From ab60045371a52985b7d5c30aacae8134d373dba1 Mon Sep 17 00:00:00 2001 From: Vansh <77244334+vansh2408@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:20:08 +0530 Subject: [PATCH 001/203] fix(shortcuts): gate Cmd/Ctrl+N folder-workspace jumps on path status (#10748) Reviewed with an independent reproduction, including a live-app check. Replaced tests that mocked the module containing the fix, and reused the existing workspace activator instead of adding a 4th copy of the dispatch. --- src/renderer/src/hooks/useIpcEvents.ts | 4 +- .../workspace-activation-path-gate.test.ts | 145 ++++++++++++++++++ src/renderer/src/lib/worktree-activation.ts | 14 +- 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/workspace-activation-path-gate.test.ts diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 775aa01e841..66b4ffd241f 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -7,7 +7,7 @@ import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-hos import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' import { applyUIZoom } from '@/lib/ui-zoom' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorktree, activateAndRevealWorkspace } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' @@ -1423,7 +1423,7 @@ export function useIpcEvents(): void { } const visibleIds = getVisibleWorktreeIds() if (index < visibleIds.length) { - activateAndRevealWorktree(visibleIds[index]) + activateAndRevealWorkspace(visibleIds[index]) } }) ) diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts new file mode 100644 index 00000000000..d8110a747fb --- /dev/null +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -0,0 +1,145 @@ +import { readFile } from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace, ProjectGroup } from '../../../shared/types' +import type { FolderWorkspacePathStatusReason } from '../../../shared/folder-workspace-path-status' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { resolveWindowShortcutAction } from '../../../shared/window-shortcut-policy' + +// Why: drives the real activation module against a real store, so a regression in +// the workspace dispatch or in the path-status guard itself both surface here. +// Mocking the module under test would let the gate be bypassed while still passing. +// Only the store singleton, the toast sink and the path-status IPC are substituted. + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), + getPathStatus: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: vi.fn() } })) + +const { createTestStore } = await import('@/store/slices/store-test-helpers') +const store = createTestStore() + +vi.mock('@/store', () => ({ useAppStore: store })) + +const { activateAndRevealWorkspace } = await import('./worktree-activation') + +const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} + +const folderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: projectGroup.id, + name: 'Unmounted folder', + folderPath: '/mnt/detached/project', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 +} + +async function seedFolderWorkspace( + status: { exists: true } | { exists: false; reason: FolderWorkspacePathStatusReason } +): Promise { + store.setState({ + projectGroups: [projectGroup], + folderWorkspaces: [folderWorkspace], + activeWorktreeId: null, + activeWorkspaceKey: null, + activeView: 'terminal', + folderWorkspacePathStatuses: {} + }) + mocks.getPathStatus.mockResolvedValue({ path: folderWorkspace.folderPath, ...status }) + // Why the real fetch: it writes the cache under the exact key + request snapshot + // that the activation guard reads back, so the fixture can't drift from production. + await store + .getState() + .fetchFolderWorkspacePathStatus( + { scope: 'folder-workspace', folderWorkspaceId: folderWorkspace.id }, + { force: true } + ) +} + +describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => { + beforeEach(() => { + mocks.toastError.mockClear() + vi.stubGlobal('window', { + api: { folderWorkspaces: { getPathStatus: mocks.getPathStatus } } + }) + }) + + it('blocks the number shortcut when the folder path is missing', async () => { + await seedFolderWorkspace({ exists: false, reason: 'missing' }) + + activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id)) + + expect(store.getState().activeWorkspaceKey).toBeNull() + expect(store.getState().activeWorktreeId).toBeNull() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) + + it('blocks the number shortcut when the SSH connection owning the folder is ambiguous', async () => { + await seedFolderWorkspace({ exists: false, reason: 'ambiguous-connection' }) + + activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id)) + + expect(store.getState().activeWorkspaceKey).toBeNull() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) + + it('still activates the number shortcut when the folder path is present', async () => { + await seedFolderWorkspace({ exists: true }) + + activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id)) + + expect(store.getState().activeWorkspaceKey).toBe(folderWorkspaceKey(folderWorkspace.id)) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + // Why per AGENTS.md: the gate must hold on Windows/Linux Ctrl too, not just Cmd. + // The digit chord resolves to the same jumpToWorktreeIndex action on every platform, + // so all three land on the guarded activator this suite pins above. + it.each([ + ['darwin' as const, { key: '1', code: 'Digit1', metaKey: true }], + ['win32' as const, { key: '1', code: 'Digit1', ctrlKey: true }], + ['linux' as const, { key: '1', code: 'Digit1', ctrlKey: true }] + ])('routes the %s number chord to jumpToWorktreeIndex', (platform, input) => { + expect(resolveWindowShortcutAction(input, platform)).toEqual({ + type: 'jumpToWorktreeIndex', + index: 0 + }) + }) + + it('does not fire the macOS chord on Windows/Linux, so Ctrl is the only path there', () => { + expect( + resolveWindowShortcutAction({ key: '1', code: 'Digit1', metaKey: true }, 'win32') + ).not.toEqual({ type: 'jumpToWorktreeIndex', index: 0 }) + }) + + // Why: the guard only helps if the IPC handler actually calls it. Pin the source + // so re-pointing the handler back at the unguarded activateAndRevealWorktree fails. + it('wires onJumpToWorktreeIndex to the guarded workspace activator', async () => { + const source = await readFile(new URL('../hooks/useIpcEvents.ts', import.meta.url), 'utf8') + const handler = source.slice( + source.indexOf('onJumpToWorktreeIndex('), + source.indexOf('onJumpToTabIndex(') + ) + expect(handler).toContain('activateAndRevealWorkspace(visibleIds[index])') + expect(handler).not.toContain('activateAndRevealWorktree(visibleIds[index])') + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 19f79bf175a..b3c9abe7fb7 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -727,14 +727,22 @@ function queueSetupAndIssueCommands( } } -// Why: break the import cycle — nav-history slice (under @/store) can't import activation directly, so register the activator here. -setWorktreeNavActivator((workspaceId) => { +/** + * Activates a sidebar workspace id of either shape. Rendered sidebar order mixes + * plain worktree ids with `folder:` keys, so every caller that navigates by that + * order must dispatch here — the folder branch is what enforces the path-status + * gate that blocks a missing/unmounted/disconnected-SSH folder (#10716). + */ +export function activateAndRevealWorkspace(workspaceId: string): ActivateAndRevealResult | false { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { return activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) } return activateAndRevealWorktree(workspaceId) -}) +} + +// Why: break the import cycle — nav-history slice (under @/store) can't import activation directly, so register the activator here. +setWorktreeNavActivator(activateAndRevealWorkspace) // Why: page entries replay via setActiveView (not open*Page) so back/forward doesn't mutate previousViewBefore* or duplicate history (see navigateToIndex). setWorktreeNavViewActivator((entry) => { From 2a640abfbe0ca7db3ae00c3204aa90fdd96c8211 Mon Sep 17 00:00:00 2001 From: David Anderson <215816+akapug@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:55:31 -0700 Subject: [PATCH 002/203] fix(pty): strip inherited Claude child-session stamps at spawn (#9961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Orca GUI or daemon launched from inside a Claude Code session inherits CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID. Every spawn path spreads the host's process.env, so each terminal Orca opens is marked a nested Claude child and Claude silently disables transcript persistence — real sessions stop writing on-disk history with no visible error. Older-protocol daemons are deliberately preserved across upgrades and the auto-updater relaunch inherits the previous app's env, so one contaminated launch propagates through subsequent updates. Orca never sets these variables, so an inherited value is always poison. Add a deny constant plus an inherited-only filter merged into envToDelete at both pty spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the agent-hook keys this is not gated on isDaemonHostSpawn, because the local provider and the relay host spread their own process.env too. Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a `string[] | undefined` intermediate into a `readonly string[]` parameter and did not typecheck); coverage extends to the runtime-controller spawn path, the local provider, and the SSH route, whose exact spawn-options assertion had to be updated because envToDelete is no longer ever undefined. --- src/main/ipc/pty.test.ts | 133 +++++++++++++++++++ src/main/ipc/pty.ts | 46 ++++--- src/main/providers/provider-dispatch.test.ts | 20 ++- 3 files changed, 178 insertions(+), 21 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 299e7b5c756..763085c0699 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1683,6 +1683,28 @@ describe('registerPtyHandlers', () => { expect(env.LANG).toBe('fr_FR.UTF-8') }) + it('strips inherited Claude child-session stamps from a local spawn env', async () => { + // Why: the local provider spreads main's process.env, so a GUI launched from + // inside a Claude session would stamp every pane as a nested child and Claude + // would silently disable transcript persistence. Not gated on isDaemonHostSpawn. + const env = await spawnAndGetEnv(undefined, { + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'session_01UCkWN5nDXNyD1V7cfamCxa' + }) + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() + }) + + it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { + const env = await spawnAndGetEnv( + { CLAUDE_CODE_CHILD_SESSION: '1' }, + { CLAUDE_CODE_CHILD_SESSION: '1' } + ) + expect(env.CLAUDE_CODE_CHILD_SESSION).toBe('1') + }) + it('always sets TERM and COLORTERM regardless of env', async () => { const env = await spawnAndGetEnv() expect(env.TERM).toBe('xterm-256color') @@ -2902,6 +2924,39 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete ?? []).not.toEqual(expect.arrayContaining(['CODEX_HOME'])) }) + it('strips inherited Claude child-session stamps from daemon spawns', async () => { + // Why: a daemon forked from inside a Claude Code session inherits these + // stamps and would mark every terminal as a nested Claude child, which + // silently disables transcript persistence for real user sessions. + const spawnOptions = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'session_01UCkWN5nDXNyD1V7cfamCxa' + }) + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ]) + ) + }) + + it('preserves an explicitly requested Claude child-session stamp', async () => { + // Why: only inherited values are poison; a caller deliberately spawning a + // nested Claude child passes the stamp in args.env and must keep it. + const spawnOptions = await daemonSpawnAndGetOptions( + { CLAUDE_CODE_CHILD_SESSION: '1' }, + undefined, + undefined, + { CLAUDE_CODE_CHILD_SESSION: '1' } + ) + expect(spawnOptions.envToDelete ?? []).not.toEqual( + expect.arrayContaining(['CLAUDE_CODE_CHILD_SESSION']) + ) + expect(spawnOptions.env.CLAUDE_CODE_CHILD_SESSION).toBe('1') + }) + it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { @@ -2983,6 +3038,84 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => { + // Why: the runtime controller is the `orca` CLI / automation spawn path and + // assembles envToDelete separately from the renderer's pty:spawn handler; + // without its own case the two paths can silently drift apart. + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ]) + ) + }) + + it('strips inherited Claude child-session stamps from a local runtime-created PTY', async () => { + // Why: the runtime strip is deliberately not gated on isDaemonHostSpawn, so + // the local provider — which spreads main's own process.env — needs its own + // case; a daemon-only test would still pass if someone added that gate. + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'handle-runtime-local') + } + const saved = process.env.CLAUDE_CODE_CHILD_SESSION + process.env.CLAUDE_CODE_CHILD_SESSION = '1' + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, env: {} }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + } finally { + if (saved === undefined) { + delete process.env.CLAUDE_CODE_CHILD_SESSION + } else { + process.env.CLAUDE_CODE_CHILD_SESSION = saved + } + } + }) + it('threads the validated pane identity into registerPty for a runtime-created daemon PTY (#7587)', async () => { type RuntimeSpawnController = { spawn(args: { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5e81dbba66d..b1a5927247d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -238,6 +238,13 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' ] as const +// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const + export function getPtyIdForPaneKey(paneKey: string): string | undefined { return paneKeyPtyId.get(paneKey) } @@ -902,14 +909,15 @@ function exposePiManagedExtensionEnv( } } +// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. function mergePtyEnvDeletions( existingKeys: string[] | undefined, - additionalKeys: readonly string[] + ...additionalKeyGroups: readonly (readonly string[])[] ): string[] | undefined { - if (!existingKeys && additionalKeys.length === 0) { + if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { return undefined } - return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) } function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { @@ -926,6 +934,15 @@ function getInheritedAgentHookEnvKeysToDelete( return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) } +function getInheritedClaudeSessionStampEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: strip only values inherited from the pty host; a caller that explicitly + // provides a stamp (deliberately spawning a nested Claude child) keeps it. + return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) +} + // Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. function restoreOrStripOverlayEnv( baseEnv: Record, @@ -3328,8 +3345,11 @@ export function registerPtyHandlers( args.onPtySpawnCommitted?.() } spawnOptions.envToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] + authEnvToDelete, + args.envToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], + // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. + getInheritedClaudeSessionStampEnvKeysToDelete(env) ) if (skipCodexHomeEnv) { spawnOptions.envToDelete = mergePtyEnvDeletions( @@ -4473,16 +4493,12 @@ export function registerPtyHandlers( ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined let combinedEnvToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), - agentTeamsEnvToDelete ?? [] - ), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] - ), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] - ), + envToDelete, + args.envToDelete ?? [], + agentTeamsEnvToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; // main cannot safely decide ownership for a process it may not parent. stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index be45aa04ff3..57d7e3f815c 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -141,12 +141,20 @@ describe('PTY provider dispatch', () => { })) as { id: string } expect(result.id).toBe('ssh-pty-1') - expect(mockSshProvider.spawn).toHaveBeenCalledWith({ - cols: 80, - rows: 24, - cwd: undefined, - env: undefined - }) + // Why: the relay host can be launched from a Claude session too, so the stamps are + // stripped on the SSH path as well. Compared as a set — envToDelete is consumed by + // membership only, so a reordering of the merge sources must not fail this. + const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] + expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( + [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ].sort() + ) + expect(mockSshProvider.spawn).toHaveBeenCalledWith( + expect.objectContaining({ cols: 80, rows: 24, cwd: undefined, env: undefined }) + ) unregisterSshPtyProvider('conn-123') }) From a065db154c91e14ca9cdef699816986f2877167d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:57:53 -0700 Subject: [PATCH 003/203] fix(sidebar): spin the worktree dot while Claude Code is thinking (#9040) (#10684) Co-authored-by: Orca --- .../tab-bar/terminal-tab-activity-status.ts | 5 +- .../terminal-tab-spinner-launch-agent.test.ts | 48 +++++++ ...rktree-status-spinner-launch-agent.test.ts | 136 ++++++++++++++++++ src/renderer/src/lib/worktree-status.ts | 30 ++-- 4 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts create mode 100644 src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 211aae2c3b2..b64ba41b0f5 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -104,7 +104,10 @@ function parseAgentStatusPaneKey(paneKey: string): { tabId: string; paneId: stri const EMPTY_PANE_IDS: ReadonlySet = new Set() type TerminalTabActivityInput = { - tab: Pick + // Why: launchAgent is read, not just carried — the status gate needs it to attribute a + // bare spinner title to an agent (#9040). Narrowing it away here compiles (it is optional) + // but silently drops the tab-bar dot back to the pre-#9040 behavior. + tab: Pick agentStatusByPaneKey?: Record // Why: the store bumps this at the 30m stale boundary without replacing the // pane-status map; it is the flag cache's invalidation key (see above). diff --git a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts new file mode 100644 index 00000000000..4259ff8ade3 --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { + resetTerminalTabActivityFlagsCacheForTest, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' + +// Why: #9040 — the tab-bar dot shares the sidebar's attribution gate, so Claude's +// token-less spinner title must reach 'working' here too. TerminalTabActivityInput +// narrows `tab` with a Pick; because launchAgent is optional, dropping it from that +// Pick compiles cleanly and would silently revert this path alone. +describe('#9040 terminal tab dot attributes spinner titles to the launched agent', () => { + beforeEach(() => { + resetTerminalTabActivityFlagsCacheForTest() + }) + + it('reports working for a Claude spinner title on a live tab', () => { + const status = resolveTerminalTabActivityStatus({ + tab: { + id: 'tab-1', + title: '⠋ implementing the feature', + launchAgent: 'claude' + } satisfies Partial as TerminalTab, + ptyIdsByTabId: { 'tab-1': ['pty-0'] } + }) + + expect(status).toBe('working') + }) + + // Control: the named-provider path this must stay at parity with. + it('reports working for a named-provider title', () => { + const status = resolveTerminalTabActivityStatus({ + tab: { id: 'tab-1', title: 'claude [working]' } as TerminalTab, + ptyIdsByTabId: { 'tab-1': ['pty-0'] } + }) + + expect(status).toBe('working') + }) + + it('stays out of working for a spinner title with no launch identity', () => { + const status = resolveTerminalTabActivityStatus({ + tab: { id: 'tab-1', title: '⠐ Review branch for regressions' } as TerminalTab, + ptyIdsByTabId: { 'tab-1': ['pty-0'] } + }) + + expect(status).not.toBe('working') + }) +}) diff --git a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts new file mode 100644 index 00000000000..34bc7916164 --- /dev/null +++ b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { buildTitleDerivedAgentRows } from '@/components/sidebar/worktree-title-derived-agent-rows' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' +import { getWorktreeStatus } from './worktree-status' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function livePtyMap(...tabIds: string[]): Record { + return Object.fromEntries(tabIds.map((id, i) => [id, [`pty-${i}`]])) +} + +function singleLeafLayout(): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + titlesByLeafId: {} + } as unknown as TerminalLayoutSnapshot +} + +function rowCount(tab: Partial, paneTitles?: Record): number { + return buildTitleDerivedAgentRows({ + tabs: [{ id: 'tab-1', title: 'bash', ...tab } as TerminalTab], + runtimePaneTitlesByTabId: paneTitles ? { 'tab-1': paneTitles } : {}, + ptyIdsByTabId: livePtyMap('tab-1'), + terminalLayoutsByTabId: { 'tab-1': singleLeafLayout() }, + seenPaneKeys: new Set(), + now: 0 + }).length +} + +// Why: #9040 — Claude's thinking title is a braille spinner plus task text with no +// provider token, so the dot's attribution gate rejected it and the worktree resolved +// to 'active', which renders the same emerald dot as 'done'. The sidebar row builder +// already falls back to the tab's launch identity for spinner titles (#9647); the dot +// must agree. +describe('#9040 worktree dot attributes spinner titles to the launched agent', () => { + it('spins for a Claude spinner title when the tab was launched as claude', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: '⠋ implementing the feature', launchAgent: 'claude' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('working') + }) + + it('spins for a spinner-only pane title when the tab was launched as claude', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], + [], + livePtyMap('tab-1'), + { 'tab-1': { 0: '⠙ refactoring the parser' } } + ) + + expect(status).toBe('working') + }) + + // Why: pins the #9647 gate — spinner attribution needs a launch identity, so a + // spinner in a tab no agent was launched in cannot spin the dot. + it('stays active for a spinner title with no launch identity', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('active') + }) + + it('does not manufacture activity from a non-spinner title with a launch identity', () => { + const status = getWorktreeStatus( + [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], + [], + livePtyMap('tab-1') + ) + + expect(status).toBe('active') + }) +}) + +// Why: launchAgent usually clears on exit (clearTabLaunchAgent via +// resolveLaunchedAgentExitEvidence), but not on every path — a hookless remote agent has +// no completion hook and no shell-foreground producer, so its launch identity can outlive +// it and a later ora-style spinner then reads as working. Documented trade-off, not an +// oversight — the row builder has behaved this way since #9647. Pinned so it stays a +// deliberate choice rather than drifting silently. +describe('#9040 spinner attribution trade-off is bounded', () => { + it('over-reports a non-agent spinner in a tab an agent was launched in', () => { + const tab = { + id: 'tab-1', + title: '⠋ Progress: resolved 42', + launchAgent: 'claude' + } satisfies Partial + + expect(getWorktreeStatus([tab], [], livePtyMap('tab-1'))).toBe('working') + // Bound 1: the same title cannot spin a tab with no launch identity. + expect(getWorktreeStatus([{ id: 'tab-1', title: tab.title }], [], livePtyMap('tab-1'))).toBe( + 'active' + ) + // Bound 2: a dead PTY drops out regardless of launch identity. + expect(getWorktreeStatus([tab], [], {})).toBe('inactive') + }) +}) + +// Why: the gate's stated purpose is that the dot never spins with no matching sidebar +// row. Claude's spinner title must reach the same dot/row agreement a named provider +// already gets — no better, and no worse. +describe('#9040 spinner attribution matches named-provider dot/row agreement', () => { + it('produces a sidebar row alongside the dot, like a named provider does', () => { + const spinnerTab = { + id: 'tab-1', + title: '⠋ implementing the feature', + launchAgent: 'claude' + } satisfies Partial + const namedTab = { id: 'tab-1', title: 'claude [working]' } + + expect(getWorktreeStatus([spinnerTab], [], livePtyMap('tab-1'))).toBe('working') + expect(rowCount(spinnerTab)).toBe(1) + // Control: the pre-existing named-provider path resolves to the same pair. + expect(getWorktreeStatus([namedTab], [], livePtyMap('tab-1'))).toBe('working') + expect(rowCount(namedTab)).toBe(1) + }) + + it('agrees for a spinner pane title too', () => { + const tab = { id: 'tab-1', title: 'bash', launchAgent: 'claude' } satisfies Partial + const paneTitles = { 'tab-1': { 0: '⠙ refactoring the parser' } } + const layouts = { 'tab-1': singleLeafLayout() } + + expect( + getWorktreeStatus([tab], [], livePtyMap('tab-1'), paneTitles, { + terminalLayoutsByTabId: layouts + }) + ).toBe('working') + expect(rowCount(tab, paneTitles['tab-1'])).toBe(1) + }) +}) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 902e3cb2257..e12d197dda9 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -2,10 +2,12 @@ import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' +import { containsBrailleSpinner } from '../../../shared/agent-title-core' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, - TerminalTab + TerminalTab, + TuiAgent } from '../../../shared/types' import type { LiveAgentWorktreeStatus } from './worktree-activity-state' @@ -27,7 +29,7 @@ const STATUS_LABELS: Record = { } export function getWorktreeStatus( - tabs: readonly Pick[], + tabs: readonly Pick[], browserTabs: readonly { id: string }[], ptyIdsByTabId: Record, runtimePaneTitlesByTabId: Record> = {}, @@ -54,7 +56,7 @@ export function getWorktreeStatus( } function tabHasStatus( - tab: Pick, + tab: Pick, runtimePaneTitlesByTabId: Record>, status: 'permission' | 'working', options: WorktreeStatusHeuristicOptions @@ -77,7 +79,10 @@ function tabHasStatus( ) { continue } - if (classifyTitleActivity(title) === status && titleStatusIsAgentAttributable(title)) { + if ( + classifyTitleActivity(title) === status && + titleStatusIsAgentAttributable(title, tab.launchAgent) + ) { return true } } @@ -87,12 +92,21 @@ function tabHasStatus( if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } - return classifyTitleActivity(tab.title) === status && titleStatusIsAgentAttributable(tab.title) + return ( + classifyTitleActivity(tab.title) === status && + titleStatusIsAgentAttributable(tab.title, tab.launchAgent) + ) } // Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row. -function titleStatusIsAgentAttributable(title: string): boolean { - return resolveAgentTypeFromTerminalTitle(title) !== null +function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | null): boolean { + if (resolveAgentTypeFromTerminalTitle(title) !== null) { + return true + } + // Why: a spinner proves activity but not identity (Claude's thinking title has no provider + // token, #9040); the tab's launch identity supplies it, mirroring the row builder's spinner + // fallback (#9647) so the dot and the sidebar row agree. + return containsBrailleSpinner(title) && Boolean(launchAgent) } export function getWorktreeStatusLabel(status: WorktreeStatus): string { @@ -109,7 +123,7 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { * `hasRetainedDone` is a retained-agent snapshot scoped to this worktreeId. */ export function resolveWorktreeStatus(args: { - tabs: readonly Pick[] + tabs: readonly Pick[] browserTabs: readonly { id: string }[] ptyIdsByTabId: Record runtimePaneTitlesByTabId?: Record> From 0ab5f499cb99b99102a0e8fae855422a984f6bab Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:06:17 -0700 Subject: [PATCH 004/203] fix(cmd-j): restore focus when issue match routing declines (#11010) --- .../src/components/WorktreeJumpPalette.tsx | 9 +++++++-- .../palette-activation-focus-routing.test.ts | 16 +++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index de0259c1529..61f3a4eb52c 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1525,7 +1525,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { closeModal() // Why: #9939 — jumping to an already-open workspace must focus its own terminal. const activation = activateAndRevealWorktree(activeMatch.id) - queueWorkspaceActivationTerminalFocus(activeMatch.id, activation) + if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) { + focusFallbackSurface() + } recordFeatureInteraction('cmd-j-workspace-open') return } @@ -1554,7 +1556,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { closeModal() // Why: #9939 — jumping to an already-open workspace must focus its own terminal. const activation = activateAndRevealWorktree(activeMatch.id) - queueWorkspaceActivationTerminalFocus(activeMatch.id, activation) + if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) { + focusFallbackSurface() + } recordFeatureInteraction('cmd-j-workspace-open') return } @@ -1628,6 +1632,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { closeModal, createLookupGuard, createWorktreeName, + focusFallbackSurface, openModal, prefetchCreateWorkspaceBaseForComposer, recordFeatureInteraction, diff --git a/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts b/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts index 0222a01910b..c63948e7c89 100644 --- a/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts +++ b/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts @@ -45,14 +45,20 @@ describe('Cmd+J activation focus routing (#9939)', () => { expect(handler).not.toMatch(/focusFallbackSurface\(\)/) }) - it('focuses the destination workspace when jumping to an already-open issue match', () => { - const source = paletteSource() + it('falls back when scoped focus declines for an already-open issue match', () => { + const handler = sourceBetween( + paletteSource(), + 'const handleCreateWorktree = useCallback', + 'const handleCloseAutoFocus' + ) const activationCalls = - source.match(/const activation = activateAndRevealWorktree\(activeMatch\.id\)/g)?.length ?? 0 - // Both issue-number match paths (PR and issue lookup) must focus the destination. + handler.match(/const activation = activateAndRevealWorktree\(activeMatch\.id\)/g)?.length ?? 0 + // Both issue/PR match paths must restore focus when terminal routing declines. expect(activationCalls).toBe(2) expect( - source.match(/queueWorkspaceActivationTerminalFocus\(activeMatch\.id, activation\)/g)?.length + handler.match( + /if \(!queueWorkspaceActivationTerminalFocus\(activeMatch\.id, activation\)\) \{\s*focusFallbackSurface\(\)\s*\}/g + )?.length ).toBe(activationCalls) }) }) From 3716a7bb49dda294ea987c44eaa0151e566e0507 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 27 Jul 2026 16:17:58 -0700 Subject: [PATCH 005/203] fix(markdown): render task continuations as text (#11008) --- .../src/assets/rich-markdown-editor.css | 16 ++++---- .../rich-markdown-task-list-style.test.ts | 15 ++++++++ .../editor/markdown-round-trip.test.ts | 25 +++++++++++++ .../editor/rich-markdown-extensions.ts | 4 +- .../editor/rich-markdown-task-list.ts | 37 +++++++++++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/assets/rich-markdown-task-list-style.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-task-list.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 4b918455221..66dddbabee2 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -628,13 +628,13 @@ padding-left: 0.75em; } -.rich-markdown-editor ul[data-type='taskList'] li { +.rich-markdown-editor ul[data-type='taskList'] > li { display: flex; align-items: flex-start; gap: 6px; } -.rich-markdown-editor ul[data-type='taskList'] li > label { +.rich-markdown-editor ul[data-type='taskList'] > li > label { flex-shrink: 0; display: flex; align-items: center; @@ -643,7 +643,7 @@ user-select: none; } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox'] { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox'] { appearance: none; width: 16px; height: 16px; @@ -657,12 +657,12 @@ flex-shrink: 0; } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox']:checked { background: var(--primary); border-color: var(--primary); } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked::after { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox']:checked::after { content: ''; position: absolute; left: 4px; @@ -677,16 +677,16 @@ transform: rotate(45deg); } -.rich-markdown-editor ul[data-type='taskList'] li > div { +.rich-markdown-editor ul[data-type='taskList'] > li > div { flex: 1; min-width: 0; } -.rich-markdown-editor ul[data-type='taskList'] li > div > p { +.rich-markdown-editor ul[data-type='taskList'] > li > div > p { margin: 0; } -.rich-markdown-editor ul[data-type='taskList'] li[data-checked='true'] > div { +.rich-markdown-editor ul[data-type='taskList'] > li[data-checked='true'] > div { text-decoration: line-through; color: var(--muted-foreground); } diff --git a/src/renderer/src/assets/rich-markdown-task-list-style.test.ts b/src/renderer/src/assets/rich-markdown-task-list-style.test.ts new file mode 100644 index 00000000000..5f9a1cac42e --- /dev/null +++ b/src/renderer/src/assets/rich-markdown-task-list-style.test.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const editorCss = fs.readFileSync(new URL('./rich-markdown-editor.css', import.meta.url), 'utf8') + +describe('rich markdown task-list styling', () => { + it('keeps flex layout scoped to direct task items', () => { + expect(editorCss).toMatch( + /\.rich-markdown-editor ul\[data-type='taskList'\] > li\s*{[^}]*display:\s*flex/s + ) + expect(editorCss).not.toMatch( + /\.rich-markdown-editor ul\[data-type='taskList'\] li\s*{[^}]*display:\s*flex/s + ) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 029692d836f..c1ab843544e 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -281,6 +281,31 @@ describe('rich markdown round trip', () => { ) }) + it('preserves aligned task-item continuations before nested bullets', () => { + const input = [ + '- [ ] Complete the provider action map used by the', + ' unchanged UI:', + ' - review creation and eligibility;', + ' - merge and auto-merge.', + '- [ ] Keep provider behavior explicit.' + ].join('\n') + + expect(roundTripMarkdown(input)).toBe( + [ + '- [ ] Complete the provider action map used by the', + '', + ' unchanged UI:', + ' - review creation and eligibility;', + ' - merge and auto-merge.', + '- [ ] Keep provider behavior explicit.' + ].join('\n') + ) + }) + + it('preserves blank-separated indented code inside task items', () => { + expect(roundTripMarkdown('- [ ] Run this:\n\n echo ok\n')).toContain('```') + }) + it('preserves doc links', () => { expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe( 'See [[setup-guide]] for details' diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index a19e307b5cf..abe3e0a1134 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -5,7 +5,6 @@ import { Code } from '@tiptap/extension-code' import Image from '@tiptap/extension-image' import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' import Placeholder from '@tiptap/extension-placeholder' -import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' @@ -33,6 +32,7 @@ import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown- import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import { RichMarkdownTaskList } from './rich-markdown-task-list' const lowlight = createLowlight(common) @@ -190,7 +190,7 @@ export function createRichMarkdownExtensions({ }).configure({ allowBase64: true }), - TaskList, + RichMarkdownTaskList, TaskItem.configure({ nested: true }), diff --git a/src/renderer/src/components/editor/rich-markdown-task-list.ts b/src/renderer/src/components/editor/rich-markdown-task-list.ts new file mode 100644 index 00000000000..9b59e4af5ea --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-task-list.ts @@ -0,0 +1,37 @@ +import type { MarkdownLexerConfiguration, MarkdownToken, MarkdownTokenizer } from '@tiptap/core' +import TaskList from '@tiptap/extension-task-list' + +const baseTokenizer = TaskList.config.markdownTokenizer as MarkdownTokenizer + +function normalizeTaskListToken(token: MarkdownToken, lexer: MarkdownLexerConfiguration): void { + const firstNested = token.nestedTokens?.[0] + if ( + token.type === 'taskItem' && + firstNested?.type === 'code' && + firstNested.codeBlockStyle === 'indented' && + typeof firstNested.text === 'string' + ) { + // Tiptap re-lexes aligned no-blank task continuations as indented code. + firstNested.type = 'paragraph' + firstNested.raw = firstNested.text + firstNested.tokens = lexer.inlineTokens(firstNested.text) + delete firstNested.codeBlockStyle + } + + for (const child of [...(token.items ?? []), ...(token.nestedTokens ?? [])]) { + normalizeTaskListToken(child, lexer) + } +} + +export const RichMarkdownTaskList = TaskList.extend({ + markdownTokenizer: { + ...baseTokenizer, + tokenize(src, tokens, lexer) { + const token = baseTokenizer.tokenize(src, tokens, lexer) + if (token) { + normalizeTaskListToken(token, lexer) + } + return token + } + } +}) From 1fcbf8e5fe6b6f84e539725adb2b6be970d8b5ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:19:22 -0700 Subject: [PATCH 006/203] fix(dashboard): stop the agent icon shrinking on long card titles (#11001) A bare flex item shrinks with its row, so kanban cards and the terminal dialog rendered a squashed ~9px agent icon whenever the worktree name overflowed. Wrap both in the shrink-0 span every other surface already uses. --- .../src/components/dashboard-popout/AgentKanbanCard.tsx | 5 ++++- .../src/components/dashboard-popout/AgentTerminalDialog.tsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index e92dbcb0640..e8d08b525d6 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -86,7 +86,10 @@ export const AgentKanbanCard = memo( )} >
- + {/* Why: a bare flex item shrinks with the row — long worktree names squashed the icon. */} + + +
- + + + {card.worktreeName} From 3baffb49ff307fa725202046ca5d3be73cf51ef9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:21:54 -0700 Subject: [PATCH 007/203] fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): refuse SSH hosts in project setup instead of acting locally projectHostSetup.clone and .setupExistingFolder threaded executionHostId all the way down but never used it for routing: cloneRepo runs a local mkdir plus a local gitSpawn, and addRepo probes the path with existsSync/statSync. An `ssh:` host therefore cloned and validated on the *local* machine and then registered the result as living on the SSH host. It only failed loudly here because the remote path did not exist locally. With a plausible destination the clone succeeds and writes a setup record pointing at the wrong machine. Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path, which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`. Fail closed until the RPC learns to route through the SSH providers. * test(runtime): make the SSH guard test observe the corruption it names The test asserted `gitSpawn` was never called and no repo was registered, but neither assertion could fail. `/home/brennan` is unwritable on macOS, so the pre-guard clone died at `mkdir` before reaching `gitSpawn`, and `/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact side effects under test were unreachable either way. `rejects.toThrow` also aborted the test before those lines ran. Use a real temp destination and a real temp git repo, await both calls via `.catch`, and assert the side effects before the wording. With the guard disabled the test now fails on `gitSpawn` being called once with a real `git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'` — the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is stubbed so a regression records the call instead of hitting the network. Also document the SSH restriction on `project setup-existing-folder`, which the guard now rejects. `setup-clone` already carried that note; its sibling did not. --- src/cli/specs/project.ts | 5 ++- src/main/runtime/orca-runtime.test.ts | 61 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 16 +++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 989a83c9f77..caeabda3de7 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -27,7 +27,10 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'], - notes: ['For remote runtimes, --path must be an absolute path on the remote server.'], + notes: [ + 'For remote runtimes, --path must be an absolute path on the remote server.', + 'SSH targets are set up through the desktop UI because the desktop client owns SSH connections.' + ], examples: [ 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 007b23f3fef..8486af362eb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7160,6 +7160,67 @@ describe('OrcaRuntimeService', () => { } }) + it('refuses SSH hosts instead of setting the project up on the local machine', async () => { + // Why: both inputs must be paths the pre-guard code would have accepted. An unwritable + // destination fails at mkdir and a non-repo path fails at isGitRepo, which would leave the + // side-effect assertions below unable to observe the local clone/probe they exist to catch. + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-ssh-guard-')) + const existingFolder = join(destination, 'orca') + mkdirSync(existingFolder, { recursive: true }) + execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' }) + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => { + // Why: unreachable while the guard holds; stubbed so a regression records the call + // instead of shelling out to a real network clone. + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 1, null)) + return proc as never + }) + const repos: Record[] = [] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const cloneError = await runtime + .setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw', + url: 'https://example.com/orca.git', + destination + }) + .catch((error: unknown) => error) + const existingFolderError = await runtime + .setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw', + path: existingFolder, + kind: 'git' + }) + .catch((error: unknown) => error) + + // Why: the defect was a silent local clone/probe recorded as remote, not a bad message, + // so the absent side effects are asserted before the wording. Both calls are awaited + // first so a regression reports the corruption rather than stopping at the first throw. + expect(spawnSpy).not.toHaveBeenCalled() + expect(repos).toHaveLength(0) + expect(cloneError).toMatchObject({ + message: expect.stringMatching(/SSH hosts are not supported/) + }) + expect(existingFolderError).toMatchObject({ + message: expect.stringMatching(/SSH hosts are not supported/) + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('adopts public clone repos into host-qualified project setup', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 34004203d41..03df14bebf6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1823,6 +1823,19 @@ function runtimeRepoMatchesExecutionHost( return repo.connectionId == null } +// Why: this runtime only has local git and local fs, so an ssh: host here would clone and +// probe the wrong machine and then register the result as remote. SSH setup is owned by the +// desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to; +// only `local` and `runtime:` legitimately reach these RPCs. +function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void { + if (parseExecutionHostId(hostId)?.kind !== 'ssh') { + return + } + throw new Error( + 'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.' + ) +} + function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } @@ -15309,6 +15322,7 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } + assertProjectHostSetupHostIsSupported(args.hostId) let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId) let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { @@ -15351,6 +15365,8 @@ export class OrcaRuntimeService { } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { + // Why: guard before cloneRepo, which would otherwise clone to the local disk. + assertProjectHostSetupHostIsSupported(args.hostId) const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return await this.setupProjectExistingFolder({ projectId: args.projectId, From abcdc04f6b935980f8dd9371493ced23e6725bae Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 27 Jul 2026 18:26:43 -0500 Subject: [PATCH 008/203] fix(ci): mirror missing lint steps in PR workflow (#10601) (#10623) Reviewed with an independent reproduction. Added the allowlist entry that unblocked verify:localization-coverage on main, the 4th drifted step, and a parity gate that fails when pnpm lint's chain contains a script absent from pr.yml. --- .github/workflows/pr.yml | 17 ++++ config/localization-coverage-allowlist.json | 28 ++++++ .../scripts/pr-workflow-lint-parity.test.mjs | 86 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 config/scripts/pr-workflow-lint-parity.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9626ed68406..a20edab4f44 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -31,9 +31,15 @@ jobs: - name: Lint run: pnpm exec oxlint --format github + - name: Check switch exhaustiveness + run: pnpm run lint:switch-exhaustiveness + - name: Check styled scrollbars run: pnpm check:styled-scrollbars + - name: Check quadratic buffer concatenation + run: pnpm run check:quadratic-buffer-concat + - name: Check reliability gate manifest run: pnpm run check:reliability-gates @@ -46,6 +52,17 @@ jobs: - name: Verify skill freshness manifest run: pnpm run verify:skill-bundle-manifest + - name: Verify localization catalog + run: pnpm run verify:localization-catalog + + - name: Verify localization coverage + run: pnpm run verify:localization-coverage + + # Why: project-owned type declarations must live in .ts so tsc + # actually checks them. TypeScript's skipLibCheck: true (inherited + # from @electron-toolkit/tsconfig) silently widens unresolved names + # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature + # past typecheck. See docs/preload-typecheck-hole.md. - name: Guard against project-owned .d.ts in preload/shared run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index b40959e7e03..2a3b4603f36 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -40,5 +40,33 @@ "text": "Idioma", "dynamic": false, "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", + "kind": "object-property:keywords", + "text": "Ghostty", + "dynamic": false, + "count": 2 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", + "kind": "object-property:keywords", + "text": "ghostty", + "dynamic": false, + "count": 2 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "kind": "object-property:keywords", + "text": "Ghostty", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "kind": "object-property:keywords", + "text": "ghostty", + "dynamic": false, + "count": 1 } ] diff --git a/config/scripts/pr-workflow-lint-parity.test.mjs b/config/scripts/pr-workflow-lint-parity.test.mjs new file mode 100644 index 00000000000..648714f244c --- /dev/null +++ b/config/scripts/pr-workflow-lint-parity.test.mjs @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure +// does not mask the rest and oxlint keeps its `--format github` annotations. +// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so +// this gate fails the moment a `lint` step has no counterpart in pr.yml. + +// Flags that only change reporting, so they must not split two otherwise identical commands. +const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter']) +const REPORTING_FLAGS = new Set(['--quiet']) +const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node']) + +function splitCommandChain(command) { + return command + .split(/\n|&&|;/) + .map((part) => part.trim()) + .filter(Boolean) +} + +function canonicalize(command) { + const tokens = command.split(/\s+/) + const canonical = [] + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) { + continue + } + if (REPORTING_FLAGS.has(token)) { + continue + } + if (REPORTING_FLAGS_WITH_VALUE.has(token)) { + index += 1 + continue + } + canonical.push(token) + } + + return canonical.join(' ') +} + +/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */ +function resolveLeafCommands(command, scripts, seen = new Set()) { + const leaves = [] + + for (const part of splitCommandChain(command)) { + const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1] + if (scriptName && scripts[scriptName] && !seen.has(scriptName)) { + leaves.push( + ...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName])) + ) + continue + } + leaves.push(canonicalize(part)) + } + + return leaves +} + +describe('PR workflow lint parity', () => { + it('runs every `pnpm lint` step on pull requests', () => { + const { scripts } = JSON.parse(readFileSync('package.json', 'utf8')) + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + + // Scan every job: which one hosts the lint steps is an organizational + // detail that has already been renamed once (verify -> static_analysis). + const workflowCommands = new Set( + Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .filter((step) => typeof step.run === 'string') + .flatMap((step) => resolveLeafCommands(step.run, scripts)) + ) + + const missing = resolveLeafCommands(scripts.lint, scripts).filter( + (leaf) => !workflowCommands.has(leaf) + ) + + expect( + missing, + `.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` + + 'Add a step for each one so PR CI matches `pnpm lint`.' + ).toEqual([]) + }) +}) From cef52c68befc32d6c93f752612f09bb9c6b62a4a Mon Sep 17 00:00:00 2001 From: giraphant Date: Tue, 28 Jul 2026 07:26:51 +0800 Subject: [PATCH 009/203] fix(runtime): stop rejecting ui.set when Linear or Jira resume fields are present (#10715) Reviewed with an independent reproduction. Extended the fix to the UiUpdate parity gaps one level up, and added a typecheck-level assertion so future field drift fails the build instead of silently rejecting paired-client payloads. --- .../runtime/rpc/methods/client-ui-schemas.ts | 60 +++++++++--- .../runtime/rpc/methods/client-ui.test.ts | 96 ++++++++++++++++++- .../rpc/methods/task-resume-state-schema.ts | 33 +++++++ .../rpc/methods/ui-state-schema-parity.ts | 14 +++ 4 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/rpc/methods/task-resume-state-schema.ts create mode 100644 src/main/runtime/rpc/methods/ui-state-schema-parity.ts diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 7265a1ee806..6b8862b1ec2 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -13,7 +13,9 @@ import { isTaskProvider } from '../../../../shared/task-providers' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' -import type { TaskProvider } from '../../../../shared/types' +import type { PersistedUIState, TaskProvider } from '../../../../shared/types' +import { TaskResumeState } from './task-resume-state-schema' +import type { AssertNoMissingKeys } from './ui-state-schema-parity' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) @@ -59,16 +61,6 @@ const WorkspaceStatusDefinition = z.object({ color: z.string().optional(), icon: z.string().optional() }) -const TaskResumeState = z - .object({ - githubMode: z.enum(['items', 'project']).optional(), - githubItemsPreset: z.string().nullable().optional(), - githubItemsQuery: z.string().optional(), - githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - linearQuery: z.string().optional() - }) - .strict() const WorkspaceCleanupDismissal = z .object({ worktreeId: z.string(), @@ -173,6 +165,21 @@ export const UiUpdate = z .object({ lastActiveRepoId: NullableString.optional(), lastActiveWorktreeId: NullableString.optional(), + // Why: App.tsx persists this on every top-level view switch (#9002). Desktop + // hydration ignores it on 'sync' broadcasts, so accepting it cannot yank a + // paired window's current view — it only restores the view on next startup. + activeView: z + .enum([ + 'terminal', + 'settings', + 'tasks', + 'activity', + 'automations', + 'space', + 'skills', + 'mobile' + ]) + .optional(), sidebarWidth: z.number().finite().optional(), rightSidebarOpen: z.boolean().optional(), rightSidebarTab: z @@ -197,6 +204,9 @@ export const UiUpdate = z .optional(), hideDefaultBranchWorkspace: z.boolean().optional(), hideAutomationGeneratedWorkspaces: z.boolean().optional(), + // Why: rides App.tsx's debounced writer, so omitting it rejected that entire + // payload (sidebar widths, filters, agent acks) for every paired client. + showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), hideCliCreatedWorkspaces: z.boolean().optional(), hideDetachedHeadWorkspaces: z.boolean().optional(), filterRepoIds: StringArray.optional(), @@ -253,6 +263,14 @@ export const UiUpdate = z _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), setupScriptPromptDismissedRepoIds: StringArray.optional(), + // Why: one-shot dismissals the renderer writes through ui.set; each was a + // whole-payload rejection for paired clients while unlisted. + setupGuideSidebarDismissed: z.boolean().optional(), + setupGuideBrowserMilestoneMigrated: z.boolean().optional(), + setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(), + browserImportHintHidden: z.boolean().optional(), + mobileEmulatorTabIntroDismissed: z.boolean().optional(), + mobileEmulatorAgentSetupDismissed: z.boolean().optional(), projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), @@ -273,3 +291,23 @@ export const UiUpdate = z }) .strict() .default({}) + +// Why: state only the main process ever writes (store.updateUI, star-nag's own +// IPC, window lifecycle). Clients never send these, so keeping them out of the +// strict schema is deliberate — but it must stay deliberate rather than +// forgotten, which is what the parity assertion below enforces. +type MainOwnedUIState = + | 'trayMinimizeNoticeShown' + | 'dashboardPopoutBounds' + | '_expandedWorktreeCardPropertiesDefaulted' + | 'starNagBaselineAgents' + | 'starNagAppVersion' + | 'starNagNextThreshold' + | 'starNagCompleted' + | 'starNagDeferredUntil' + | 'starNagAgentValueMomentAppVersion' +const _uiUpdateParity: AssertNoMissingKeys< + Omit, + z.infer +> = true +void _uiUpdateParity diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4accec2b0e3..03b0f698756 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -437,7 +437,16 @@ describe('client UI RPC methods', () => { githubItemsQuery: 'is:open', githubProjectHiddenFieldIdsByView: { 'project-1:view-1': ['field-1'] - } + }, + linearMode: 'projects', + linearContext: { + kind: 'project', + id: 'project-9', + workspaceId: 'workspace-1', + model: 'project' + }, + jiraPreset: 'assigned', + jiraQuery: 'ENG' }, workspaceCleanup: { dismissals: { @@ -479,7 +488,16 @@ describe('client UI RPC methods', () => { githubItemsQuery: 'is:open', githubProjectHiddenFieldIdsByView: { 'project-1:view-1': ['field-1'] - } + }, + linearMode: 'projects', + linearContext: { + kind: 'project', + id: 'project-9', + workspaceId: 'workspace-1', + model: 'project' + }, + jiraPreset: 'assigned', + jiraQuery: 'ENG' }, workspaceCleanup: { dismissals: { @@ -510,6 +528,80 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ui: updated } }) }) + // Why one case per field: the schema is strict, so a single unlisted key makes + // the dispatcher reject the ENTIRE ui.set payload with invalid_argument instead + // of stripping it. A combined payload would pass as soon as any one field were + // restored, hiding the rest of the drift. + it.each([ + ['taskResumeState.linearMode', { taskResumeState: { linearMode: 'projects' } }], + [ + 'taskResumeState.linearContext', + { + taskResumeState: { + linearContext: { kind: 'project', id: 'project-9', workspaceId: 'workspace-1' } + } + } + ], + ['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }], + ['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }], + ['activeView', { activeView: 'tasks' }], + ['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }], + ['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }], + ['setupGuideBrowserMilestoneMigrated', { setupGuideBrowserMilestoneMigrated: true }], + [ + 'setupGuideBrowserMilestoneLegacyComplete', + { setupGuideBrowserMilestoneLegacyComplete: true } + ], + ['browserImportHintHidden', { browserImportHintHidden: true }], + ['mobileEmulatorTabIntroDismissed', { mobileEmulatorTabIntroDismissed: true }], + ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }] + ])('accepts %s, which the renderer persists through ui.set', async (_label, payload) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith(payload) + }) + + it('accepts the whole debounced App writer payload', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + // Mirrors App.tsx's 150ms writer: one unlisted key here dropped every other + // preference in the same call for paired web/SSH/relay clients. + const payload = { + sidebarWidth: 280, + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + rightSidebarWidth: 320, + markdownTocPanelWidth: 200, + groupBy: 'repo', + sortBy: 'smart', + projectOrderBy: 'manual', + showActiveOnly: false, + hideSleepingWorkspaces: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + showDotfilesByWorktree: { 'repo::/worktree': true }, + filterRepoIds: ['repo-1'], + acknowledgedAgentsByPaneKey: { 'pane-1': 123 } + } + + const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith(payload) + }) + it('records a feature interaction through the runtime host', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts new file mode 100644 index 00000000000..825248ac4e7 --- /dev/null +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/types' +import type { AssertNoMissingKeys } from './ui-state-schema-parity' + +/** Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. */ +export const TaskResumeState = z + .object({ + githubMode: z.enum(['items', 'project']).optional(), + githubItemsPreset: z.string().nullable().optional(), + githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), + linearMode: z.enum(['issues', 'projects', 'views']).optional(), + linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + linearQuery: z.string().optional(), + linearContext: z + .object({ + kind: z.enum(['project', 'view']), + id: z.string(), + workspaceId: z.string(), + model: z.enum(['issue', 'project']).optional() + }) + .strict() + .optional(), + jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(), + jiraQuery: z.string().optional() + }) + .strict() + +const _taskResumeStateParity: AssertNoMissingKeys< + TaskResumeStateType, + z.infer +> = true +void _taskResumeStateParity diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts new file mode 100644 index 00000000000..5ed5e5d91ca --- /dev/null +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts @@ -0,0 +1,14 @@ +/** + * The client-facing UI schemas are `.strict()`, so Zod rejects an unlisted key + * instead of stripping it — the dispatcher then fails the WHOLE `ui.set` payload + * with `invalid_argument`. A field added to `PersistedUIState`/`TaskResumeState` + * without a matching schema entry therefore silently breaks every paired + * web/mobile/relay client while desktop (schema-less `ui:set` IPC) stays green. + * + * Assigning `true` to this type turns that runtime drift into a typecheck error + * that names the missing key. + */ +export type AssertNoMissingKeys> = + Exclude extends never + ? true + : { missingFromSchema: Exclude } From 97cb32c1ccd4869829f8119c81fb168b18acb57c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:32:24 -0700 Subject: [PATCH 010/203] fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): release an abandoned synchronized-output frame on reveal Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in `?2026h … ?2026l`. Hiding a pane mid-bracket — which a worktree switch or cold-park lands on routinely, since these brackets are written many times a second — leaves xterm's `decPrivateModes.synchronizedOutput` latched. RenderService.refreshRows checks that latch *before* rendering, so while it holds, every repaint Orca owns is a no-op: the forced render-pause repaint, the plain `refresh()` fallback, and the shared glyph-atlas rebuild all render zero rows while the xterm buffer is perfectly correct. Release the latch at the two reveal repaint entry points so those repaints actually paint. Also adds an OpenCode-shaped alt-screen e2e fixture and spec. The existing inline-TUI convergence spec covers the normal-buffer shape (live block glued to the bottom, history scrolling into scrollback); this covers the full-screen alternate-buffer shape, where nothing scrolls and so no row ever self-heals through the scroll path. Scope note: xterm arms a 1s watchdog that clears this latch on its own, so this closes a bounded window rather than the whole STA-2694 report. The e2e spec passes with and without the production change for that reason; the unit tests are what pin the behavior. Refs STA-2694. * fix(terminal): clear the render model on the plain-refocus repaint path `schedulePaneRevealPresent` — the atlas-preserving path a plain window refocus takes — only called `terminal.refresh()`. xterm's renderers are diff-based: `_updateModel` early-continues on any cell whose code/fg/bg/ext still match the cached model, so a refresh repaints nothing for a pane whose buffer never changed. When an occluded window loses its canvas contents while that model stays populated, the refresh skips exactly the cells that went stale and the pane keeps compositing pre-hide pixels — until a window resize reallocates the model, which is the repair users find by hand. Clear the model first (`RenderService.clear()` → renderer `clear()` → `_clearModel(true)`) so the refresh becomes a guaranteed full repaint. That drops cached cells and glyph vertices but NOT the texture atlas, which is shared by every same-config terminal and whose mid-stream wipe re-arms xterm's page-merge garble race (xterm.js #4480) — the reason this path is atlas-preserving in the first place. Also covers the DOM-renderer fallback in `resetWebglTextureAtlas`: `clearTextureAtlas()` is what invalidated the model on the WebGL path, so a pane without an addon had nothing invalidate it and hit the same skip. Scope note: the e2e spec guards buffer/geometry convergence across the hide/reveal boundaries and adds idle-agent and headful desktop-hide cases, but it cannot observe a stale canvas — both oracles built for that (canvas-vs-buffer ink sampling, screenshot-vs-forced-repaint) were proven blind by injecting the defect, and the spec header documents why. The unit tests pin the ordering and the atlas-preservation invariant. Refs STA-2694. Co-authored-by: Orca * docs(terminal): hand off the STA-2694 reveal-artifact investigation Records both fixed defects with their xterm mechanisms, the reveal/wake call graph, why every e2e oracle for a stale canvas was proven blind, how to arm the in-app render-desync sentinel on real hardware, and the one unverified lead (dimension staleness) that would explain why a window resize specifically is the repair users find. Refs STA-2694. Co-authored-by: Orca * Revert "fix(terminal): clear the render model on the plain-refocus repaint path" This reverts commit 0f7ec4458d37010338f16e70ff06957cb335e074. * test(terminal): add a draw-command oracle for reveal repaints, and correct the STA-2694 scope Every pixel oracle tried for STA-2694 was blind: `drawImage` on a non-preserveDrawingBuffer WebGL canvas returns a re-rendered copy, and Playwright's screenshot drives a fresh compositor frame that heals a stale paint before capture. Reading pixels is self-defeating here — the read triggers the repaint that hides the bug. Count the WebGL draw commands instead, by wrapping GlyphRenderer.updateCell and gl.drawElementsInstanced on the live pane. A draw command cannot be healed after the fact, so "did the reveal actually repaint?" becomes directly observable. Teeth-verified: removing releaseAbandonedSynchronizedOutput from schedulePaneRevealPresent fails the stranded-latch test. Two findings, both of which change previously-committed claims: 1. The 1s watchdog does NOT bound the synchronized-output defect. It is armed only inside `bufferRows`, and `refreshRows` returns at its `_isPaused` check first — so while a pane is occluded nothing reaches `bufferRows` and no timer is ever pending. A pane hidden mid-`?2026h` holds the latch with no watchdog behind it, indefinitely. ed1eaf55f1's "closes a bounded window" scope note was wrong; this is the unbounded garble the report describes, and the fix closes it. Corrected in the module doc comment. 2. It refutes the diff-based-staleness hypothesis behind 0f7ec4458d (reverted in 8d5eacecb4). `_updateModel` does early-continue per unchanged cell, but `GlyphRenderer.render` then copies vertices for EVERY row up to `lineLengths[y]` and issues ONE full-viewport draw — measured identical instance counts (562) for a diff-skipped and a model-cleared refresh, with updateCell at 0 vs 561. The DOM renderer likewise replaceChildren()s every row unconditionally. Clearing the model could not change what reached the screen, and `_clearModel(true)` zeroes every glyph vertex while `RenderService.clear()` fires no repaint of its own — so it opened a blank-viewport window (also asserted here) for no benefit. Also keeps the idle-agent and headful desktop-hide cases from the reverted commit, since those were independent of the refuted production change, and rewrites the alt-screen spec header to point paint questions at this oracle. Refs STA-2694. Co-authored-by: Orca * docs(terminal): rewrite the STA-2694 handoff after the refutation Records that the garble window is unbounded (the 1s watchdog never arms for an occluded pane), that the diff-based-staleness hypothesis was refuted by measurement and reverted, why pixel oracles are structurally blind here, and the two leads now closed by measurement (dimension staleness, lazy atlas bindings). Refs STA-2694. Co-authored-by: Orca * test(terminal): capture visual proof of the STA-2694 stale paint The earlier screenshot oracles were blind because they compared a revealed pane against a repaired one and both ran the same repaint code. Capturing the defect directly works instead, because the mechanism is self-preserving: while synchronizedOutput is latched, refreshRows returns before reaching the renderer, so a compositor frame just re-composites the existing canvas texture and the stale pixels survive the screenshot rather than being healed by it. Latch a frame, write a full new frame the pane cannot paint, and capture. The screenshot comes back byte-identical to the pre-hide one while the buffer holds the new frame — the buffer/screen divergence users report — and differs after the reveal repaint runs. Asserts both halves, so it fails if either the defect stops reproducing or the fix stops repairing it. Refs STA-2694. Co-authored-by: Orca * test(terminal): note where the xterm gate-order double is pinned for real The unit double encodes RenderService's paused-then-latch gate order, which can drift on an xterm upgrade. Point at the e2e oracle that pins the same order against the real renderer, so a future upgrade has a trail to the authoritative check. Refs STA-2694. Co-authored-by: Orca * test(terminal): add a perf budget for the synchronized-output release releaseAbandonedSynchronizedOutput runs inside resetWebglTextureAtlas, which a streaming alt-screen TUI can reach through the terminal-output atlas recovery path — not only on reveal. Measure rather than assert that this costs nothing. Steady state (a TUI that closes every frame it opens): 200 bracketed frames produce zero releases, zero extra draw calls, and an unmeasurable early-out cost. Worst case (every reveal finds a latched frame): 50 latched atlas resets at 0.08ms each. Both are asserted with headroom, so the guard catches a future change that makes this scan the buffer per pane rather than flaking on machine speed. Refs STA-2694. Co-authored-by: Orca * test(terminal): address review — drive real code paths, close vacuity gaps CodeRabbit caught a genuine tautology in the perf budget: it timed a hand-copied mirror of the early-out rather than the shipped function, so the assertion would have held even if the real code grew a buffer scan. Driving resetWebglTextureAtlases instead moved the measured cost from ~0 to ~0.03ms per call, which is the honest number for the whole recovery; bound re-set to 0.4ms (10x measured). Other review fixes: - Assert the draw counts both perf tests were measuring and logging but never checking, so the 'no extra draws' titles now mean something. - Fail fast when decPrivateModes is unavailable; previously the latched test would pass without ever exercising the fix. - Re-check the latch right after the worktree switch in the mid-frame test: the pane is visible until then, so the 1s watchdog can arm and clear it before the hide, making the run vacuous. - Count scheduleRevealPresent invocations instead of returning a literal true, so a missing test hook no longer masquerades as a production failure. - Assert the latch clears on every reveal iteration, not just the last. - Make the fixture heartbeat write atomic (tmp + rename); writeFileSync truncates first, so a reader could see '' and read it as frame 0. - Relabel assertRevealPixelsNeedNoRepair as the weak secondary check it is; it contradicted the file header by calling itself 'the decisive paint assertion'. Refs STA-2694. Co-authored-by: Orca --------- Co-authored-by: Orca --- ...694-alt-screen-reveal-artifacts-handoff.md | 213 +++++ .../lib/pane-manager/pane-reveal-repaint.ts | 5 + .../lib/pane-manager/pane-webgl-renderer.ts | 5 + ...reveal-repaint-synchronized-output.test.ts | 101 +++ ...rminal-synchronized-output-release.test.ts | 94 +++ .../terminal-synchronized-output-release.ts | 75 ++ .../opencode-altscreen-live-fixture.cjs | 174 ++++ ...pencode-altscreen-reveal-artifacts.spec.ts | 782 ++++++++++++++++++ ...terminal-reveal-draw-command-probe.spec.ts | 558 +++++++++++++ ...minal-reveal-latch-visual-evidence.spec.ts | 227 +++++ ...l-synchronized-output-release-perf.spec.ts | 285 +++++++ 11 files changed, 2519 insertions(+) create mode 100644 docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md create mode 100644 src/renderer/src/lib/pane-manager/reveal-repaint-synchronized-output.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts create mode 100644 tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs create mode 100644 tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts create mode 100644 tests/e2e/terminal-reveal-draw-command-probe.spec.ts create mode 100644 tests/e2e/terminal-reveal-latch-visual-evidence.spec.ts create mode 100644 tests/e2e/terminal-synchronized-output-release-perf.spec.ts diff --git a/docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md b/docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md new file mode 100644 index 00000000000..5b68ea02916 --- /dev/null +++ b/docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md @@ -0,0 +1,213 @@ +# STA-2694 — garbled terminal after switching away from an AI workspace + +Handoff snapshot: 2026-07-27, branch +`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`. + +## The report + +> When I use an AI tool—such as OpenCode—and switch away to the desktop or +> another task, the page appears garbled or distorted upon returning; I have to +> resize the window to restore the display. + +Reporter hints: related to terminal parking / workspace switching; also happens +with Claude Code and probably grok. + +## Status + +**One defect, root-caused and fixed, with a mechanism-level test that fails +without the fix.** The garble window is *unbounded*, not the ~1s I originally +claimed — see [the correction](#correction-the-window-is-unbounded). + +A second hypothesis was implemented, then **refuted by measurement and +reverted**. That refutation is the other half of the value here: it rules out a +whole class of "just force a fuller repaint" fixes, and it is pinned by a test so +it cannot be reintroduced. + +| | | +|---|---| +| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal | +| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` | +| Oracle + scope correction | `3ccffc17ec` | +| Unit tests | 8 passing across 2 files | +| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful | +| Teeth-verified | Yes — removing the fix fails the stranded-latch test | + +## The defect + +Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in +DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second. +Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion +lands there routinely) and xterm keeps +`decPrivateModes.synchronizedOutput` latched `true`. + +`RenderService.refreshRows` checks that latch **before** rendering +(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op: +the forced render-pause repaint, the plain `refresh()` fallback, and the shared +glyph-atlas rebuild all render zero rows against a perfectly correct buffer. + +The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes +the handler's buffered row range at both reveal repaint entry points, before the +repaint. Both reveal paths reach it: + +- `schedulePaneRevealPresent` (plain refocus / desktop return) +- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached + from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path) + +### Correction: the window is unbounded + +`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the +whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch. +**That was wrong.** The watchdog is armed only inside +`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its +`_isPaused` check *first*: + +```ts +public refreshRows(start, end, sync = false, isRedrawOnly = false): void { + if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here + if (this._coreService.decPrivateModes.synchronizedOutput) { + this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms + return + } + ... +``` + +While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing +ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden +mid-frame holds the latch with no watchdog behind it, indefinitely — which is the +indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts` +asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing +0 instances. + +This also explains the specific workaround: resizing the window makes the TUI +repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch. + +## The refuted hypothesis (do not reimplement) + +The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells +whose model entry still matches the buffer, leaving an occluded canvas showing +pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading — +`WebglRenderer._updateModel` really does early-continue per unchanged cell. + +It is wrong, measured on a live pane: + +| Refresh | `updateCell` calls | instances drawn | +|---|---|---| +| diff-skipped (model matches buffer) | 0 | 562 | +| model cleared first | 561 | 562 | + +`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]` +into the active buffer and issues **one** full-viewport `drawElementsInstanced`. +The diff only decides how much *vertex data* is rewritten, never how much is +*drawn*. The DOM renderer likewise `replaceChildren()`s every row +unconditionally. So clearing the model cannot change what reaches the screen. + +Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and +`RenderService.clear()` fires no repaint of its own — so any paint landing between +the clear and the repopulating refresh draws an **empty** viewport (0 instances, +also asserted in the spec). On a reveal that refresh is debounced through a RAF +and can itself be swallowed, which would turn a merely-stale pane into a blank +one. Reverted in `8d5eacecb4`. + +The first test in the oracle spec pins the refutation so this cannot come back as +a "fix". + +## Why pixel oracles failed, and what replaced them + +The field defect is "the buffer is correct but the compositor shows pre-hide +pixels". Two pixel oracles were built and **both were proven blind** by injecting +that exact defect (freeze `RenderService.refreshRows`, then write new content): + +1. **Canvas-vs-buffer ink sampling.** `drawImage` on a + non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it + reported 0 missing cells against 5263 cells of text the canvas had never + drawn. +2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh + compositor frame, which *heals* the stale paint before capture; and the + "repair" ran the same code the reveal already ran, so a shared defect cancels + out. + +An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen +rows, so its 4.4% pixel diff measured legitimate reflow. + +**Pixels are the wrong layer** — anything that reads them can trigger the repaint +that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` +instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the +live pane and counts draw commands. A draw command cannot be healed after the +fact, so "did the reveal repaint?" is directly observable — and falsifiable, +which is how the second hypothesis got refuted. + +Use that spec for **paint** questions; +`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and +PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop +hide). The idle-agent case matters: every earlier test kept the TUI streaming +across the reveal, so live frames repainted whatever the reveal got wrong and the +defect healed itself before any assertion ran. + +## Leads closed by measurement + +Both were live suspects in the previous handoff; both are now ruled out. + +- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is + behind an unchanged/zero-sized early exit that a hidden pane trips, and + `performSafeFit` early-returns at unchanged geometry — so nothing on the reveal + path re-runs `handleResize()`. Measured across a real `window.hide()` / + `show()`: canvas backing store `1272×1104` matched the renderer's computed + dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true, + the screen element stayed connected. No staleness. +- **Lagging atlas page bindings.** Right after a reveal the bound texture version + *does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and + `GlyphRenderer.render` rebinds any page whose version moved, so one repaint + brings them level (1366/1366). Normal, not a defect. Judge bindings only + *after* a draw. + +## Residual risk + +The oracle proves the repaint is *issued*; it does not photograph the user's +screen. If a report survives this fix, the remaining suspects are below the draw +call — GPU-process/compositor-level content loss — where the in-app sentinel on +real hardware is the tool: + +```js +localStorage.setItem('orca:render-desync-sentinel', '1') // then reload +``` + +Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts +a 10s burst at 250ms intervals; a trip needs the same cells missing across 2 +consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` + +`corrupt.json` to `/terminal-render-desync-evidence//`, then +runs atlas recovery and captures the healed frame. Armed at import when the flag +is set (`terminal-freeze-breadcrumbs.ts:45`). + +## Verification commands + +```bash +npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/ +npx tsc --noEmit -p config/tsconfig.tc.web.json +npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \ + tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \ + tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \ + --config tests/playwright.config.ts --project=electron-headless --workers=1 +SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \ + --config tests/playwright.config.ts --project=electron-headful --workers=1 +``` + +To confirm the fix still has teeth, delete the +`releaseAbandonedSynchronizedOutput(pane.terminal)` line from +`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails +with "the reveal repaint did not release the stranded latch, so the pane stays +frozen". + +## Files + +Production: + +- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix) +- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`) +- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`) + +Tests: + +- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts` +- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle) +- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`, + `tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs` diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index 9fb283782d2..9aa24002b6d 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -1,6 +1,7 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { reattachWebglIfNeeded } from './pane-webgl-reattach' import { resetWebglTextureAtlas } from './pane-webgl-renderer' +import { releaseAbandonedSynchronizedOutput } from './terminal-synchronized-output-release' function scheduleSettledFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame !== 'function') { @@ -60,6 +61,10 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable Iterable): void { forEachPaneOnSettledFrame(getPanes, (pane) => { reattachWebglIfNeeded(pane) + // Why before the refresh: while a TUI's synchronized-output frame is + // latched, RenderService buffers refreshes instead of rendering them, so + // this present would paint nothing. See terminal-synchronized-output-release. + releaseAbandonedSynchronizedOutput(pane.terminal) if (pane.terminal.rows > 0) { pane.terminal.refresh(0, pane.terminal.rows - 1) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 65e78c00802..f4f625a8437 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -8,6 +8,7 @@ import { resetTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' import { safeFitAndThen } from './pane-fit' +import { releaseAbandonedSynchronizedOutput } from './terminal-synchronized-output-release' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined @@ -129,6 +130,10 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { return } try { + // Why first: a TUI hidden mid-`?2026h` leaves synchronized output latched, + // and RenderService buffers every refresh while it holds — so the repaint + // below would render nothing at all. See terminal-synchronized-output-release. + releaseAbandonedSynchronizedOutput(pane.terminal) // Why: rapid TUI redraws can corrupt xterm's WebGL glyph atlas without a // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. diff --git a/src/renderer/src/lib/pane-manager/reveal-repaint-synchronized-output.test.ts b/src/renderer/src/lib/pane-manager/reveal-repaint-synchronized-output.test.ts new file mode 100644 index 00000000000..4d3519726b7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/reveal-repaint-synchronized-output.test.ts @@ -0,0 +1,101 @@ +/** + * STA-2694: an alt-screen agent TUI (OpenCode/OpenTUI, Codex, grok) brackets + * every repaint in `?2026h … ?2026l`. Hiding the pane mid-bracket latches + * xterm's synchronizedOutput mode, and RenderService.refreshRows checks that + * latch BEFORE rendering — so on reveal the whole repaint arsenal renders zero + * rows and the canvas keeps compositing pre-hide pixels. + * + * These tests drive the real reveal entry points against a terminal double that + * mirrors xterm's gate order, so a regression that stops releasing the latch + * shows up as "reveal repainted nothing". + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { schedulePaneRevealPresent } from './pane-reveal-repaint' +import { resetWebglTextureAtlas } from './pane-webgl-renderer' +import type { ManagedPaneInternal } from './pane-manager-types' + +type GateState = { synchronizedOutput: boolean; rendered: number } + +// Mirrors RenderService.refreshRows: paused first, then the synchronized-output +// buffer gate, then the actual render. This double can drift from xterm on an +// upgrade, so the gate order it encodes is independently pinned against the real +// renderer in tests/e2e/terminal-reveal-draw-command-probe.spec.ts. +function createGatedPane(options: { synchronizedOutput: boolean }): { + pane: ManagedPaneInternal + gate: GateState +} { + const gate: GateState = { synchronizedOutput: options.synchronizedOutput, rendered: 0 } + const refreshRows = (): void => { + if (gate.synchronizedOutput) { + return + } + gate.rendered += 1 + } + const terminal = { + rows: 24, + cols: 80, + refresh: refreshRows, + _core: { + coreService: { + get decPrivateModes() { + return gate + } + }, + _renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _syncOutputHandler: { flush: vi.fn() } + } + } + } + const pane = { + id: 1, + terminal, + gpuRenderingEnabled: false, + webglAddon: null, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false + } as unknown as ManagedPaneInternal + return { pane, gate } +} + +describe('reveal repaint under an abandoned synchronized-output frame', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('atlas-reset recovery repaints a pane hidden mid-synchronized-frame', () => { + const { pane, gate } = createGatedPane({ synchronizedOutput: true }) + + resetWebglTextureAtlas(pane) + + expect(gate.synchronizedOutput, 'the abandoned frame must be released').toBe(false) + expect(gate.rendered, 'reveal must actually repaint, not buffer').toBeGreaterThan(0) + }) + + it('atlas-preserving present repaints a pane hidden mid-synchronized-frame', () => { + const { pane, gate } = createGatedPane({ synchronizedOutput: true }) + + schedulePaneRevealPresent(() => [pane]) + + expect(gate.synchronizedOutput).toBe(false) + expect(gate.rendered).toBeGreaterThan(0) + }) + + it('leaves a pane that was not mid-frame exactly as before', () => { + const { pane, gate } = createGatedPane({ synchronizedOutput: false }) + + resetWebglTextureAtlas(pane) + + expect(gate.synchronizedOutput).toBe(false) + expect(gate.rendered).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.test.ts new file mode 100644 index 00000000000..e04efbd4d1a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import { releaseAbandonedSynchronizedOutput } from './terminal-synchronized-output-release' + +function createTerminal(options: { + synchronizedOutput?: boolean | undefined + withoutCore?: boolean + withoutModes?: boolean + flush?: () => unknown +}): unknown { + if (options.withoutCore) { + return {} + } + return { + _core: { + coreService: options.withoutModes + ? {} + : { decPrivateModes: { synchronizedOutput: options.synchronizedOutput } }, + _renderService: { _syncOutputHandler: { flush: options.flush } } + } + } +} + +describe('releaseAbandonedSynchronizedOutput', () => { + it('clears a latched synchronized-output frame and flushes its buffered rows', () => { + const flush = vi.fn() + const terminal = createTerminal({ synchronizedOutput: true, flush }) + + expect(releaseAbandonedSynchronizedOutput(terminal)).toBe(true) + expect( + (terminal as { _core: { coreService: { decPrivateModes: { synchronizedOutput: boolean } } } }) + ._core.coreService.decPrivateModes.synchronizedOutput + ).toBe(false) + // The buffered range dies with the latch; the caller's own full refresh + // repaints those rows, and leaving it armed keeps xterm's 1s timer alive. + expect(flush).toHaveBeenCalledTimes(1) + }) + + it('leaves an unlatched terminal untouched', () => { + const flush = vi.fn() + expect( + releaseAbandonedSynchronizedOutput(createTerminal({ synchronizedOutput: false, flush })) + ).toBe(false) + expect( + releaseAbandonedSynchronizedOutput(createTerminal({ synchronizedOutput: undefined, flush })) + ).toBe(false) + expect(flush).not.toHaveBeenCalled() + }) + + it('degrades to a no-op when xterm internals are unavailable', () => { + expect(releaseAbandonedSynchronizedOutput(createTerminal({ withoutCore: true }))).toBe(false) + expect(releaseAbandonedSynchronizedOutput(createTerminal({ withoutModes: true }))).toBe(false) + expect(releaseAbandonedSynchronizedOutput(null)).toBe(false) + expect(releaseAbandonedSynchronizedOutput(undefined)).toBe(false) + }) + + it('still clears the latch when the buffered-row flush throws', () => { + const terminal = createTerminal({ + synchronizedOutput: true, + flush: () => { + throw new Error('disposed mid-frame') + } + }) + + expect(releaseAbandonedSynchronizedOutput(terminal)).toBe(true) + expect( + (terminal as { _core: { coreService: { decPrivateModes: { synchronizedOutput: boolean } } } }) + ._core.coreService.decPrivateModes.synchronizedOutput + ).toBe(false) + }) + + it('unblocks the repaint that xterm would otherwise swallow', () => { + // Mirrors RenderService.refreshRows' gate order: the synchronizedOutput + // check precedes rendering, so a latched frame turns every reveal repaint + // into a no-op — the STA-2694 failure. + const modes = { synchronizedOutput: true } + let rendered = 0 + const refreshRows = (): void => { + if (modes.synchronizedOutput) { + return + } + rendered += 1 + } + const terminal = { + _core: { coreService: { decPrivateModes: modes }, _renderService: {} } + } + + refreshRows() + expect(rendered, 'a latched frame swallows the repaint').toBe(0) + + releaseAbandonedSynchronizedOutput(terminal) + refreshRows() + expect(rendered, 'releasing the latch lets the repaint through').toBe(1) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts b/src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts new file mode 100644 index 00000000000..c8999aa2797 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts @@ -0,0 +1,75 @@ +/** + * Releases a DEC 2026 (synchronized output) frame that a TUI left open across a + * hide, so reveal repaints are not silently swallowed. + * + * Why: alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every + * repaint in `?2026h … ?2026l`. If the pane is hidden mid-bracket — the exact + * moment a worktree switch or cold-park lands, since the fixture writes these + * brackets many times a second — xterm keeps `decPrivateModes.synchronizedOutput` + * latched. RenderService.refreshRows then buffers instead of rendering, and it + * checks that BEFORE the sync flag, so on reveal every repaint Orca owns is a + * no-op: the forced render-pause repaint, the plain `refresh()` fallback, and + * the shared glyph-atlas rebuild all render zero rows. The canvas keeps + * compositing pre-hide pixels while xterm's buffer is perfectly correct. + * + * xterm's 1s safety timeout does NOT bound this: it is armed only inside + * `bufferRows`, and `refreshRows` returns at its `_isPaused` check first — so + * while a pane is occluded nothing reaches `bufferRows` and no timer is ever + * pending. A pane hidden mid-frame holds the latch with no watchdog behind it, + * indefinitely. (terminal-reveal-draw-command-probe.spec.ts asserts exactly + * this: latch set, `_timeout === undefined`, and a repaint drawing 0 instances.) + * Resizing the window "fixes" it because SIGWINCH makes the TUI repaint, and + * that repaint's closing `?2026l` clears the latch — exactly the workaround + * users report (STA-2694). + * + * Reveal is a safe place to drop the latch: a frame opened before the pane was + * hidden can no longer be completed atomically, and its content is already in + * the buffer. Clearing it costs at most one extra repaint of a frame the TUI + * was mid-way through, versus a terminal that stays visibly corrupted. + * + * All access is behind typeof guards: an xterm upgrade that renames these + * internals degrades to a no-op rather than throwing into a render frame. + */ + +type MaybeSynchronizedOutputModes = { + synchronizedOutput?: boolean +} + +type TerminalWithSynchronizedOutput = { + _core?: { + coreService?: { decPrivateModes?: MaybeSynchronizedOutputModes } + _renderService?: { _syncOutputHandler?: { flush?: () => unknown } } + } +} + +function getDecPrivateModes(terminal: unknown): MaybeSynchronizedOutputModes | null { + const modes = (terminal as TerminalWithSynchronizedOutput | null)?._core?.coreService + ?.decPrivateModes + return modes && typeof modes === 'object' ? modes : null +} + +/** + * Clears a synchronized-output latch left open across a hide. + * + * Returns true when a latch was actually released, so callers can treat it as + * "this pane needs a repaint now" — the buffered rows are dropped with the + * latch, and the caller's own full refresh is what repaints them. + */ +export function releaseAbandonedSynchronizedOutput(terminal: unknown): boolean { + const modes = getDecPrivateModes(terminal) + if (modes?.synchronizedOutput !== true) { + return false + } + modes.synchronizedOutput = false + try { + // Why: drop the handler's buffered row range with the latch. Leaving it + // armed would let a later flush widen an unrelated refresh to rows this + // pane is about to repaint in full anyway, and keeps its 1s timer alive. + const handler = (terminal as TerminalWithSynchronizedOutput)._core?._renderService + ?._syncOutputHandler + handler?.flush?.() + } catch { + // The latch is already cleared; a failed flush must not block the repaint. + } + return true +} diff --git a/tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs b/tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs new file mode 100644 index 00000000000..1e9ed7932ef --- /dev/null +++ b/tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs @@ -0,0 +1,174 @@ +// OpenCode/OpenTUI-shaped ALT-SCREEN TUI: a full-screen frame repainted every +// tick inside synchronized-output brackets, with a wide glyph inventory (box +// drawing, emoji, CJK) so each repaint exercises the shared WebGL glyph atlas. +// +// Why a second TUI fixture: codex-inline-live-block-fixture.cjs models the +// INLINE shape (normal buffer, live block glued to the bottom). OpenCode runs +// full-screen on the alternate buffer and repaints absolutely-positioned rows, +// which is the shape that leaves stale/garbled pixels after a hidden stretch — +// nothing scrolls, so no row ever self-heals through the scroll path. +// +// argv[2] = heartbeat file path (latest frame number, rewritten every tick) +// argv[3] = ticks per second (default 16) +// The stream never stops on its own; tests hide/park/reveal around it. +const fs = require('node:fs') + +const heartbeatPath = process.argv[2] +const TICKS_PER_SECOND = Math.max(1, Number(process.argv[3]) || 16) +const TICK_MS = Math.max(1, Math.round(1000 / TICKS_PER_SECOND)) + +// Why a rotating inventory: a single repeated glyph set stays resident in the +// atlas after one rasterization. Rotating forces fresh glyph uploads on most +// frames, which is what grows atlas pages and drives the page-merge path. +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const BADGES = ['🟢', '🟡', '🔵', '🟣', '🟠'] +const CJK = ['実行中', '生成中', '待機中', '完了', '解析中'] + +let frame = 0 + +function rows() { + return process.stdout.rows || 24 +} + +function cols() { + return process.stdout.columns || 80 +} + +function bodyRow(index) { + const spin = SPINNER[(frame + index) % SPINNER.length] + const badge = BADGES[(frame + index) % BADGES.length] + const cjk = CJK[(frame + index) % CJK.length] + const bar = '█'.repeat((frame + index) % 12).padEnd(12, '░') + return ( + `│ ${spin} ${badge} row ${String(index).padStart(3, '0')} ${cjk} ${bar} ` + + `tok ${String((frame * 7 + index) % 100000).padStart(6, '0')} λ∑√≈∂ ` + ) +} + +// A full-screen absolute repaint: home, then one absolutely-positioned write +// per row. No scrolling, so a row that paints wrong stays wrong. +function fullFrame() { + const width = Math.max(20, Math.min(cols() - 2, 100)) + const height = rows() + const lines = [ + `╭─ OPENCODE_FRAME_${String(frame).padStart(6, '0')} ${'─'.repeat(Math.max(0, width - 30))}╮` + ] + for (let index = 0; index < Math.max(0, height - 3); index += 1) { + lines.push(`${bodyRow(index).slice(0, width).padEnd(width, ' ')}│`) + } + lines.push( + `╰─ status: streaming · OPENCODE_TAIL_${String(frame).padStart(6, '0')} ${'─'.repeat(Math.max(0, width - 46))}╯` + ) + let out = '' + for (const [index, line] of lines.entries()) { + out += `\x1b[${index + 1};1H\x1b[K${line}` + } + return out +} + +function tick() { + frame += 1 + // Synchronized output around every repaint, exactly like OpenTUI. + process.stdout.write(`\x1b[?2026h\x1b[?25l${fullFrame()}\x1b[?25h\x1b[?2026l`) + if (heartbeatPath) { + try { + // Why write-then-rename: writeFileSync truncates before writing, so a + // concurrent reader can see '' and read it as frame 0. + fs.writeFileSync(`${heartbeatPath}.tmp`, String(frame)) + fs.renameSync(`${heartbeatPath}.tmp`, heartbeatPath) + } catch { + // heartbeat is best-effort; the stream itself is the product + } + } +} + +// OpenCode-shaped startup: capability queries, alt screen, mouse reporting. +process.stdout.write('\x1b[c\x1b[6n\x1b]10;?\x07\x1b]11;?\x07') +process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') +process.stdout.write('\x1b[?1002h\x1b[?1006h') + +let tickTimer = setInterval(tick, TICK_MS) +let frozen = false + +// Freeze: stop producing new frames but STAY on the alt screen holding the last +// painted frame. Tests need this to compare pixels — a live stream would differ +// between two shots for legitimate reasons, and quitting would leave a shell +// prompt. A frozen fixture also stops answering SIGWINCH, so only Orca's own +// render path can repair the display, which is what's under test. +// +// `midFrame` models the STA-2694 hazard directly: the TUI opens a synchronized +// frame (`?2026h`), paints it, and is interrupted before writing the closing +// `?2026l`. That is what a hide lands on many times a second in the field, and +// it leaves xterm's synchronizedOutput mode latched — which makes every reveal +// repaint a no-op until something clears it. +function freeze(options) { + if (frozen) { + return + } + frozen = true + clearInterval(tickTimer) + tickTimer = null + if (options && options.midFrame) { + process.stdout.write(`\x1b[?2026h\x1b[?25l${fullFrame()}`) + } +} + +// A real TUI repaints on SIGWINCH. Keep that for realism while live — tests must +// converge WITHOUT relying on it, since needing a resize IS the bug under test. +process.stdout.on('resize', () => { + if (frozen) { + return + } + process.stdout.write(`\x1b[?2026h\x1b[?25l${fullFrame()}\x1b[?25h\x1b[?2026l`) +}) +process.stdin.resume() +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} + +let stopping = false +function stop() { + if (stopping) { + return + } + stopping = true + if (tickTimer) { + clearInterval(tickTimer) + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + // Why: the e2e sends Ctrl+C while raw mode is active, so Node receives a byte + // instead of SIGINT; explicitly restore modes and terminate the fixture. + process.stdout.write('\x1b[?1002l\x1b[?1006l\x1b[?25h\x1b[?2026l\x1b[?1049l', () => + process.exit(0) + ) +} +// Why a multi-byte sentinel and not a single key: at startup this fixture emits +// DA1/CPR/OSC-10/OSC-11 queries, and the terminal's replies arrive on stdin — +// an OSC color reply ("rgb:ffff/...") contains 'f', so a single-letter trigger +// froze the fixture against its own startup handshake before frame 1. +const FREEZE_SENTINEL = 'ORCA_FREEZE_NOW' +const FREEZE_MID_FRAME_SENTINEL = 'ORCA_FREEZE_MID_FRAME' +const MAX_SENTINEL_LEN = Math.max(FREEZE_SENTINEL.length, FREEZE_MID_FRAME_SENTINEL.length) +let stdinTail = '' +process.stdin.on('data', (chunk) => { + if (chunk.includes(0x03)) { + stop() + return + } + stdinTail = `${stdinTail}${chunk.toString('latin1')}`.slice(-2 * MAX_SENTINEL_LEN) + // Why check mid-frame first: both share the 'ORCA_FREEZE_' prefix, so testing + // the longer sentinel first keeps a partial read from picking the wrong mode. + if (stdinTail.includes(FREEZE_MID_FRAME_SENTINEL)) { + stdinTail = '' + freeze({ midFrame: true }) + return + } + if (stdinTail.includes(FREEZE_SENTINEL)) { + stdinTail = '' + freeze() + } +}) +process.on('SIGINT', stop) +process.on('SIGTERM', stop) diff --git a/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts b/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts new file mode 100644 index 00000000000..d5c475a1173 --- /dev/null +++ b/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts @@ -0,0 +1,782 @@ +/** + * STA-2694 repro attempt: switching away from a workspace running an alt-screen + * AI TUI (OpenCode/OpenTUI, also Claude Code and grok) and returning later shows + * a garbled/distorted terminal that only a manual window resize repairs. + * + * Why this spec exists next to terminal-inline-tui-reveal-convergence.spec.ts: + * that one drives the INLINE shape (normal buffer, live block glued to the + * bottom, history scrolling into scrollback). OpenCode runs FULL-SCREEN on the + * alternate buffer and repaints absolutely-positioned rows — nothing scrolls, so + * no row ever self-heals through the scroll path. These tests cover that shape + * across the hide/reveal boundaries (worktree switch, cold park, idle agent, + * desktop hide) and guard the convergence properties we CAN observe: the buffer + * converges to the live frame, the pane stays on the alt screen, and xterm's + * grid, the fit proposal, and the PTY-applied size all agree without a resize. + * + * ⚠ These tests do NOT observe the CANVAS. Two pixel oracles were tried here and + * both were proven blind by injecting the exact defect (freeze + * RenderService.refreshRows, then write new content, so the buffer advances + * while the canvas cannot): + * + * 1. Canvas-vs-buffer ink sampling (the render-desync sentinel's method). + * `drawImage` on a non-preserveDrawingBuffer WebGL canvas hands back a + * re-rendered copy, so it reported 0 missing cells against 5263 cells of + * text the canvas had never drawn. + * 2. Screenshot comparison against a forced repaint. Playwright's screenshot + * drives a fresh compositor frame, which HEALS the stale paint before it + * is captured; and the "repair" calls the same repaint code the reveal + * already ran, so a defect shared by both shots cancels out. + * + * Pixels are the wrong layer for this: anything that reads them can trigger the + * repaint that hides the bug. terminal-reveal-draw-command-probe.spec.ts solves + * that by counting the WebGL draw commands a repaint issues, which cannot be + * healed after the fact — use it for paint questions and this spec for buffer, + * geometry, and PTY-size convergence. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { PNG } from 'pngjs' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' +import { + ensureTerminalVisible, + getActiveTabId, + switchToOtherWorktree, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'opencode-altscreen-live-fixture.cjs') +const FRAME_RE = /OPENCODE_FRAME_(\d+)/g +const TAIL_RE = /OPENCODE_TAIL_(\d+)/g +// The fixture ticks ~16x/s; allow generous parse/delivery lag while still +// rejecting a frame frozen from before the hide. +const MAX_VISIBLE_FRAME_LAG = 60 + +type RevealProbe = { + ptyId: string | null + cols: number + rows: number + bufferType: string + proposed: { cols: number; rows: number } | null + appliedPtySize: { cols: number; rows: number } | null + screenRows: string[] +} + +function latestMatch(text: string, pattern: RegExp): number { + let latest = -1 + for (const match of text.matchAll(pattern)) { + latest = Math.max(latest, Number(match[1])) + } + return latest +} + +function heartbeatFrame(heartbeatPath: string): number { + try { + return Number(readFileSync(heartbeatPath, 'utf8').trim()) + } catch { + return -1 + } +} + +async function probeRevealedPane(page: Page, tabId: string): Promise { + return page.evaluate( + async ({ tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + const ptyId = pane.container.dataset.ptyId ?? null + const terminal = pane.terminal + const buffer = terminal.buffer.active + const screenRows: string[] = [] + for (let i = 0; i < terminal.rows; i += 1) { + const line = buffer.getLine(buffer.viewportY + i) + screenRows.push(line ? line.translateToString(true) : '') + } + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + let appliedPtySize: { cols: number; rows: number } | null = null + try { + appliedPtySize = ptyId ? ((await window.api.pty.getSize(ptyId)) ?? null) : null + } catch { + appliedPtySize = null + } + return { + ptyId, + cols: terminal.cols, + rows: terminal.rows, + bufferType: buffer.type, + proposed, + appliedPtySize, + screenRows + } + }, + { tabId } + ) +} + +function describeProbe(probe: RevealProbe | null): string { + if (!probe) { + return 'pane not mounted' + } + return JSON.stringify( + { + cols: probe.cols, + rows: probe.rows, + bufferType: probe.bufferType, + proposed: probe.proposed, + appliedPtySize: probe.appliedPtySize, + screenHead: probe.screenRows.slice(0, 4), + screenTail: probe.screenRows.slice(-4) + }, + null, + 1 + ) +} + +async function forceWebglOnActiveTab(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) +} + +async function hasWebglPane(page: Page, tabId: string): Promise { + return page.evaluate( + (tabId) => + (window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some( + (diagnostic) => diagnostic.hasWebgl + ), + tabId + ) +} + +async function paneClipRect( + page: Page, + tabId: string +): Promise<{ x: number; y: number; width: number; height: number } | null> { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + const rect = pane.container.getBoundingClientRect() + if (rect.width < 10 || rect.height < 10) { + return null + } + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }, tabId) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function withCpuThrottle(page: Page, rate: number, run: () => Promise): Promise { + const session = await page.context().newCDPSession(page) + try { + await session.send('Emulation.setCPUThrottlingRate', { rate }) + return await run() + } finally { + await session.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => {}) + await session.detach().catch(() => {}) + } +} + +type StreamingTabSetup = { + worktreeId: string + tabId: string + ptyId: string + heartbeatPath: string + stop: () => Promise +} + +// Why agent-marked: a real OpenCode tab carries launchAgent/telemetry, which +// flips the reveal into the live-agent reattach branches (mode-preserving +// resets, hidden startup query grammar, post-replay focus-in) — the branches +// the field bug lives behind. +async function startStreamingAltScreenTui( + page: Page, + testInfo: TestInfo, + options: { ticksPerSecond?: number } = {} +): Promise { + await waitForSessionReady(page) + const worktreeId = await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + const heartbeatPath = testInfo.outputPath(`opencode-altscreen-heartbeat-${Date.now()}.txt`) + const command = `node ${JSON.stringify(FIXTURE_PATH)} ${JSON.stringify(heartbeatPath)} ${options.ticksPerSecond ?? 16}` + const tabId = await page.evaluate( + ({ worktreeId, command }) => { + const store = window.__store + if (!store) { + throw new Error('startStreamingAltScreenTui: window.__store is unavailable') + } + const state = store.getState() + // Why 'codex' and not 'opencode': the agent marker exists only to take the + // live-agent reattach branches, and codex is the kind the e2e environment + // reliably launches (same choice as the inline-TUI convergence spec). The + // TUI *shape* under test comes from the fixture, not from this marker. + const tab = state.createTab(worktreeId, undefined, undefined, { launchAgent: 'codex' }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: 'codex', + telemetry: { + agent_kind: 'codex', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, command } + ) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'agent-marked streaming tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + // WebGL on: the paint-layer seams under test (atlas wipes, render-pause + // release) only exist on the GPU renderer path. + await forceWebglOnActiveTab(page) + await expect + .poll( + async () => + latestMatch((await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? '', FRAME_RE), + { + timeout: 20_000, + message: 'alt-screen TUI fixture did not start streaming in the visible pane' + } + ) + .toBeGreaterThan(5) + const ptyId = (await probeRevealedPane(page, tabId))?.ptyId + if (!ptyId) { + throw new Error('streaming tab did not bind a PTY') + } + return { + worktreeId, + tabId, + ptyId, + heartbeatPath, + stop: async () => { + await sendToTerminal(page, ptyId, '\x03').catch(() => {}) + await page.waitForTimeout(100) + } + } +} + +// Buffer-level convergence: xterm parsed the live frames. This is necessary but +// NOT sufficient — the field bug shows a correct buffer behind garbled pixels. +async function assertBufferConverged( + page: Page, + testInfo: TestInfo, + setup: StreamingTabSetup, + label: string +): Promise { + const { tabId, heartbeatPath } = setup + expect(heartbeatFrame(heartbeatPath), 'fixture stopped streaming while hidden').toBeGreaterThan(5) + + let lastProbe: RevealProbe | null = null + await expect + .poll( + async () => { + lastProbe = await probeRevealedPane(page, tabId) + if (!lastProbe) { + return 'pane-not-mounted' + } + if (lastProbe.bufferType !== 'alternate') { + return `not-alt-screen bufferType=${lastProbe.bufferType}` + } + const screen = lastProbe.screenRows.join('\n') + const visibleFrame = latestMatch(screen, FRAME_RE) + const visibleTail = latestMatch(screen, TAIL_RE) + if (visibleTail < 0) { + return 'status-row-missing' + } + const liveFrame = heartbeatFrame(heartbeatPath) + if (visibleFrame < 0 || liveFrame - visibleFrame > MAX_VISIBLE_FRAME_LAG) { + return `stale-frame visible=${visibleFrame} live=${liveFrame}` + } + return 'converged' + }, + { + timeout: 20_000, + message: `${label}: revealed buffer did not converge to the live alt-screen TUI` + } + ) + .toBe('converged') + .catch(async (error) => { + testInfo.annotations.push({ + type: `${label}-buffer-divergence`, + description: describeProbe(lastProbe) + }) + throw error + }) + + const probe = await probeRevealedPane(page, tabId) + expect(probe, `${label}: pane disappeared after convergence`).not.toBeNull() + // Geometry: no stale-grid leg — xterm, fit proposal, and PTY must agree + // without any manual resize. + expect(probe!.proposed, `${label}: fit proposal diverges: ${describeProbe(probe)}`).toEqual({ + cols: probe!.cols, + rows: probe!.rows + }) + expect( + probe!.appliedPtySize, + `${label}: PTY applied size diverges: ${describeProbe(probe)}` + ).toEqual({ cols: probe!.cols, rows: probe!.rows }) + return probe! +} + +/** + * SECONDARY, WEAK check: geometry-stable repaint equivalence. + * + * Freeze the TUI, screenshot, force a clean rebuild in-app (atlas clear + full + * refresh), screenshot again, and require the two to match. + * + * Why weak, despite looking decisive: the "repair" runs the same repaint code + * the reveal already ran, so a defect present in BOTH shots cancels out and the + * comparison reports success. Injecting a frozen renderer proved that blind + * spot. Treat a green result as "the reveal did not leave a repaint-visible + * difference", never as proof the canvas painted correctly. + * + * The sound paint oracles live elsewhere: terminal-reveal-draw-command-probe + * counts WebGL draw commands (unhealable after the fact), and + * terminal-reveal-latch-visual-evidence captures the stale pixels directly. + */ +async function assertRevealPixelsNeedNoRepair( + page: Page, + testInfo: TestInfo, + setup: StreamingTabSetup, + label: string +): Promise { + const { tabId, ptyId } = setup + // Freeze the fixture in place: it stops emitting frames but stays on the alt + // screen holding its last frame. Quitting would leave a shell prompt, and a + // live stream would differ between the two shots for legitimate reasons. + await sendToTerminal(page, ptyId, 'ORCA_FREEZE_NOW').catch(() => {}) + await page.waitForTimeout(2_000) + + // Guard against a vacuous comparison: both shots must show a real TUI frame. + const frozenProbe = await probeRevealedPane(page, tabId) + expect( + frozenProbe?.bufferType, + `${label}: fixture left the alt screen before the paint check: ${describeProbe(frozenProbe)}` + ).toBe('alternate') + expect( + latestMatch(frozenProbe?.screenRows.join('\n') ?? '', FRAME_RE), + `${label}: no TUI frame on screen for the paint check: ${describeProbe(frozenProbe)}` + ).toBeGreaterThan(0) + + const clip = await paneClipRect(page, tabId) + expect(clip, `${label}: pane rect unavailable for paint check`).not.toBeNull() + const revealed = await page.screenshot({ clip: clip! }) + + // The in-app equivalent of the user's manual resize repair, with the geometry + // untouched: clear the glyph atlas and force a full repaint from the buffer. + const rebuilt = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + return false + } + manager.resetWebglTextureAtlases?.() + manager.refreshAllPanes?.() + return true + }, tabId) + expect(rebuilt, `${label}: could not force a clean repaint`).toBe(true) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) + await page.waitForTimeout(600) + + const repairedClip = await paneClipRect(page, tabId) + expect(repairedClip, `${label}: pane rect unavailable after repaint`).not.toBeNull() + // Geometry must be identical, or the comparison is not apples-to-apples. + expect(repairedClip, `${label}: pane geometry moved during the repaint`).toEqual(clip) + const repaired = await page.screenshot({ clip: repairedClip! }) + + writeFileSync(testInfo.outputPath(`${label}-revealed.png`), revealed) + writeFileSync(testInfo.outputPath(`${label}-after-clean-repaint.png`), repaired) + + // Anti-vacuity: a blank-vs-blank comparison would "match" trivially. The + // repainted shot is the known-good reference, so require real glyph ink. + const repairedInk = measureInkRatio(repaired) + expect( + repairedInk, + `${label}: repainted pane has no glyphs (ink ${repairedInk.toFixed(4)}) — ` + + 'the screenshot is not capturing terminal content, so the comparison is vacuous' + ).toBeGreaterThan(0.01) + + const diff = compareTerminalScreenshots(repaired, revealed) + expect( + diff.matches, + `${label}: revealed canvas did not match a clean repaint of its own buffer ` + + `(diff ${diff.diffPixels}px, ratio ${diff.diffRatio.toFixed(4)}) — this is STA-2694` + ).toBe(true) +} + +// Fraction of pixels differing from the dominant (background) color. +function measureInkRatio(screenshot: Buffer): number { + const png = PNG.sync.read(screenshot) + const counts = new Map() + for (let offset = 0; offset < png.data.length; offset += 4) { + const key = + ((png.data[offset] ?? 0) << 16) | + ((png.data[offset + 1] ?? 0) << 8) | + (png.data[offset + 2] ?? 0) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + let backgroundKey = 0 + let backgroundCount = -1 + for (const [key, count] of counts) { + if (count > backgroundCount) { + backgroundKey = key + backgroundCount = count + } + } + const backgroundRed = (backgroundKey >> 16) & 0xff + const backgroundGreen = (backgroundKey >> 8) & 0xff + const backgroundBlue = backgroundKey & 0xff + let ink = 0 + let total = 0 + for (let offset = 0; offset < png.data.length; offset += 4) { + const distance = + Math.abs((png.data[offset] ?? 0) - backgroundRed) + + Math.abs((png.data[offset + 1] ?? 0) - backgroundGreen) + + Math.abs((png.data[offset + 2] ?? 0) - backgroundBlue) + total += 1 + if (distance > 48) { + ink += 1 + } + } + return total > 0 ? ink / total : 0 +} + +// Reads xterm's live DEC 2026 latch for the pane. While true, RenderService +// buffers every refresh instead of rendering it. +async function readSynchronizedOutputLatch(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const modes = ( + pane?.terminal as unknown as + | { _core?: { coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } } + | undefined + )?._core?.coreService?.decPrivateModes + return modes ? modes.synchronizedOutput === true : null + }, tabId) +} + +async function streamWhileHidden(setup: StreamingTabSetup, minFrames: number): Promise { + const heartbeatBefore = heartbeatFrame(setup.heartbeatPath) + await expect + .poll(() => heartbeatFrame(setup.heartbeatPath), { + timeout: 60_000, + message: 'fixture did not keep streaming while hidden/parked' + }) + .toBeGreaterThan(heartbeatBefore + minFrames) +} + +test.describe('OpenCode alt-screen reveal artifacts (STA-2694)', () => { + test('worktree switch away and back paints correctly without a manual resize', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(180_000) + const setup = await startStreamingAltScreenTui(orcaPage, testInfo) + try { + test.skip( + !(await hasWebglPane(orcaPage, setup.tabId)), + 'WebGL renderer unavailable in this environment' + ) + // The field action: switch away to another workspace (suspends rendering + // and releases WebGL), let the agent keep streaming, then come back. + const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId) + test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide') + await streamWhileHidden(setup, 120) + + await withCpuThrottle(orcaPage, 6, async () => { + await switchToWorktree(orcaPage, setup.worktreeId) + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(2_000) + }) + + await assertBufferConverged(orcaPage, testInfo, setup, 'worktree-return') + await assertRevealPixelsNeedNoRepair(orcaPage, testInfo, setup, 'worktree-return') + } finally { + await setup.stop() + } + }) + + test('parked tab reveal paints correctly without a manual resize', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(240_000) + const setup = await startStreamingAltScreenTui(orcaPage, testInfo, { ticksPerSecond: 20 }) + try { + test.skip( + !(await hasWebglPane(orcaPage, setup.tabId)), + 'WebGL renderer unavailable in this environment' + ) + // Tab B hides tab A; the decoy then hides B so B takes the last-active + // exemption and tab A genuinely cold-parks (renderer torn down). + await createActiveTerminalTab(orcaPage, setup.worktreeId) + await createActiveTerminalTab(orcaPage, setup.worktreeId) + await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS }) + await streamWhileHidden(setup, 150) + + await withCpuThrottle(orcaPage, 6, async () => { + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(3_000) + }) + + await assertBufferConverged(orcaPage, testInfo, setup, 'parked-reveal') + await assertRevealPixelsNeedNoRepair(orcaPage, testInfo, setup, 'parked-reveal') + } finally { + await setup.stop() + } + }) + + // The field condition the other tests miss: the agent is IDLE when you come + // back. Every test above keeps the TUI streaming across the reveal, so live + // frames repaint whatever the reveal got wrong — the defect heals itself + // before any assertion runs. A real OpenCode session sits waiting for input, + // so nothing arrives to heal it, and whatever the reveal painted is what the + // user stares at until they resize the window. + test('parked reveal of an IDLE alt-screen agent paints without a manual repair', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(240_000) + const setup = await startStreamingAltScreenTui(orcaPage, testInfo) + try { + test.skip( + !(await hasWebglPane(orcaPage, setup.tabId)), + 'WebGL renderer unavailable in this environment' + ) + // Go idle BEFORE hiding, cleanly between brackets — a settled agent + // holding its last full-screen frame. + await sendToTerminal(orcaPage, setup.ptyId, 'ORCA_FREEZE_NOW').catch(() => {}) + await orcaPage.waitForTimeout(2_000) + const idleFrame = latestMatch( + (await probeRevealedPane(orcaPage, setup.tabId))?.screenRows.join('\n') ?? '', + FRAME_RE + ) + expect(idleFrame, 'fixture never painted a frame before going idle').toBeGreaterThan(0) + + // Cold-park the idle tab: renderer torn down, so the reveal must restore + // and repaint entirely from the snapshot with no live output to help. + await createActiveTerminalTab(orcaPage, setup.worktreeId) + await createActiveTerminalTab(orcaPage, setup.worktreeId) + await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS }) + await orcaPage.waitForTimeout(3_000) + + await withCpuThrottle(orcaPage, 6, async () => { + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(3_000) + }) + + // The same frame must still be on screen — the agent produced nothing new. + const revealedProbe = await probeRevealedPane(orcaPage, setup.tabId) + expect( + revealedProbe?.bufferType, + `idle-parked-reveal: pane is not on the alt screen: ${describeProbe(revealedProbe)}` + ).toBe('alternate') + expect( + latestMatch(revealedProbe?.screenRows.join('\n') ?? '', FRAME_RE), + `idle-parked-reveal: idle frame lost across the park: ${describeProbe(revealedProbe)}` + ).toBeGreaterThan(0) + + await assertRevealPixelsNeedNoRepair(orcaPage, testInfo, setup, 'idle-parked-reveal') + } finally { + await setup.stop() + } + }) + + // The LITERAL user action: "switch away to the desktop". That is an OS-level + // window hide/occlusion, not in-app navigation — it flips + // document.visibilityState, releases the WebGL context, and comes back + // through the window-wake recovery path rather than the worktree-reveal path. + // The pane never unmounts and its worktree never changes, so none of the + // reveal-repaint machinery the other tests exercise even runs. + test('@headful desktop switch away and back paints an idle agent without a manual repair', async ({ + orcaPage, + electronApp + }, testInfo) => { + test.setTimeout(240_000) + const setup = await startStreamingAltScreenTui(orcaPage, testInfo) + try { + test.skip( + !(await hasWebglPane(orcaPage, setup.tabId)), + 'WebGL renderer unavailable in this environment' + ) + // Idle agent holding a full-screen frame — the field state on return. + await sendToTerminal(orcaPage, setup.ptyId, 'ORCA_FREEZE_NOW').catch(() => {}) + await orcaPage.waitForTimeout(2_000) + + // Switch away to the desktop: hide the window entirely. + await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.hide() + }) + await orcaPage.waitForTimeout(4_000) + + // ...and come back to it. + await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.show() + window.focus() + }) + await orcaPage.waitForTimeout(3_000) + + const revealedProbe = await probeRevealedPane(orcaPage, setup.tabId) + expect( + revealedProbe?.bufferType, + `desktop-return: pane is not on the alt screen: ${describeProbe(revealedProbe)}` + ).toBe('alternate') + + await assertRevealPixelsNeedNoRepair(orcaPage, testInfo, setup, 'desktop-return') + } finally { + await electronApp + .evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + .catch(() => {}) + await setup.stop() + } + }) + + // The mechanism behind STA-2694: OpenTUI-style TUIs bracket every repaint in + // `?2026h … ?2026l`, so a hide can land inside an open bracket. xterm then + // keeps synchronizedOutput latched, and RenderService checks that latch + // BEFORE rendering — so on reveal the forced repaint, the plain refresh + // fallback, and the glyph-atlas rebuild all render zero rows while the buffer + // is perfectly correct. A window resize "fixes" it only because SIGWINCH + // makes the TUI emit the closing `?2026l`. + test('reveal repaints a pane hidden inside an unclosed synchronized-output frame', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(180_000) + const setup = await startStreamingAltScreenTui(orcaPage, testInfo) + try { + // Stop the TUI mid-bracket, then hide — the exact field sequence. + await sendToTerminal(orcaPage, setup.ptyId, 'ORCA_FREEZE_MID_FRAME').catch(() => {}) + // Why poll instead of a fixed wait: xterm arms a 1s safety timeout that + // clears the latch on its own, so a longer sleep would sample after the + // watchdog fired and miss the very state under test. + await expect + .poll(() => readSynchronizedOutputLatch(orcaPage, setup.tabId), { + timeout: 5_000, + intervals: [50], + message: 'fixture did not leave a synchronized-output frame open' + }) + .toBe(true) + + const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId) + test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide') + // Why re-check here: the pane was still VISIBLE between the freeze and this + // switch, so refreshes reached bufferRows and armed xterm's 1s watchdog. If + // it fired before the hide, the pane is now unlatched and the final + // assertion would pass without the defect ever existing. + expect( + await readSynchronizedOutputLatch(orcaPage, setup.tabId), + 'the watchdog cleared the latch before the hide, so this run proves nothing' + ).toBe(true) + await orcaPage.waitForTimeout(2_500) + await switchToWorktree(orcaPage, setup.worktreeId) + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(2_500) + + // The reveal must not leave the pane wedged behind the stale latch: while + // it holds, nothing this pane can do repaints a single row. + expect( + await readSynchronizedOutputLatch(orcaPage, setup.tabId), + 'reveal left a stale synchronized-output latch, so every repaint is swallowed' + ).toBe(false) + await assertRevealPixelsNeedNoRepair(orcaPage, testInfo, setup, 'mid-frame-hide') + } finally { + await setup.stop() + } + }) +}) diff --git a/tests/e2e/terminal-reveal-draw-command-probe.spec.ts b/tests/e2e/terminal-reveal-draw-command-probe.spec.ts new file mode 100644 index 00000000000..c5d286926e4 --- /dev/null +++ b/tests/e2e/terminal-reveal-draw-command-probe.spec.ts @@ -0,0 +1,558 @@ +/** + * STA-2694 draw-command oracle. + * + * Every pixel-level oracle tried for this bug was blind: `drawImage` on a + * non-`preserveDrawingBuffer` WebGL canvas returns a re-rendered copy, and + * Playwright's screenshot drives a fresh compositor frame that heals a stale + * paint before capture. This spec sidesteps pixels entirely and counts the + * WebGL draw commands the repaint actually issues, by wrapping + * `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the live pane. + * + * A draw command cannot be healed after the fact, so "did the reveal repaint?" + * becomes directly observable — and falsifiable, which is how this spec refuted + * one hypothesis outright (see the first test). + */ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +type DrawStats = { + updateCellCalls: number + drawCalls: number + instancesDrawn: number + rows: number + cols: number +} + +type RendererGeometry = { + canvasWidth: number + canvasHeight: number + modelCanvasWidth: number + modelCanvasHeight: number + charWidth: number + charHeight: number + isAttached: boolean + synchronizedOutput: boolean + isPaused: boolean + atlasPageVersions: number[] + boundTextureVersions: number[] + screenConnected: boolean +} + +async function setUpWebglPane(page: Page): Promise { + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + for (const manager of window.__paneManagers?.values() ?? []) { + ;( + manager as { setTerminalGpuAcceleration?: (mode: string) => void } + ).setTerminalGpuAcceleration?.('on') + } + }) + const gotWebgl = await page + .waitForFunction( + () => { + for (const manager of window.__paneManagers?.values() ?? []) { + const diagnostics = + ( + manager as { getRenderingDiagnostics?: () => { hasWebgl: boolean }[] } + ).getRenderingDiagnostics?.() ?? [] + if (diagnostics.some((diagnostic) => diagnostic.hasWebgl)) { + return true + } + } + return false + }, + { timeout: 20_000 } + ) + .then(() => true) + .catch(() => false) + if (!gotWebgl) { + return false + } + // Stable text so lineLengths (and therefore drawn instances) are non-zero. + await page.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const terminal = (pane as { terminal?: { write?: (data: string) => void } }).terminal + terminal?.write?.(`${'STA2694-PROBE '.repeat(40)}\r\n`) + } + } + }) + await page.waitForTimeout(1_500) + return true +} + +/** Wraps updateCell + drawElementsInstanced on the live pane's WebGL renderer. */ +async function instrumentDrawCommands(page: Page): Promise { + await page.evaluate(() => { + const win = window as unknown as { + __paneManagers?: Map unknown[] }> + __drawProbe?: unknown + } + // getPanes() returns a public view without webglAddon, so reach the live + // renderer through the render service instead. + let renderer: Record | null = null + for (const manager of win.__paneManagers?.values() ?? []) { + for (const candidate of manager.getPanes?.() ?? []) { + const active = ( + candidate as { + terminal?: { _core?: { _renderService?: { _renderer?: { value?: unknown } } } } + } + ).terminal?._core?._renderService?._renderer?.value as Record | undefined + if (active?._gl) { + renderer = active + } + } + } + if (!renderer) { + throw new Error('no WebGL pane to instrument') + } + const glyphRenderer = (renderer._glyphRenderer as { value?: Record })?.value + if (!glyphRenderer) { + throw new Error('renderer has no glyph renderer') + } + const gl = renderer._gl as WebGL2RenderingContext + const probe = { + updateCellCalls: 0, + drawCalls: 0, + instancesDrawn: 0, + reset(): void { + probe.updateCellCalls = 0 + probe.drawCalls = 0 + probe.instancesDrawn = 0 + } + } + win.__drawProbe = probe + + const originalUpdateCell = glyphRenderer.updateCell as (...args: unknown[]) => unknown + glyphRenderer.updateCell = function patched(...args: unknown[]): unknown { + probe.updateCellCalls++ + return originalUpdateCell.apply(this, args) + } + const originalDraw = gl.drawElementsInstanced.bind(gl) + gl.drawElementsInstanced = function patched( + mode: number, + count: number, + type: number, + offset: number, + instanceCount: number + ): void { + probe.drawCalls++ + probe.instancesDrawn += instanceCount + originalDraw(mode, count, type, offset, instanceCount) + } + }) +} + +type RefreshOptions = { clearModel?: boolean; latchSynchronizedOutput?: boolean } + +async function forceRefreshAndRead(page: Page, options: RefreshOptions = {}): Promise { + return page.evaluate((opts) => { + const win = window as unknown as { + __paneManagers?: Map unknown[] }> + __drawProbe?: { + updateCellCalls: number + drawCalls: number + instancesDrawn: number + reset: () => void + } + } + let found: unknown = null + for (const manager of win.__paneManagers?.values() ?? []) { + for (const candidate of manager.getPanes?.() ?? []) { + const candidateTerminal = ( + candidate as { + terminal?: { + _core?: { _renderService?: { _renderer?: { value?: { _gl?: unknown } } } } + } + } + ).terminal + if (candidateTerminal?._core?._renderService?._renderer?.value?._gl) { + found = candidateTerminal + } + } + } + const terminal = found as + | { + rows: number + cols: number + _core?: { + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _renderService?: { + clear?: () => void + refreshRows?: (s: number, e: number, sync?: boolean) => void + } + } + } + | undefined + const probe = win.__drawProbe + if (!terminal || !probe) { + throw new Error('probe or terminal missing') + } + if (opts.clearModel) { + terminal._core?._renderService?.clear?.() + } + const modes = terminal._core?.coreService?.decPrivateModes + if (opts.latchSynchronizedOutput && modes) { + modes.synchronizedOutput = true + } + probe.reset() + // Synchronous refresh so the counters are populated before we read them. + terminal._core?._renderService?.refreshRows?.(0, terminal.rows - 1, true) + return { + updateCellCalls: probe.updateCellCalls, + drawCalls: probe.drawCalls, + instancesDrawn: probe.instancesDrawn, + rows: terminal.rows, + cols: terminal.cols + } + }, options) +} + +async function readRendererGeometry(page: Page): Promise { + return page.evaluate(() => { + let renderer: Record | null = null + let terminal: Record | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const candidate of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const candidateTerminal = (candidate as { terminal?: Record }).terminal + const active = ( + candidateTerminal as + | { _core?: { _renderService?: { _renderer?: { value?: Record } } } } + | undefined + )?._core?._renderService?._renderer?.value + if (active?._gl) { + renderer = active + terminal = candidateTerminal as Record + } + } + } + if (!renderer || !terminal) { + throw new Error('no WebGL pane') + } + const canvas = renderer._canvas as HTMLCanvasElement + const dimensions = renderer.dimensions as { + device: { canvas: { width: number; height: number } } + } + const charSizeService = renderer._charSizeService as { width: number; height: number } + const core = terminal._core as { + screenElement?: HTMLElement + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _renderService?: { _isPaused?: boolean } + } + const atlas = renderer._charAtlas as { pages?: { version: number }[] } | undefined + const glyphRenderer = (renderer._glyphRenderer as { value?: Record })?.value + const boundTextures = (glyphRenderer?._atlasTextures as { version: number }[] | undefined) ?? [] + return { + canvasWidth: canvas.width, + canvasHeight: canvas.height, + modelCanvasWidth: dimensions.device.canvas.width, + modelCanvasHeight: dimensions.device.canvas.height, + charWidth: charSizeService.width, + charHeight: charSizeService.height, + isAttached: Boolean(renderer._isAttached), + synchronizedOutput: Boolean(core.coreService?.decPrivateModes?.synchronizedOutput), + isPaused: Boolean(core._renderService?._isPaused), + atlasPageVersions: (atlas?.pages ?? []).map((page) => page.version), + boundTextureVersions: boundTextures.map((texture) => texture?.version ?? -1), + screenConnected: Boolean(core.screenElement?.isConnected) + } + }) +} + +test.describe('reveal draw-command oracle (STA-2694)', () => { + // REFUTATION TEST. The hypothesis was: xterm's renderers are diff-based, so a + // reveal `refresh()` skips cells whose model entry still matches the buffer, + // leaving an occluded canvas showing pre-hide pixels until a resize rebuilds + // the model. `_updateModel` really does early-continue per unchanged cell — + // but `GlyphRenderer.render` then copies vertices for EVERY row up to + // `lineLengths[y]` and issues ONE full-viewport `drawElementsInstanced`. So + // the diff only decides how much VERTEX data is rewritten, never how much is + // DRAWN. This test pins that, so the refuted hypothesis cannot be + // reintroduced as a "fix". + test('a diff-skipped refresh draws exactly as much as a model-cleared one', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + test.skip(!(await setUpWebglPane(orcaPage)), 'WebGL renderer unavailable in this environment') + await instrumentDrawCommands(orcaPage) + + // Model already matches the buffer, so every cell diff-skips. + const warm = await forceRefreshAndRead(orcaPage) + // Model cleared first, so every cell is re-sent through updateCell. + const cleared = await forceRefreshAndRead(orcaPage, { clearModel: true }) + + expect(warm.updateCellCalls, 'expected the warm pass to diff-skip its cells').toBe(0) + expect(cleared.updateCellCalls, 'expected the cleared pass to re-send cells').toBeGreaterThan(0) + // The decisive comparison: identical draw output despite a 0-vs-N split in + // vertex updates. Clearing the model cannot change what reaches the screen. + expect(warm.drawCalls, 'a diff-skipped refresh issued no draw at all').toBeGreaterThan(0) + expect( + warm.instancesDrawn, + 'a diff-skipped refresh drew fewer instances than a model-cleared one — ' + + 'the diff-based-staleness hypothesis would be back in play' + ).toBe(cleared.instancesDrawn) + }) + + // The COST of clearing the render model, which the refutation above shows buys + // nothing: `_clearModel(true)` zeroes every glyph vertex and resets + // `lineLengths`, and `RenderService.clear()` fires no repaint of its own. So + // any paint that lands between the clear and the repopulating refresh draws an + // EMPTY viewport. On a reveal the following refresh is debounced through a RAF + // and can additionally be swallowed (paused render, latched synchronized + // output), which would turn a merely-stale pane into a blank one. + test('clearing the render model without repopulating it draws an empty viewport', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + test.skip(!(await setUpWebglPane(orcaPage)), 'WebGL renderer unavailable in this environment') + await instrumentDrawCommands(orcaPage) + + const populated = await forceRefreshAndRead(orcaPage) + expect(populated.instancesDrawn, 'baseline drew nothing').toBeGreaterThan(0) + + // Clear the model, then draw WITHOUT letting _updateModel repopulate it — + // exactly the window a debounced or swallowed refresh leaves open. + const blank = await orcaPage.evaluate(() => { + const win = window as unknown as { + __paneManagers?: Map unknown[] }> + __drawProbe?: { instancesDrawn: number; drawCalls: number; reset: () => void } + } + let renderer: Record | null = null + let model: unknown = null + for (const manager of win.__paneManagers?.values() ?? []) { + for (const candidate of manager.getPanes?.() ?? []) { + const active = ( + candidate as { + terminal?: { _core?: { _renderService?: { _renderer?: { value?: unknown } } } } + } + ).terminal?._core?._renderService?._renderer?.value as Record | undefined + if (active?._gl) { + renderer = active + model = active._model + } + } + } + const glyphRenderer = (renderer?._glyphRenderer as { value?: Record })?.value + const probe = win.__drawProbe + if (!renderer || !glyphRenderer || !probe) { + throw new Error('probe or renderer missing') + } + // What RenderService.clear() does to the renderer. + ;(renderer.clear as () => void).call(renderer) + probe.reset() + // Draw the cleared model directly, skipping _updateModel. + ;(glyphRenderer.render as (m: unknown) => void).call(glyphRenderer, model) + return { instancesDrawn: probe.instancesDrawn, drawCalls: probe.drawCalls } + }) + + expect( + blank.instancesDrawn, + 'a cleared model still drew content — the blank-pane window would not exist' + ).toBe(0) + // Restore the pane so the fixture teardown sees a painted terminal. + await forceRefreshAndRead(orcaPage, { clearModel: true }) + }) + + // The mechanism that IS real: while a TUI's synchronized-output frame is + // latched, RenderService returns before reaching the renderer, so a repaint + // draws literally nothing. Counting draw commands proves it directly instead + // of inferring it from pixels. + test('a latched synchronized-output frame drops the repaint entirely', async ({ orcaPage }) => { + test.setTimeout(120_000) + test.skip(!(await setUpWebglPane(orcaPage)), 'WebGL renderer unavailable in this environment') + await instrumentDrawCommands(orcaPage) + + const baseline = await forceRefreshAndRead(orcaPage) + expect(baseline.drawCalls, 'baseline refresh drew nothing').toBeGreaterThan(0) + + // Hide mid-`?2026h`: the latch stays set and every repaint is swallowed. + const latched = await forceRefreshAndRead(orcaPage, { latchSynchronizedOutput: true }) + expect( + latched.drawCalls, + 'a latched synchronized-output frame should swallow the repaint completely' + ).toBe(0) + + // What the production fix does at both reveal repaint entry points. + const released = await orcaPage.evaluate(() => { + let invoked = 0 + for (const manager of window.__paneManagers?.values() ?? []) { + const schedule = (manager as { scheduleRevealPresent?: () => void }).scheduleRevealPresent + if (typeof schedule === 'function') { + schedule.call(manager) + invoked++ + } + } + return invoked + }) + // Why count rather than assume: scheduleRevealPresent is optional-chained, + // so a missing test hook would otherwise surface below as "the fix did not + // release the latch" and misattribute the failure to production code. + expect(released, 'no pane manager exposed scheduleRevealPresent').toBeGreaterThan(0) + await orcaPage.waitForTimeout(500) + + const afterRelease = await forceRefreshAndRead(orcaPage) + expect( + afterRelease.drawCalls, + 'the reveal repaint left the latch set, so the pane still cannot paint' + ).toBeGreaterThan(0) + }) + + // The indefinite-garble case, and the reason the 1s watchdog does NOT bound + // it. `refreshRows` checks `_isPaused` BEFORE the synchronized-output latch + // and returns early, so while a pane is occluded nothing ever reaches + // `bufferRows` — which is the only place the watchdog arms. A pane hidden mid + // `?2026h` therefore holds the latch with NO timer pending, and stays that way + // until something clears the latch explicitly. That is unbounded, not 1s. + test('an occluded pane strands a latched frame with no watchdog to clear it', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + test.skip(!(await setUpWebglPane(orcaPage)), 'WebGL renderer unavailable in this environment') + await instrumentDrawCommands(orcaPage) + + const stranded = await orcaPage.evaluate(() => { + let terminal: Record | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const candidate of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const candidateTerminal = (candidate as { terminal?: Record }).terminal + const active = ( + candidateTerminal as + | { _core?: { _renderService?: { _renderer?: { value?: { _gl?: unknown } } } } } + | undefined + )?._core?._renderService?._renderer?.value + if (active?._gl) { + terminal = candidateTerminal as Record + } + } + } + const core = terminal?._core as { + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _renderService?: { + _isPaused?: boolean + _syncOutputHandler?: { _timeout?: number; _isBuffering?: boolean } + refreshRows?: (s: number, e: number, sync?: boolean) => void + } + } + const service = core?._renderService + const modes = core?.coreService?.decPrivateModes + if (!service || !modes || !terminal) { + throw new Error('render service unavailable') + } + // Exactly the field state: the TUI opened a frame, then the pane was + // occluded (IntersectionObserver pauses rendering) before ?2026l arrived. + modes.synchronizedOutput = true + service._isPaused = true + service.refreshRows?.(0, (terminal.rows as number) - 1, true) + const armed = { + watchdogPending: service._syncOutputHandler?._timeout !== undefined, + buffering: service._syncOutputHandler?._isBuffering === true, + latched: modes.synchronizedOutput === true + } + // Reveal: the pane intersects again and the pause lifts. + service._isPaused = false + return armed + }) + + expect(stranded.latched, 'the latch should still be set while occluded').toBe(true) + // The decisive observation: no timer, so nothing will ever clear this latch. + expect( + stranded.watchdogPending, + 'a watchdog was armed, so the latch would self-clear in 1s and the window would be bounded' + ).toBe(false) + expect(stranded.buffering, 'the paused return happened before any row buffering').toBe(false) + + // A repaint now draws nothing, no matter how long the pane waits. + const whileLatched = await forceRefreshAndRead(orcaPage) + expect(whileLatched.drawCalls, 'expected the stranded latch to swallow the repaint').toBe(0) + + // The production fix, at the real reveal entry point. + const invokedReveal = await orcaPage.evaluate(() => { + let invoked = 0 + for (const manager of window.__paneManagers?.values() ?? []) { + const schedule = (manager as { scheduleRevealPresent?: () => void }).scheduleRevealPresent + if (typeof schedule === 'function') { + schedule.call(manager) + invoked++ + } + } + return invoked + }) + expect(invokedReveal, 'no pane manager exposed scheduleRevealPresent').toBeGreaterThan(0) + await orcaPage.waitForTimeout(600) + + const afterFix = await forceRefreshAndRead(orcaPage) + expect( + afterFix.drawCalls, + 'the reveal repaint did not release the stranded latch, so the pane stays frozen' + ).toBeGreaterThan(0) + expect(afterFix.instancesDrawn, 'the released repaint drew an empty viewport').toBeGreaterThan( + 0 + ) + }) + + // Tests the remaining live hypothesis: that a hidden pane's canvas backing + // store or char metrics go stale, so nothing re-runs handleResize() on + // reveal and only a real window resize repairs the display. + test('a hide/reveal cycle leaves canvas geometry and atlas bindings consistent', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + test.skip(!(await setUpWebglPane(orcaPage)), 'WebGL renderer unavailable in this environment') + + const before = await readRendererGeometry(orcaPage) + await electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.hide() + }) + await orcaPage.waitForTimeout(4_000) + await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + window?.show() + window?.focus() + }) + await orcaPage.waitForTimeout(3_000) + + await instrumentDrawCommands(orcaPage) + const afterReveal = await readRendererGeometry(orcaPage) + const afterDraw = await forceRefreshAndRead(orcaPage) + // Atlas page bindings are lazy: GlyphRenderer.render rebinds any page whose + // version moved. So the binding must be judged AFTER a draw — a lag that a + // render repairs on its own is normal, one that survives a render is not. + const after = await readRendererGeometry(orcaPage) + console.log('[probe] geometry before hide:', JSON.stringify(before)) + console.log('[probe] geometry right after reveal:', JSON.stringify(afterReveal)) + console.log('[probe] post-reveal draw:', JSON.stringify(afterDraw)) + console.log('[probe] geometry after a repaint:', JSON.stringify(after)) + + // The canvas backing store must still match the dimensions the renderer + // computes from the grid; a mismatch is exactly what a window resize fixes. + expect( + { width: after.canvasWidth, height: after.canvasHeight }, + `canvas backing store diverged from renderer dimensions after reveal: ${JSON.stringify(after)}` + ).toEqual({ width: after.modelCanvasWidth, height: after.modelCanvasHeight }) + expect(after.charWidth, 'char width went stale/zero across the hide').toBeGreaterThan(0) + expect(after.charHeight, 'char height went stale/zero across the hide').toBeGreaterThan(0) + expect(after.isAttached, 'renderer detached across the hide and never re-attached').toBe(true) + expect(after.screenConnected, 'screen element disconnected across the hide').toBe(true) + expect(after.synchronizedOutput, 'synchronized output left latched after reveal').toBe(false) + expect(after.isPaused, 'render service left paused after reveal').toBe(false) + // A glyph page whose bound texture version still lags AFTER a repaint + // renders stale/garbled glyphs — the shared-atlas half of the report. + expect( + after.boundTextureVersions.slice(0, after.atlasPageVersions.length), + `bound atlas textures still lag the atlas pages after a full repaint: ${JSON.stringify(after)}` + ).toEqual(after.atlasPageVersions) + expect(afterDraw.drawCalls, 'the pane cannot draw at all after a reveal').toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/terminal-reveal-latch-visual-evidence.spec.ts b/tests/e2e/terminal-reveal-latch-visual-evidence.spec.ts new file mode 100644 index 00000000000..43fbe9c7535 --- /dev/null +++ b/tests/e2e/terminal-reveal-latch-visual-evidence.spec.ts @@ -0,0 +1,227 @@ +/** + * STA-2694 visual evidence generator (not a pass/fail guard — see the oracle + * spec for that). + * + * Earlier screenshot oracles here were blind because they compared a revealed + * pane against a repaired one, and both ran the same repaint code. Capturing + * the defect directly works, because the mechanism is self-preserving: while + * `synchronizedOutput` is latched, `RenderService.refreshRows` returns before + * reaching the renderer, so a compositor frame just re-composites the existing + * canvas texture. The stale pixels survive the screenshot instead of being + * healed by it. + * + * So: latch the frame, write new content the pane cannot paint, and capture. + * Buffer says one thing, screen shows another — which is the field report. + * + * Run with ORCA_E2E_EVIDENCE_DIR=/path to collect the PNGs. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +const EVIDENCE_DIR = process.env.ORCA_E2E_EVIDENCE_DIR ?? '/tmp/sta2694-evidence' + +async function activeWebglTerminalReady(page: Page): Promise { + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + for (const manager of window.__paneManagers?.values() ?? []) { + ;( + manager as { setTerminalGpuAcceleration?: (mode: string) => void } + ).setTerminalGpuAcceleration?.('on') + } + }) + return page + .waitForFunction( + () => { + for (const manager of window.__paneManagers?.values() ?? []) { + const diagnostics = + ( + manager as { getRenderingDiagnostics?: () => { hasWebgl: boolean }[] } + ).getRenderingDiagnostics?.() ?? [] + if (diagnostics.some((diagnostic) => diagnostic.hasWebgl)) { + return true + } + } + return false + }, + { timeout: 20_000 } + ) + .then(() => true) + .catch(() => false) +} + +/** Writes to the pane's xterm buffer directly, bypassing the PTY. */ +async function writeToPane(page: Page, data: string): Promise { + await page.evaluate((payload) => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + ;(pane as { terminal?: { write?: (d: string) => void } }).terminal?.write?.(payload) + } + } + }, data) + await page.waitForTimeout(600) +} + +async function paneClip( + page: Page +): Promise<{ x: number; y: number; width: number; height: number } | null> { + return page.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + const pane = (manager as { getPanes?: () => { container?: HTMLElement }[] }).getPanes?.()[0] + const rect = pane?.container?.getBoundingClientRect() + if (rect && rect.width > 10 && rect.height > 10) { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + } + } + return null + }) +} + +/** The visible screen text, as xterm's buffer believes it to be. */ +async function readScreenText(page: Page): Promise { + return page.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const terminal = ( + pane as { + terminal?: { + rows: number + buffer?: { + active?: { + viewportY: number + getLine: ( + i: number + ) => { translateToString: (trim: boolean) => string } | undefined + } + } + } + } + ).terminal + const active = terminal?.buffer?.active + if (!terminal || !active) { + continue + } + const lines: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + lines.push(active.getLine(active.viewportY + y)?.translateToString(true) ?? '') + } + return lines.join('\n') + } + } + return '' + }) +} + +test.describe('STA-2694 visual evidence', () => { + test('a latched frame keeps stale pixels on screen, and the fix repaints them', async ({ + orcaPage + }) => { + test.setTimeout(180_000) + test.skip( + !(await activeWebglTerminalReady(orcaPage)), + 'WebGL renderer unavailable in this environment' + ) + mkdirSync(EVIDENCE_DIR, { recursive: true }) + + // 1. A settled "agent frame" on screen, painted normally. + await writeToPane( + orcaPage, + `\x1b[2J\x1b[H\x1b[1;32m### AGENT FRAME 1 — BEFORE SWITCHING AWAY ###\x1b[0m\r\n\r\n${Array.from( + { length: 8 }, + (_, i) => ` line ${i + 1}: original content ${'-'.repeat(30)}` + ).join('\r\n')}` + ) + const clip = await paneClip(orcaPage) + expect(clip, 'pane rect unavailable').not.toBeNull() + const beforeShot = await orcaPage.screenshot({ clip: clip! }) + writeFileSync(path.join(EVIDENCE_DIR, '1-before-hide.png'), beforeShot) + + // 2. Hide mid-`?2026h`: the TUI opened a frame that never closed, and the + // pane was occluded before it could. This is the field state. + await orcaPage.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const core = (pane as { terminal?: { _core?: unknown } }).terminal?._core as + | { coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } + | undefined + const modes = core?.coreService?.decPrivateModes + if (modes) { + modes.synchronizedOutput = true + } + } + } + }) + + // 3. The agent keeps working while hidden. The buffer advances; the canvas + // cannot follow, because refreshRows returns at the latch. + await writeToPane( + orcaPage, + `\x1b[2J\x1b[H\x1b[1;31m### AGENT FRAME 2 — WHAT THE BUFFER NOW HOLDS ###\x1b[0m\r\n\r\n${Array.from( + { length: 8 }, + (_, i) => ` line ${i + 1}: UPDATED content ${'#'.repeat(30)}` + ).join('\r\n')}` + ) + + const staleShot = await orcaPage.screenshot({ clip: clip! }) + writeFileSync(path.join(EVIDENCE_DIR, '2-garbled-on-return.png'), staleShot) + const bufferWhileStale = await readScreenText(orcaPage) + + // The buffer holds frame 2... + expect(bufferWhileStale, 'buffer never received frame 2').toContain('AGENT FRAME 2') + writeFileSync(path.join(EVIDENCE_DIR, 'buffer-while-garbled.txt'), bufferWhileStale, 'utf8') + // ...while the SCREEN is byte-identical to the pre-hide capture. This is the + // defect itself: the buffer advanced a whole frame and the canvas shows + // none of it. It also proves the screenshot did not heal the pane, which is + // what made the earlier pixel oracles blind. + expect( + Buffer.compare(beforeShot, staleShot), + 'the screen changed while the frame was latched, so no stale paint was captured' + ).toBe(0) + + // 4. The reveal repaint Orca runs — the production fix. + await orcaPage.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + ;(manager as { scheduleRevealPresent?: () => void }).scheduleRevealPresent?.() + } + }) + await orcaPage.waitForTimeout(1_200) + const repairedShot = await orcaPage.screenshot({ clip: clip! }) + writeFileSync(path.join(EVIDENCE_DIR, '3-after-fix.png'), repairedShot) + + // The latch must be gone and the pane must now be painting. + const latchAfter = await orcaPage.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const core = (pane as { terminal?: { _core?: unknown } }).terminal?._core as + | { coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } + | undefined + if (core?.coreService?.decPrivateModes?.synchronizedOutput) { + return true + } + } + } + return false + }) + expect(latchAfter, 'the reveal repaint left the latch set').toBe(false) + + // And the fix closes the gap: the repaired capture differs from the stale + // one, so the reveal repaint actually reached the screen. + expect( + Buffer.compare(staleShot, repairedShot), + 'the pre-fix and post-fix captures are byte-identical, so the fix repainted nothing' + ).not.toBe(0) + console.log(`[evidence] wrote PNGs to ${EVIDENCE_DIR}`) + }) +}) diff --git a/tests/e2e/terminal-synchronized-output-release-perf.spec.ts b/tests/e2e/terminal-synchronized-output-release-perf.spec.ts new file mode 100644 index 00000000000..6d354c12992 --- /dev/null +++ b/tests/e2e/terminal-synchronized-output-release-perf.spec.ts @@ -0,0 +1,285 @@ +/** + * Performance budget for the STA-2694 synchronized-output release (PR #10907). + * + * `releaseAbandonedSynchronizedOutput` runs inside `resetWebglTextureAtlas`, + * which a streaming alt-screen TUI can reach through the terminal-output atlas + * recovery path — not just on reveal. So it is worth proving, rather than + * asserting, that it does not add per-chunk work or extra repaints while an + * agent TUI floods output. + * + * Two things must hold: + * 1. Cost per call is O(1) — two property reads and, only when actually + * latched, one assignment plus a flush. No buffer/scrollback scan. + * 2. It adds NO repaints in steady state. The function returns early unless a + * frame was genuinely abandoned, so a healthy streaming TUI (which closes + * every `?2026l` it opens) must see zero releases and zero extra draws. + */ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +async function webglTerminalReady(page: Page): Promise { + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + for (const manager of window.__paneManagers?.values() ?? []) { + ;( + manager as { setTerminalGpuAcceleration?: (mode: string) => void } + ).setTerminalGpuAcceleration?.('on') + } + }) + return page + .waitForFunction( + () => { + for (const manager of window.__paneManagers?.values() ?? []) { + const diagnostics = + ( + manager as { getRenderingDiagnostics?: () => { hasWebgl: boolean }[] } + ).getRenderingDiagnostics?.() ?? [] + if (diagnostics.some((diagnostic) => diagnostic.hasWebgl)) { + return true + } + } + return false + }, + { timeout: 20_000 } + ) + .then(() => true) + .catch(() => false) +} + +test.describe('synchronized-output release performance (STA-2694)', () => { + test('a healthy streaming TUI triggers no releases and no extra draws', async ({ orcaPage }) => { + test.setTimeout(180_000) + test.skip(!(await webglTerminalReady(orcaPage)), 'WebGL renderer unavailable') + + const result = await orcaPage.evaluate(async () => { + let terminal: Record | null = null + let renderer: Record | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const candidate = (pane as { terminal?: Record }).terminal + const active = ( + candidate as + | { _core?: { _renderService?: { _renderer?: { value?: { _gl?: unknown } } } } } + | undefined + )?._core?._renderService?._renderer?.value + if (active?._gl) { + terminal = candidate as Record + renderer = active as Record + } + } + } + if (!terminal || !renderer) { + throw new Error('no WebGL pane') + } + const gl = renderer._gl as WebGL2RenderingContext + let drawCalls = 0 + const originalDraw = gl.drawElementsInstanced.bind(gl) + gl.drawElementsInstanced = function counted( + mode: number, + count: number, + type: number, + offset: number, + instanceCount: number + ): void { + drawCalls++ + originalDraw(mode, count, type, offset, instanceCount) + } + + const write = (terminal as { write: (d: string, cb?: () => void) => void }).write.bind( + terminal + ) + const modes = ( + terminal._core as { coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } + ).coreService?.decPrivateModes + + // A well-behaved alt-screen TUI: 200 fully-bracketed frames. + const FRAMES = 200 + const frame = (n: number): string => + `\x1b[?2026h\x1b[H${Array.from( + { length: 20 }, + (_, row) => `row ${row} frame ${n} ${'x'.repeat(60)}` + ).join('\r\n')}\x1b[?2026l` + + const start = performance.now() + for (let i = 0; i < FRAMES; i++) { + write(frame(i)) + } + await new Promise((resolve) => { + write('', () => resolve()) + }) + const writeMs = performance.now() - start + const drawsAfterStream = drawCalls + if (!modes) { + throw new Error('decPrivateModes unavailable — the measurement would be vacuous') + } + + // Time the REAL production path in its steady state (nothing latched). + // Driving resetWebglTextureAtlases rather than a copy of the early-out is + // the point: a future change that makes the release scan the buffer shows + // up here instead of hiding behind a hand-written mirror. + const drawsBeforeIdleResets = drawCalls + const releaseStart = performance.now() + for (let i = 0; i < FRAMES; i++) { + for (const manager of window.__paneManagers?.values() ?? []) { + ;(manager as { resetWebglTextureAtlases?: () => void }).resetWebglTextureAtlases?.() + } + } + const releaseMs = performance.now() - releaseStart + const drawsDuringIdleResets = drawCalls - drawsBeforeIdleResets + + return { + writeMs, + releaseMs, + drawsAfterStream, + drawsDuringIdleResets, + latchedAfterStream: modes.synchronizedOutput === true, + frames: FRAMES + } + }) + + console.log( + `[perf] ${result.frames} bracketed frames written in ${result.writeMs.toFixed(1)}ms` + ) + console.log( + `[perf] ${result.frames} unlatched atlas resets took ${result.releaseMs.toFixed(3)}ms ` + + `(${((result.releaseMs / result.frames) * 1000).toFixed(2)}µs each)` + ) + console.log( + `[perf] draws during stream: ${result.drawsAfterStream}, ` + + `during idle resets: ${result.drawsDuringIdleResets}` + ) + + // A healthy TUI closes every frame it opens, so the latch is clear and the + // release is a pure early-out that changes nothing. + expect(result.latchedAfterStream, 'a fully-bracketed stream should leave no latch behind').toBe( + false + ) + // The title's "no extra draws" claim, actually asserted: a bracketed stream + // that never abandons a frame must not provoke synchronous repaints. + expect( + result.drawsAfterStream, + `a fully-bracketed stream forced ${result.drawsAfterStream} synchronous draws` + ).toBe(0) + // Measured ~0.04ms per full atlas-reset call on CI-class hardware. Note this + // is the cost of the WHOLE recovery (atlas clear + refresh), not the release + // alone — the release itself is the early-out that contributes ~nothing, as + // the zero draw counts above show. Bound at 10x measured so it catches a + // change that makes this scan the buffer per pane without flaking on speed. + expect( + result.releaseMs / result.frames, + `unlatched reset cost ${result.releaseMs / result.frames}ms per call, expected < 0.4ms` + ).toBeLessThan(0.4) + }) + + // Worst case: every reveal finds a latched frame, so the release does its full + // work (assignment + handler flush) and the caller repaints. This bounds the + // cost of the path the fix actually exists for. + test('the latched path stays bounded and repaints exactly once per reveal', async ({ + orcaPage + }) => { + test.setTimeout(180_000) + test.skip(!(await webglTerminalReady(orcaPage)), 'WebGL renderer unavailable') + + const result = await orcaPage.evaluate(async () => { + let terminal: Record | null = null + let renderer: Record | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of (manager as { getPanes?: () => unknown[] }).getPanes?.() ?? []) { + const candidate = (pane as { terminal?: Record }).terminal + const active = ( + candidate as + | { _core?: { _renderService?: { _renderer?: { value?: { _gl?: unknown } } } } } + | undefined + )?._core?._renderService?._renderer?.value + if (active?._gl) { + terminal = candidate as Record + renderer = active as Record + } + } + } + if (!terminal || !renderer) { + throw new Error('no WebGL pane') + } + const gl = renderer._gl as WebGL2RenderingContext + let drawCalls = 0 + const originalDraw = gl.drawElementsInstanced.bind(gl) + gl.drawElementsInstanced = function counted( + mode: number, + count: number, + type: number, + offset: number, + instanceCount: number + ): void { + drawCalls++ + originalDraw(mode, count, type, offset, instanceCount) + } + ;(terminal as { write: (d: string) => void }).write(`\x1b[H${'content '.repeat(200)}`) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const modes = ( + terminal._core as { coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } + ).coreService?.decPrivateModes + if (!modes) { + // Without this the latch is never set, the recovery has nothing to do, + // and every assertion below passes without exercising the fix. + throw new Error('decPrivateModes unavailable — the latched path would be vacuous') + } + const REVEALS = 50 + drawCalls = 0 + const start = performance.now() + let latchSurvivedAReset = false + for (let i = 0; i < REVEALS; i++) { + modes.synchronizedOutput = true + for (const manager of window.__paneManagers?.values() ?? []) { + ;(manager as { resetWebglTextureAtlases?: () => void }).resetWebglTextureAtlases?.() + } + if (modes.synchronizedOutput) { + latchSurvivedAReset = true + } + } + const elapsedMs = performance.now() - start + return { + elapsedMs, + reveals: REVEALS, + drawCalls, + latchSurvivedAReset, + stillLatched: modes.synchronizedOutput === true + } + }) + + console.log( + `[perf] ${result.reveals} latched atlas resets in ${result.elapsedMs.toFixed(1)}ms ` + + `(${(result.elapsedMs / result.reveals).toFixed(2)}ms each), ${result.drawCalls} draw calls` + ) + + expect(result.stillLatched, 'the latch must be cleared by the recovery path').toBe(false) + // Every iteration must clear it, not just the last one. + expect( + result.latchSurvivedAReset, + 'a reset left the latch set, so the release did not run on every reveal' + ).toBe(false) + // "exactly once per reveal": the recovery repaints through the render-pause + // release, so draws must scale with reveals rather than multiplying per pane. + expect( + result.drawCalls, + `${result.drawCalls} draws across ${result.reveals} reveals exceeds one repaint each` + ).toBeLessThanOrEqual(result.reveals * 3) + // Reveal is a one-shot user action, so even the full path has ample headroom; + // this guards against a future change making it scan the buffer per pane. + expect( + result.elapsedMs / result.reveals, + `latched atlas reset cost ${result.elapsedMs / result.reveals}ms per reveal, expected < 20ms` + ).toBeLessThan(20) + }) +}) From 10ca89ac8b474dec1e0e5966f53c2828abd0750f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:36:39 -0700 Subject: [PATCH 011/203] feat(updater): switch to validated local mac builds (#10889) * feat(updater): switch to validated local mac builds * test(updater): cover local build recovery actions * fix(types): keep local build contract in project sources --- config/electron-builder.config.cjs | 25 +- config/scripts/build-mac-local.mjs | 46 +++ config/scripts/build-mac-local.test.mjs | 15 + .../scripts/electron-builder-config.test.mjs | 58 ++++ config/scripts/mac-build-compatibility.cjs | 34 +++ .../scripts/mac-build-compatibility.test.mjs | 36 +++ package.json | 2 +- .../local-build-candidate.test.ts | 172 +++++++++++ .../local-builds/local-build-candidate.ts | 279 ++++++++++++++++++ ...local-build-compatibility-contract.test.ts | 21 ++ .../local-builds/local-build-compatibility.ts | 71 +++++ .../local-build-feed-server.test.ts | 37 +++ .../local-builds/local-build-feed-server.ts | 92 ++++++ src/main/local-builds/local-build-switch.ts | 59 ++++ src/main/menu/register-app-menu.test.ts | 12 + src/main/menu/register-app-menu.ts | 11 +- src/main/updater-events.ts | 67 +++-- src/main/updater-fallback.ts | 3 + src/main/updater.test.ts | 204 +++++++++++++ src/main/updater.ts | 151 +++++++++- .../components/UpdateCard.error-card.test.tsx | 38 ++- src/renderer/src/components/UpdateCard.tsx | 218 ++++++++------ .../settings/GeneralUpdateSettingsSection.tsx | 60 ++-- .../sidebar/SidebarSettingsHelpMenu.tsx | 8 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../lib/update-check-click-options.test.ts | 19 +- .../src/lib/update-check-click-options.ts | 8 +- .../local-build-compatibility-contract.json | 11 + .../local-build-compatibility-contract.ts | 11 + src/shared/local-build-compatibility.test.ts | 45 +++ src/shared/local-build-compatibility.ts | 90 ++++++ src/shared/types.ts | 6 +- 36 files changed, 1769 insertions(+), 175 deletions(-) create mode 100644 config/scripts/build-mac-local.mjs create mode 100644 config/scripts/build-mac-local.test.mjs create mode 100644 config/scripts/mac-build-compatibility.cjs create mode 100644 config/scripts/mac-build-compatibility.test.mjs create mode 100644 src/main/local-builds/local-build-candidate.test.ts create mode 100644 src/main/local-builds/local-build-candidate.ts create mode 100644 src/main/local-builds/local-build-compatibility-contract.test.ts create mode 100644 src/main/local-builds/local-build-compatibility.ts create mode 100644 src/main/local-builds/local-build-feed-server.test.ts create mode 100644 src/main/local-builds/local-build-feed-server.ts create mode 100644 src/main/local-builds/local-build-switch.ts create mode 100644 src/shared/local-build-compatibility-contract.json create mode 100644 src/shared/local-build-compatibility-contract.ts create mode 100644 src/shared/local-build-compatibility.test.ts create mode 100644 src/shared/local-build-compatibility.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e9a3bb6b919..e731cb7f1d8 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -12,10 +12,13 @@ const { verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') +const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' +const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION +const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' @@ -59,8 +62,9 @@ const winSpeechNativeResource = { /** @type {import('electron-builder').Configuration} */ module.exports = { - appId: 'com.stablyai.orca', + appId, productName: 'Orca', + ...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}), directories: { buildResources: 'resources/build' }, @@ -171,6 +175,25 @@ module.exports = { if (!existsSync(resourcesDir)) { return } + if (context.electronPlatformName === 'darwin') { + const architectureByEnum = { 1: 'x64', 3: 'arm64' } + const architecture = architectureByEnum[context.arch] + if (!architecture) { + throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`) + } + const version = context.packager.appInfo.version + let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown' + if (commit === 'unknown') { + try { + commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + } catch { + // Source archives can still produce a signed build with an explicit version. + } + } + writeMacBuildCompatibility(resourcesDir, { version, commit, architecture }) + } prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) verifyPackagedMainRuntimeDeps(resourcesDir) // Why: boot the packaged daemon-entry under plain Node, but only for the diff --git a/config/scripts/build-mac-local.mjs b/config/scripts/build-mac-local.mjs new file mode 100644 index 00000000000..d0426a9468a --- /dev/null +++ b/config/scripts/build-mac-local.mjs @@ -0,0 +1,46 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +export function createLocalBuildVersion(baseVersion, timestamp, commit) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!Number.isSafeInteger(timestamp) || timestamp <= 0) { + throw new Error('Local build timestamp is invalid.') + } + const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12) + if (!sanitizedCommit) { + throw new Error('Git commit identity is empty.') + } + const suffix = `local.${timestamp}.${sanitizedCommit}` + return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}` +} + +export function getLocalBuildIdentity() { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + return { + commit, + version: createLocalBuildVersion(packageJson.version, Date.now(), commit) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getLocalBuildIdentity() + console.log(`[build:mac] local update version ${identity.version}`) + execFileSync( + process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + ['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'], + { + env: { + ...process.env, + ORCA_BUILD_COMMIT: identity.commit, + ORCA_LOCAL_BUILD_VERSION: identity.version + }, + stdio: 'inherit' + } + ) +} diff --git a/config/scripts/build-mac-local.test.mjs b/config/scripts/build-mac-local.test.mjs new file mode 100644 index 00000000000..21c5874323d --- /dev/null +++ b/config/scripts/build-mac-local.test.mjs @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { createLocalBuildVersion } from './build-mac-local.mjs' + +describe('createLocalBuildVersion', () => { + it('creates unique valid prerelease versions without changing the release base', () => { + expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe( + '1.4.159-rc.0.local.123456.abc123' + ) + expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123') + }) + + it('sanitizes commit identifiers', () => { + expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef') + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 4d492d68cd0..0d4c7dd7180 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,6 +19,12 @@ const { } = require('../packaged-runtime-node-modules.cjs') describe('electron-builder config', () => { + it('keeps the packaged app identity aligned with local-build validation', () => { + expect(electronBuilderConfig.appId).toBe( + require('../../src/shared/local-build-compatibility-contract.json').appId + ) + }) + it('excludes repo-only source trees from app.asar', () => { expect(electronBuilderConfig.files).toEqual( expect.arrayContaining([ @@ -173,6 +179,58 @@ describe('electron-builder config', () => { } }) + it('overrides packaged semver only for local macOS builds', () => { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = process.env.ORCA_LOCAL_BUILD_VERSION + const originalMacRelease = process.env.ORCA_MAC_RELEASE + try { + delete require.cache[configPath] + delete process.env.ORCA_MAC_RELEASE + process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc' + expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({ + version: '1.4.159-rc.0.local.123.abc' + }) + } finally { + if (originalMacRelease === undefined) { + delete process.env.ORCA_MAC_RELEASE + } else { + process.env.ORCA_MAC_RELEASE = originalMacRelease + } + if (original === undefined) { + delete process.env.ORCA_LOCAL_BUILD_VERSION + } else { + process.env.ORCA_LOCAL_BUILD_VERSION = original + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } + }) + + it('never applies local semver to release packaging', () => { + const configPath = require.resolve('../electron-builder.config.cjs') + const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION + const originalMacRelease = process.env.ORCA_MAC_RELEASE + try { + delete require.cache[configPath] + process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc' + process.env.ORCA_MAC_RELEASE = '1' + expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined() + } finally { + if (originalLocalVersion === undefined) { + delete process.env.ORCA_LOCAL_BUILD_VERSION + } else { + process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion + } + if (originalMacRelease === undefined) { + delete process.env.ORCA_MAC_RELEASE + } else { + process.env.ORCA_MAC_RELEASE = originalMacRelease + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } + }) + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/mac-build-compatibility.cjs b/config/scripts/mac-build-compatibility.cjs new file mode 100644 index 00000000000..4df85d4fea1 --- /dev/null +++ b/config/scripts/mac-build-compatibility.cjs @@ -0,0 +1,34 @@ +const { writeFileSync } = require('node:fs') +const { join } = require('node:path') +const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json') + +const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json' + +function createMacBuildCompatibility({ version, commit, architecture }) { + if (architecture !== 'arm64' && architecture !== 'x64') { + throw new Error(`Unsupported macOS build architecture: ${architecture}`) + } + return { + ...compatibilityContract, + buildId: `${version}-${commit}-${architecture}`, + version, + commit, + platform: 'darwin', + architecture + } +} + +function writeMacBuildCompatibility(resourcesDir, identity) { + const compatibility = createMacBuildCompatibility(identity) + writeFileSync( + join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME), + `${JSON.stringify(compatibility, null, 2)}\n`, + 'utf8' + ) +} + +module.exports = { + MAC_BUILD_COMPATIBILITY_FILENAME, + createMacBuildCompatibility, + writeMacBuildCompatibility +} diff --git a/config/scripts/mac-build-compatibility.test.mjs b/config/scripts/mac-build-compatibility.test.mjs new file mode 100644 index 00000000000..81b642c503d --- /dev/null +++ b/config/scripts/mac-build-compatibility.test.mjs @@ -0,0 +1,36 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs') + +describe('mac build compatibility metadata', () => { + it('binds version, commit, and architecture into the packaged contract', () => { + expect( + createMacBuildCompatibility({ + version: '1.2.3-local.1', + commit: 'abc123', + architecture: 'arm64' + }) + ).toMatchObject({ + formatVersion: 1, + appId: 'com.stablyai.orca', + buildId: '1.2.3-local.1-abc123-arm64', + version: '1.2.3-local.1', + commit: 'abc123', + stateSchemaVersion: 1, + platform: 'darwin', + architecture: 'arm64' + }) + }) + + it('rejects unsupported architecture metadata', () => { + expect(() => + createMacBuildCompatibility({ + version: '1.2.3', + commit: 'abc123', + architecture: 'universal' + }) + ).toThrow('Unsupported macOS build architecture') + }) +}) diff --git a/package.json b/package.json index b68b1a2b838..1df03908168 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", diff --git a/src/main/local-builds/local-build-candidate.test.ts b/src/main/local-builds/local-build-candidate.test.ts new file mode 100644 index 00000000000..2807164ed9c --- /dev/null +++ b/src/main/local-builds/local-build-candidate.test.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { stringify } from 'yaml' +import type { LocalBuildCompatibility } from '../../shared/local-build-compatibility' +import { loadLocalBuildCandidate } from './local-build-candidate' +import { startLocalBuildFeed } from './local-build-feed-server' + +const tempDirectories: string[] = [] +const execFileAsync = promisify(execFile) + +function compatibility(): LocalBuildCompatibility { + return { + formatVersion: 1, + appId: 'com.stablyai.orca', + buildId: '1.2.3-local.1-abc-arm64', + version: '1.2.3-local.1', + commit: 'abc', + stateSchemaVersion: 1, + readableStateSchemaVersions: [1], + daemonProtocolVersion: 28, + attachableDaemonProtocolVersions: [28], + platform: 'darwin', + architecture: 'arm64' + } +} + +async function fixture(options: { sha512?: string; url?: string } = {}) { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-build-')) + tempDirectories.push(directory) + const artifactName = 'orca-macos-arm64.zip' + const artifactPath = join(directory, artifactName) + const content = Buffer.from('signed-zip-placeholder') + await writeFile(artifactPath, content) + const manifestPath = join(directory, 'latest-mac.yml') + await writeFile( + manifestPath, + stringify({ + version: compatibility().version, + files: [ + { + url: options.url ?? artifactName, + sha512: options.sha512 ?? createHash('sha512').update(content).digest('base64'), + size: content.length + }, + { + url: 'orca-macos-arm64.dmg', + sha512: Buffer.alloc(64).toString('base64'), + size: 1 + } + ] + }) + ) + return { artifactPath, directory, manifestPath } +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('loadLocalBuildCandidate', () => { + it('returns a sanitized, architecture-specific feed after hash validation', async () => { + const { manifestPath } = await fixture() + const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + + expect(candidate.version).toBe('1.2.3-local.1') + expect([...candidate.artifacts.keys()]).toEqual(['orca-macos-arm64.zip']) + expect(candidate.manifestContent).toContain('orca-macos-arm64.zip') + await candidate.close() + }) + + it('rejects mismatched hashes and traversal paths', async () => { + const badHash = await fixture({ sha512: Buffer.alloc(64).toString('base64') }) + await expect( + loadLocalBuildCandidate(badHash.manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('SHA-512 verification failed') + + const traversal = await fixture({ url: '../orca-macos-arm64.zip' }) + await expect( + loadLocalBuildCandidate(traversal.manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('invalid file entry') + }) + + it('rejects symlinked artifacts', async () => { + const { artifactPath, directory, manifestPath } = await fixture() + const realArtifact = join(directory, 'real.zip') + await writeFile(realArtifact, 'signed-zip-placeholder') + await rm(artifactPath) + await symlink(realArtifact, artifactPath) + + await expect( + loadLocalBuildCandidate(manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('regular files, not links') + }) + + it('serves the same artifact descriptor that passed validation', async () => { + const { artifactPath, directory, manifestPath } = await fixture() + const movedArtifactPath = join(directory, 'validated.zip') + const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64', { + readCompatibility: async () => { + await rename(artifactPath, movedArtifactPath) + await writeFile(artifactPath, 'replacement') + return compatibility() + } + }) + const feed = await startLocalBuildFeed(candidate) + try { + await expect( + fetch(`${feed.url}orca-macos-arm64.zip`).then((response) => response.text()) + ).resolves.toBe('signed-zip-placeholder') + } finally { + await feed.close() + } + }) + + it.runIf(process.platform === 'darwin')( + 'reads signed compatibility metadata through the held artifact descriptor', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-build-zip-')) + tempDirectories.push(directory) + const zipRoot = join(directory, 'zip-root') + const resources = join(zipRoot, 'Orca.app', 'Contents', 'Resources') + await mkdir(resources, { recursive: true }) + await writeFile(join(resources, 'orca-local-build.json'), JSON.stringify(compatibility())) + const artifactName = 'orca-macos-arm64.zip' + const artifactPath = join(directory, artifactName) + await execFileAsync('/usr/bin/zip', ['-qry', artifactPath, 'Orca.app'], { cwd: zipRoot }) + const artifact = await readFile(artifactPath) + const manifestPath = join(directory, 'latest-mac.yml') + await writeFile( + manifestPath, + stringify({ + version: compatibility().version, + files: [ + { + url: artifactName, + sha512: createHash('sha512').update(artifact).digest('base64'), + size: artifact.length + } + ] + }) + ) + + const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64') + expect(candidate.compatibility).toEqual(compatibility()) + await candidate.close() + } + ) + + it('requires exactly one ZIP for the running architecture', async () => { + const { manifestPath } = await fixture() + await expect( + loadLocalBuildCandidate(manifestPath, 'x64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('exactly one x64 Orca ZIP') + }) +}) diff --git a/src/main/local-builds/local-build-candidate.ts b/src/main/local-builds/local-build-candidate.ts new file mode 100644 index 00000000000..772a17bb77e --- /dev/null +++ b/src/main/local-builds/local-build-candidate.ts @@ -0,0 +1,279 @@ +import { createHash } from 'node:crypto' +import { spawn } from 'node:child_process' +import { constants } from 'node:fs' +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { parse, stringify } from 'yaml' +import { + LOCAL_BUILD_COMPATIBILITY_FILENAME, + ORCA_APP_ID, + parseLocalBuildCompatibility, + type LocalBuildCompatibility +} from '../../shared/local-build-compatibility' +import { isValidAppVersion } from '../../shared/app-version' + +const MAX_MANIFEST_BYTES = 256 * 1024 +const MAX_COMPATIBILITY_BYTES = 64 * 1024 +const MAX_UPDATE_FILES = 8 +const MAX_ZIP_BYTES = 8 * 1024 * 1024 * 1024 +const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._ ()+-]*\.zip$/ + +type ManifestFile = { + url: string + sha512: string + size?: number +} + +export type LocalBuildCandidate = { + version: string + compatibility: LocalBuildCompatibility + manifestContent: string + artifacts: Map + close: () => Promise +} + +type LocalBuildCandidateLoaderOptions = { + readCompatibility?: (zipFile: FileHandle) => Promise +} + +function parseManifestFile(value: unknown): ManifestFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The local update manifest contains an invalid file entry.') + } + const record = value as Record + if ( + typeof record.url !== 'string' || + !SAFE_ARTIFACT_NAME.test(record.url) || + basename(record.url) !== record.url || + typeof record.sha512 !== 'string' || + !/^[A-Za-z0-9+/]{86}==$/.test(record.sha512) || + (record.size !== undefined && (!Number.isSafeInteger(record.size) || Number(record.size) <= 0)) + ) { + throw new Error('The local update manifest contains an invalid file entry.') + } + return { + url: record.url, + sha512: record.sha512, + ...(record.size === undefined ? {} : { size: Number(record.size) }) + } +} + +function isZipManifestFile(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const url = (value as Record).url + return typeof url === 'string' && url.toLowerCase().endsWith('.zip') +} + +function parseManifest(value: unknown): { version: string; files: ManifestFile[] } { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The selected file is not a valid macOS update manifest.') + } + const record = value as Record + if ( + typeof record.version !== 'string' || + record.version.length === 0 || + record.version.length > 100 || + !isValidAppVersion(record.version) || + !Array.isArray(record.files) || + record.files.length === 0 || + record.files.length > MAX_UPDATE_FILES + ) { + throw new Error('The selected file is not a valid macOS update manifest.') + } + const zipFiles = record.files.filter(isZipManifestFile) + if (zipFiles.length === 0) { + throw new Error('The selected macOS update manifest does not contain a ZIP.') + } + return { version: record.version, files: zipFiles.map(parseManifestFile) } +} + +async function hashFile(file: FileHandle): Promise { + const hash = createHash('sha512') + await new Promise((resolve, reject) => { + const stream = file.createReadStream({ autoClose: false, start: 0 }) + stream.on('data', (chunk) => hash.update(chunk)) + stream.on('error', reject) + stream.on('end', resolve) + }) + return hash.digest('base64') +} + +async function assertContainedRegularFile(rootPath: string, filePath: string): Promise { + const fileInfo = await lstat(filePath) + if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) { + throw new Error('Local update artifacts must be regular files, not links.') + } + const [resolvedRoot, resolvedFile] = await Promise.all([realpath(rootPath), realpath(filePath)]) + if (dirname(resolvedFile) !== resolvedRoot) { + throw new Error('Local update artifacts must stay beside latest-mac.yml.') + } +} + +function extractCompatibility(zipFile: FileHandle): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + '/usr/bin/unzip', + ['-p', '/dev/fd/3', `Orca.app/Contents/Resources/${LOCAL_BUILD_COMPATIBILITY_FILENAME}`], + { stdio: ['ignore', 'pipe', 'ignore', zipFile.fd] } + ) + const chunks: Buffer[] = [] + let outputBytes = 0 + let outputError: Error | null = null + const stdout = child.stdout + if (!stdout) { + child.kill() + reject(new Error('Could not read compatibility metadata.')) + return + } + stdout.on('data', (chunk: Buffer) => { + outputBytes += chunk.length + if (outputBytes > MAX_COMPATIBILITY_BYTES) { + outputError = new Error('Compatibility metadata is too large.') + child.kill() + return + } + chunks.push(chunk) + }) + child.on('error', reject) + child.on('close', (code) => { + if (outputError) { + reject(outputError) + } else if (code !== 0) { + reject(new Error(`unzip exited with status ${code ?? 'unknown'}.`)) + } else { + resolve(Buffer.concat(chunks).toString('utf8')) + } + }) + }) +} + +async function readCompatibility(zipFile: FileHandle): Promise { + let stdout: string + try { + stdout = await extractCompatibility(zipFile) + } catch (error) { + console.warn('[local-build] Could not read compatibility metadata:', error) + throw new Error( + 'This build predates local switching or is missing its signed compatibility metadata.' + ) + } + try { + return parseLocalBuildCompatibility(JSON.parse(stdout)) + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('The selected build has malformed compatibility metadata.') + } + throw error + } +} + +async function validateArtifact( + rootPath: string, + manifestFile: ManifestFile, + manifestVersion: string, + compatibilityReader: (zipFile: FileHandle) => Promise +): Promise<{ compatibility: LocalBuildCompatibility; file: FileHandle; size: number }> { + const filePath = join(rootPath, manifestFile.url) + await assertContainedRegularFile(rootPath, filePath) + const file = await open( + filePath, + constants.O_RDONLY | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0) + ) + try { + const fileStats = await file.stat() + if (!fileStats.isFile() || fileStats.size <= 0 || fileStats.size > MAX_ZIP_BYTES) { + throw new Error('The selected local build ZIP has an invalid size.') + } + if (manifestFile.size !== undefined && fileStats.size !== manifestFile.size) { + throw new Error(`Size verification failed for ${manifestFile.url}.`) + } + if ((await hashFile(file)) !== manifestFile.sha512) { + throw new Error(`SHA-512 verification failed for ${manifestFile.url}.`) + } + const compatibility = await compatibilityReader(file) + if (compatibility.appId !== ORCA_APP_ID || compatibility.version !== manifestVersion) { + throw new Error('The selected ZIP does not match its update manifest.') + } + return { compatibility, file, size: fileStats.size } + } catch (error) { + await file.close() + throw error + } +} + +export async function loadLocalBuildCandidate( + manifestPath: string, + architecture: NodeJS.Architecture, + options: LocalBuildCandidateLoaderOptions = {} +): Promise { + if (basename(manifestPath) !== 'latest-mac.yml') { + throw new Error('Select the latest-mac.yml generated by pn build:mac.') + } + await assertContainedRegularFile(dirname(manifestPath), manifestPath) + const manifestFile = await open( + manifestPath, + constants.O_RDONLY | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0) + ) + let manifestText: string + try { + const manifestStats = await manifestFile.stat() + if ( + !manifestStats.isFile() || + manifestStats.size <= 0 || + manifestStats.size > MAX_MANIFEST_BYTES + ) { + throw new Error('The selected update manifest is too large.') + } + manifestText = await manifestFile.readFile('utf8') + } finally { + await manifestFile.close() + } + const manifest = parseManifest(parse(manifestText, { maxAliasCount: 0 })) + const rootPath = dirname(manifestPath) + const compatibilityReader = options.readCompatibility ?? readCompatibility + const validationResults = await Promise.allSettled( + manifest.files.map((file) => + validateArtifact(rootPath, file, manifest.version, compatibilityReader) + ) + ) + const validated = validationResults + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value) + const failed = validationResults.find((result) => result.status === 'rejected') + if (failed?.status === 'rejected') { + await Promise.all(validated.map((entry) => entry.file.close())) + throw failed.reason + } + const matching = validated + .map((entry, index) => ({ ...entry, manifestFile: manifest.files[index] })) + .filter((entry) => entry.compatibility.architecture === architecture) + if (matching.length !== 1) { + await Promise.all(validated.map((entry) => entry.file.close())) + throw new Error(`The manifest must contain exactly one ${architecture} Orca ZIP.`) + } + const target = matching[0] + await Promise.all( + validated.filter((entry) => entry.file !== target.file).map((entry) => entry.file.close()) + ) + const sanitizedFile = { + url: target.manifestFile.url, + sha512: target.manifestFile.sha512, + ...(target.manifestFile.size === undefined ? {} : { size: target.manifestFile.size }) + } + return { + version: manifest.version, + compatibility: target.compatibility, + manifestContent: stringify({ + version: manifest.version, + files: [sanitizedFile], + path: sanitizedFile.url, + sha512: sanitizedFile.sha512 + }), + artifacts: new Map([[target.manifestFile.url, { file: target.file, size: target.size }]]), + close: async () => { + await target.file.close() + } + } +} diff --git a/src/main/local-builds/local-build-compatibility-contract.test.ts b/src/main/local-builds/local-build-compatibility-contract.test.ts new file mode 100644 index 00000000000..dc2e2af54fd --- /dev/null +++ b/src/main/local-builds/local-build-compatibility-contract.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { SCHEMA_VERSION } from '../../shared/constants' +import compatibilityContract from '../../shared/local-build-compatibility-contract.json' +import { LOCAL_BUILD_COMPATIBILITY_CONTRACT } from '../../shared/local-build-compatibility-contract' +import { + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + PROTOCOL_VERSION +} from '../daemon/daemon-protocol-version' + +describe('packaged local build compatibility contract', () => { + it('stays aligned with runtime state and daemon constants', () => { + expect(LOCAL_BUILD_COMPATIBILITY_CONTRACT).toEqual(compatibilityContract) + expect(compatibilityContract).toMatchObject({ + appId: 'com.stablyai.orca', + stateSchemaVersion: SCHEMA_VERSION, + readableStateSchemaVersions: [SCHEMA_VERSION], + daemonProtocolVersion: PROTOCOL_VERSION, + attachableDaemonProtocolVersions: [...PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION] + }) + }) +}) diff --git a/src/main/local-builds/local-build-compatibility.ts b/src/main/local-builds/local-build-compatibility.ts new file mode 100644 index 00000000000..015377f9d5d --- /dev/null +++ b/src/main/local-builds/local-build-compatibility.ts @@ -0,0 +1,71 @@ +import { SCHEMA_VERSION } from '../../shared/constants' +import { + getLocalBuildCompatibilityError, + type LocalBuildCompatibility +} from '../../shared/local-build-compatibility' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' +import { getDaemonProvider } from '../daemon/daemon-init' +import { getLocalPtyProvider } from '../ipc/pty' +import { LocalPtyProvider } from '../providers/local-pty-provider' + +export type LocalBuildCompatibilityResult = { + liveTerminalCount: number + liveDaemonProtocols: number[] +} + +async function getLiveDaemonProtocols(): Promise<{ + count: number + protocols: number[] +}> { + const provider = getDaemonProvider() + if (!provider) { + const localProvider = getLocalPtyProvider() + if (!(localProvider instanceof LocalPtyProvider)) { + throw new Error('Could not verify terminal preservation. Restart Orca and try again.') + } + const localProcesses = await localProvider.listProcesses() + if (localProcesses.length > 0) { + throw new Error( + 'Local build switching is blocked while non-persistent fallback terminals are running.' + ) + } + throw new Error('The terminal service is still starting. Try again in a moment.') + } + if (provider instanceof DegradedDaemonPtyProvider) { + throw new Error( + 'Local build switching is blocked while the terminal service is in fallback mode. Restart Orca first.' + ) + } + const adapters = + provider instanceof DaemonPtyRouter ? provider.getAllAdapters() : [provider as DaemonPtyAdapter] + const sessions = await Promise.all( + adapters.map(async (adapter) => ({ + protocol: adapter.protocolVersion, + count: (await adapter.listSessions()).length + })) + ) + return { + count: sessions.reduce((sum, entry) => sum + entry.count, 0), + protocols: sessions.filter((entry) => entry.count > 0).map((entry) => entry.protocol) + } +} + +export async function assertLocalBuildCompatibility( + target: LocalBuildCompatibility +): Promise { + const stateCompatibilityError = getLocalBuildCompatibilityError(target, SCHEMA_VERSION, []) + if (stateCompatibilityError) { + throw new Error(stateCompatibilityError) + } + const live = await getLiveDaemonProtocols() + const compatibilityError = getLocalBuildCompatibilityError(target, SCHEMA_VERSION, live.protocols) + if (compatibilityError) { + throw new Error(compatibilityError) + } + return { + liveTerminalCount: live.count, + liveDaemonProtocols: [...new Set(live.protocols)].sort((left, right) => left - right) + } +} diff --git a/src/main/local-builds/local-build-feed-server.test.ts b/src/main/local-builds/local-build-feed-server.test.ts new file mode 100644 index 00000000000..6c020c10fef --- /dev/null +++ b/src/main/local-builds/local-build-feed-server.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { LocalBuildCandidate } from './local-build-candidate' +import { startLocalBuildFeed } from './local-build-feed-server' + +describe('startLocalBuildFeed', () => { + it('serves only tokenized manifest and validated artifact routes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-feed-')) + const artifactPath = join(directory, 'orca-macos-arm64.zip') + await writeFile(artifactPath, 'zip') + const artifactFile = await open(artifactPath, 'r') + const candidate = { + version: '1.2.3-local.1', + manifestContent: 'version: 1.2.3-local.1\n', + artifacts: new Map([['orca-macos-arm64.zip', { file: artifactFile, size: 3 }]]), + close: () => artifactFile.close() + } as LocalBuildCandidate + const feed = await startLocalBuildFeed(candidate) + try { + await expect( + fetch(`${feed.url}latest-mac.yml`).then((response) => response.text()) + ).resolves.toContain('1.2.3-local.1') + await expect( + fetch(`${feed.url}orca-macos-arm64.zip`).then((response) => response.text()) + ).resolves.toBe('zip') + const baseUrl = new URL(feed.url) + await expect( + fetch(`${baseUrl.origin}/latest-mac.yml`).then((response) => response.status) + ).resolves.toBe(404) + } finally { + await feed.close() + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/local-builds/local-build-feed-server.ts b/src/main/local-builds/local-build-feed-server.ts new file mode 100644 index 00000000000..443cf17bfc2 --- /dev/null +++ b/src/main/local-builds/local-build-feed-server.ts @@ -0,0 +1,92 @@ +import { randomBytes } from 'node:crypto' +import { createServer, type Server } from 'node:http' +import type { LocalBuildCandidate } from './local-build-candidate' + +export type LocalBuildFeed = { + url: string + close: () => Promise +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()) + }) +} + +export async function startLocalBuildFeed(candidate: LocalBuildCandidate): Promise { + const token = randomBytes(24).toString('hex') + const prefix = `/${token}/` + const server = createServer((request, response) => { + if (request.method !== 'GET' || !request.url) { + response.writeHead(404).end() + return + } + let pathname: string + try { + pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname) + } catch { + response.writeHead(400).end() + return + } + if (!pathname.startsWith(prefix)) { + response.writeHead(404).end() + return + } + const filename = pathname.slice(prefix.length) + if (filename === 'latest-mac.yml') { + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/yaml; charset=utf-8' + }) + response.end(candidate.manifestContent) + return + } + const artifact = candidate.artifacts.get(filename) + if (!artifact) { + response.writeHead(404).end() + return + } + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/zip' + }) + const stream = artifact.file.createReadStream({ + autoClose: false, + start: 0, + end: artifact.size - 1 + }) + stream.on('error', () => response.destroy()) + response.on('error', () => stream.destroy()) + response.on('close', () => stream.destroy()) + stream.pipe(response) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + server.on('error', (error) => console.warn('[updater] Local build feed error:', error)) + resolve() + }) + }).catch(async (error) => { + await candidate.close() + throw error + }) + const address = server.address() + if (!address || typeof address === 'string') { + await closeServer(server) + await candidate.close() + throw new Error('Could not start the local update feed.') + } + let closed = false + return { + url: `http://127.0.0.1:${address.port}${prefix}`, + close: async () => { + if (closed) { + return + } + closed = true + await closeServer(server) + await candidate.close() + } + } +} diff --git a/src/main/local-builds/local-build-switch.ts b/src/main/local-builds/local-build-switch.ts new file mode 100644 index 00000000000..2f1982c726e --- /dev/null +++ b/src/main/local-builds/local-build-switch.ts @@ -0,0 +1,59 @@ +import { app, dialog, type BrowserWindow } from 'electron' +import { SCHEMA_VERSION } from '../../shared/constants' +import { compareAppVersions } from '../../shared/app-version' +import { assertLocalBuildCompatibility } from './local-build-compatibility' +import { loadLocalBuildCandidate, type LocalBuildCandidate } from './local-build-candidate' + +export async function chooseLocalBuild( + window: BrowserWindow | null +): Promise { + const openDialogOptions: Electron.OpenDialogOptions = { + title: 'Choose a Local Orca Build', + buttonLabel: 'Choose Build', + properties: ['openFile'], + filters: [{ name: 'Orca update manifest', extensions: ['yml'] }] + } + const selection = await (window + ? dialog.showOpenDialog(window, openDialogOptions) + : dialog.showOpenDialog(openDialogOptions)) + const manifestPath = selection.filePaths[0] + if (selection.canceled || !manifestPath) { + return null + } + const candidate = await loadLocalBuildCandidate(manifestPath, process.arch) + try { + if (compareAppVersions(candidate.version, app.getVersion()) === 0) { + throw new Error( + 'This build has the same version as the running app. Run pn build:mac again to create a uniquely versioned build.' + ) + } + const compatibility = await assertLocalBuildCompatibility(candidate.compatibility) + const terminalSummary = + compatibility.liveTerminalCount === 0 + ? 'No live terminals need to reconnect.' + : `${compatibility.liveTerminalCount} live terminal${ + compatibility.liveTerminalCount === 1 ? '' : 's' + } will reconnect after restart.` + const messageBoxOptions: Electron.MessageBoxOptions = { + type: 'question', + title: 'Use Local Orca Build?', + message: `${app.getVersion()} → ${candidate.version}`, + detail: `${terminalSummary}\nWorkspace cards and settings are compatible with state schema ${SCHEMA_VERSION}.\n\nThe build must have the same valid code signature as Orca or installation will stop.`, + buttons: ['Use Local Build', 'Cancel'], + defaultId: 0, + cancelId: 1, + noLink: true + } + const confirmation = await (window + ? dialog.showMessageBox(window, messageBoxOptions) + : dialog.showMessageBox(messageBoxOptions)) + if (confirmation.response === 0) { + return candidate + } + } catch (error) { + await candidate.close() + throw error + } + await candidate.close() + return null +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index fa636e8623c..8433fd93e8a 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -171,6 +171,11 @@ describe('registerAppMenu', () => { undefined as never, (isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent ) + item?.click?.( + {} as never, + undefined as never, + { altKey: true, shiftKey: true } as Electron.KeyboardEvent + ) item?.click?.( {} as never, undefined as never, @@ -187,6 +192,13 @@ describe('registerAppMenu', () => { [{ includePrerelease: true, includePerfPrerelease: true }], [{ includePrerelease: false, includePerfPrerelease: true }], [{ includePrerelease: false, includePerfPrerelease: false }], + [ + { + includePrerelease: !isMac, + includePerfPrerelease: false, + ...(isMac ? { localBuild: true } : {}) + } + ], [{ includePrerelease: false, includePerfPrerelease: false }] ]) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 2f3a3d70a97..d1e43d4fb82 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -96,10 +96,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { event ) => { const modifierClick = !event.triggeredByAccelerator + const localBuild = isMac && modifierClick && event.altKey === true const includePerfPrerelease = - modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) - const includePrerelease = modifierClick && event.shiftKey === true - onCheckForUpdates({ includePrerelease, includePerfPrerelease }) + !localBuild && modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) + const includePrerelease = !localBuild && modifierClick && event.shiftKey === true + onCheckForUpdates({ + includePrerelease, + includePerfPrerelease, + ...(localBuild ? { localBuild: true } : {}) + }) } const checkForUpdatesItem: Electron.MenuItemConstructorOptions = { diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 5164161854e..411bb1a7184 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -30,7 +30,8 @@ type UpdaterHandlerContext = { getUserInitiatedCheck: () => boolean handleQuitAndInstallFailure: () => boolean isQuitAndInstallHandoffActive: () => boolean - hasNewerDownloadedVersion: () => boolean + hasInstallableDownloadedVersion: () => boolean + isLocalBuildCheck: () => boolean shouldHandleUpdaterErrorEvent: () => boolean clearUpdateAvailableEventPending: (attemptId: number | null) => void isActiveUpdateCheckAttempt: (attemptId: number) => boolean @@ -40,6 +41,7 @@ type UpdaterHandlerContext = { performQuitAndInstall: () => void | Promise shouldDeferMacQuitForInstall: () => boolean recordCompletedUpdateCheck: () => void + restoreReleaseUpdateSource: () => void sendCheckFailureStatus: ( message: string, userInitiated?: boolean, @@ -70,7 +72,8 @@ export function registerAutoUpdaterHandlers({ getUserInitiatedCheck, handleQuitAndInstallFailure, isQuitAndInstallHandoffActive, - hasNewerDownloadedVersion, + hasInstallableDownloadedVersion, + isLocalBuildCheck, shouldHandleUpdaterErrorEvent, clearUpdateAvailableEventPending, isActiveUpdateCheckAttempt, @@ -80,6 +83,7 @@ export function registerAutoUpdaterHandlers({ performQuitAndInstall, shouldDeferMacQuitForInstall, recordCompletedUpdateCheck, + restoreReleaseUpdateSource, sendCheckFailureStatus, sendErrorStatus, sendStatus, @@ -93,9 +97,9 @@ export function registerAutoUpdaterHandlers({ // Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready". if (process.platform === 'darwin') { nativeUpdater.on('update-downloaded', () => { - const hasNewerVersion = hasNewerDownloadedVersion() - handleMacInstallerReady(hasNewerVersion, performQuitAndInstall, () => { - // Send the held 'downloaded' status now, only if the staged version is newer. + const hasInstallableVersion = hasInstallableDownloadedVersion() + handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => { + // Send the held status only while its staged build is still installable. sendStatus({ state: 'downloaded', version: getPendingInstallVersion(), @@ -121,7 +125,7 @@ export function registerAutoUpdaterHandlers({ if ( deferMacQuitUntilInstallerReady( getCurrentStatus(), - hasNewerDownloadedVersion(), + hasInstallableDownloadedVersion(), getPendingInstallVersion, sendStatus ) @@ -158,8 +162,8 @@ export function registerAutoUpdaterHandlers({ const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() setUserInitiatedCheck(false) - // Guard: don't show an update that isn't actually newer than what's running. - if (compareVersions(info.version, app.getVersion()) <= 0) { + // Release checks remain newer-only; validated local builds may intentionally downgrade. + if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { clearAvailableUpdateContext() if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence. @@ -178,7 +182,9 @@ export function registerAutoUpdaterHandlers({ markUpdateAvailableEventPending(attemptId) void (async () => { try { - const changelog = await fetchChangelog(info.version, app.getVersion()).catch(() => null) + const changelog = isLocalBuildCheck() + ? null + : await fetchChangelog(info.version, app.getVersion()).catch(() => null) // Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast. if (!isActiveUpdateCheckAttempt(attemptId)) { @@ -191,13 +197,15 @@ export function registerAutoUpdaterHandlers({ // Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state. setAvailableVersion(info.version) setAvailableReleaseUrl(null) - if (missingManifestFallback || publishingWindowLastGoodCheck) { - // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } else { - recordCompletedUpdateCheck() - if (!wasUserInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + if (!isLocalBuildCheck()) { + if (missingManifestFallback || publishingWindowLastGoodCheck) { + // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes. + scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } else { + recordCompletedUpdateCheck() + if (!wasUserInitiated) { + scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } } } @@ -217,18 +225,24 @@ export function registerAutoUpdaterHandlers({ const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult() const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() + const localBuildCheck = isLocalBuildCheck() setUserInitiatedCheck(false) clearAvailableUpdateContext() - if (missingManifestFallback || publishingWindowLastGoodCheck) { - // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } else { - recordCompletedUpdateCheck() - if (!wasUserInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + if (!localBuildCheck) { + if (missingManifestFallback || publishingWindowLastGoodCheck) { + // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h. + scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } else { + recordCompletedUpdateCheck() + if (!wasUserInitiated) { + scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } } } sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) + if (localBuildCheck) { + restoreReleaseUpdateSource() + } }) autoUpdater.on('download-progress', (progress) => { @@ -242,8 +256,8 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('update-downloaded', (info) => { clearBackgroundCheckLaunchPending() - // Skip the banner for non-newer versions (same-version or stale cached updates). - if (compareVersions(info.version, app.getVersion()) <= 0) { + // Release downloads remain newer-only; the local source was validated before checking. + if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { clearAvailableUpdateContext() sendStatus({ state: 'not-available' }) return @@ -288,5 +302,8 @@ export function registerAutoUpdaterHandlers({ return } sendErrorStatus(message, wasUserInitiated || undefined) + if (isLocalBuildCheck()) { + restoreReleaseUpdateSource() + } }) } diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 411ddbd0aba..d91cdf75ef2 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -10,6 +10,9 @@ export const isPrereleaseVersion = isPrereleaseAppVersion export const isValidVersion = isValidAppVersion export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean { + if (left.source !== right.source) { + return false + } switch (left.state) { case 'idle': return right.state === 'idle' diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index b09c727ee8c..730dd5be96c 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -50,6 +50,8 @@ const { autoUpdaterMock.setFeedURL.mockClear() autoUpdaterMock.updateConfigPath = undefined autoUpdaterMock.allowPrerelease = false + autoUpdaterMock.allowDowngrade = false + autoUpdaterMock.disableDifferentialDownload = false autoUpdaterMock.autoRunAppAfterInstall = true delete (autoUpdaterMock as Record).verifyUpdateCodeSignature } @@ -59,6 +61,8 @@ const { autoInstallOnAppQuit: false, autoRunAppAfterInstall: true, allowPrerelease: false, + allowDowngrade: false, + disableDifferentialDownload: false, on, checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), @@ -146,6 +150,14 @@ const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({ fetchNewerReleaseTagsMock: vi.fn() })) +const { chooseLocalBuildMock, startLocalBuildFeedMock, closeLocalBuildFeedMock } = vi.hoisted( + () => ({ + chooseLocalBuildMock: vi.fn(), + startLocalBuildFeedMock: vi.fn(), + closeLocalBuildFeedMock: vi.fn() + }) +) + vi.mock('./updater-prerelease-feed', () => ({ fetchNewerReleaseTagsWithReadiness: async (...args: unknown[]) => { const result = await fetchNewerReleaseTagsMock(...args) @@ -157,6 +169,14 @@ vi.mock('./updater-prerelease-feed', () => ({ `https://github.com/stablyai/orca/releases/download/${tag}` })) +vi.mock('./local-builds/local-build-switch', () => ({ + chooseLocalBuild: chooseLocalBuildMock +})) + +vi.mock('./local-builds/local-build-feed-server', () => ({ + startLocalBuildFeed: startLocalBuildFeedMock +})) + describe('updater', () => { beforeEach(() => { vi.resetModules() @@ -177,6 +197,12 @@ describe('updater', () => { shouldApplyNudgeMock.mockReset().mockReturnValue(false) fetchChangelogMock.mockReset().mockResolvedValue(null) fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([]) + chooseLocalBuildMock.mockReset() + closeLocalBuildFeedMock.mockReset() + startLocalBuildFeedMock.mockReset().mockResolvedValue({ + url: 'http://127.0.0.1:1234/token/', + close: closeLocalBuildFeedMock + }) vi.unstubAllGlobals() vi.useRealTimers() }) @@ -196,6 +222,184 @@ describe('updater', () => { expect(powerMonitorOnMock).not.toHaveBeenCalled() }) + it.runIf(process.platform === 'darwin')( + 'allows a validated local build to downgrade through the normal updater lifecycle', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' }) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowDowngrade).toBe(true) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(true) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'http://127.0.0.1:1234/token/' + }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'available', + version: '0.9.0-local.1', + source: 'local' + }) + ) + }) + + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'http://127.0.0.1:1234/token/' + }) + expect(autoUpdaterMock.allowDowngrade).toBe(true) + + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + checkForUpdatesFromMenu() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores ordinary release checks after local build selection fails', + async () => { + chooseLocalBuildMock.mockRejectedValue(new Error('invalid local build')) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'invalid local build', + userInitiated: true, + source: 'local' + }) + }) + + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores ordinary release checks after a local build is unavailable', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-not-available') + return Promise.resolve(undefined) + }) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + }) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true, + source: 'local' + }) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores ordinary release checks after a local updater failure', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('local feed failed')) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'local feed failed', + userInitiated: true, + source: 'local' + }) + }) + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + it('deduplicates identical check errors from the event and rejected promise', async () => { autoUpdaterMock.checkForUpdates.mockImplementation(() => { autoUpdaterMock.emit('checking-for-update') diff --git a/src/main/updater.ts b/src/main/updater.ts index a1373af8a62..34067e03b50 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -44,6 +44,7 @@ import { hasServeUpdateSupervisor, requestServeUpdateHandoff } from './serve-update-handoff' +import type { LocalBuildFeed } from './local-builds/local-build-feed-server' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -128,6 +129,9 @@ let downloadInFlight = false /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ let quittingForUpdate = false let autoUpdater: ElectronAutoUpdater | null = null +let activeUpdateSource: 'release' | 'local' = 'release' +let activeLocalBuildFeed: LocalBuildFeed | null = null +let localBuildSelectionInProgress = false function getAutoUpdater(): ElectronAutoUpdater { if (!autoUpdater) { @@ -141,6 +145,36 @@ function clearAvailableUpdateContext(): void { availableReleaseUrl = null } +function closeLocalBuildFeed(): void { + const feed = activeLocalBuildFeed + activeLocalBuildFeed = null + if (feed) { + void feed.close() + } +} + +function restoreReleaseUpdateSource(): void { + closeLocalBuildFeed() + activeUpdateSource = 'release' + if (autoUpdater) { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } +} + +function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { + clearAvailableUpdateContext() + if ( + currentStatus.state !== 'error' || + currentStatus.message !== message || + currentStatus.userInitiated !== userInitiated || + currentStatus.source !== 'local' + ) { + sendStatus({ state: 'error', message, userInitiated, source: 'local' }) + } + restoreReleaseUpdateSource() +} + function clearPrereleaseFallbackContext(): void { pendingPrereleaseFallback = null } @@ -219,7 +253,9 @@ function sendStatus(status: UpdateStatus): void { } } - const decoratedStatus = decorateStatusWithActiveNudge(status) + const sourcedStatus: UpdateStatus = + activeUpdateSource === 'local' ? { ...status, source: 'local' } : status + const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) if (isUpdateCheckResultState(status.state)) { finishActiveUpdateCheckAttempt() @@ -523,8 +559,11 @@ function getKnownReleaseUrl(): string | undefined { return availableReleaseUrl ?? undefined } -function hasNewerDownloadedVersion(): boolean { - return availableVersion !== null && compareVersions(availableVersion, app.getVersion()) > 0 +function hasInstallableDownloadedVersion(): boolean { + return ( + availableVersion !== null && + (activeUpdateSource === 'local' || compareVersions(availableVersion, app.getVersion()) > 0) + ) } function getPendingInstallVersion(): string { @@ -769,6 +808,10 @@ async function sendCheckFailureStatus( source: CheckFailureSource = 'promise', sourceError?: unknown ): Promise { + if (activeUpdateSource === 'local') { + sendLocalBuildErrorAndRestore(message, userInitiated) + return + } const failureKey = getCheckFailureKey(message, userInitiated) if ( source === 'promise' && @@ -1172,6 +1215,9 @@ function retryPrereleaseFallbackAfterMissingManifest( function runBackgroundUpdateCheck( nudgeId: string | null = getPersistedPendingUpdateNudgeId() ): void { + if (activeUpdateSource === 'local' || localBuildSelectionInProgress) { + return + } if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { return } @@ -1238,6 +1284,20 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { sendStatus({ state: 'not-available', userInitiated: true }) return } + if (options?.localBuild) { + void checkForLocalBuildFromMenu() + return + } + if (localBuildSelectionInProgress) { + return + } + if ( + activeUpdateSource === 'local' && + (currentStatus.state === 'checking' || currentStatus.state === 'downloading') + ) { + return + } + restoreReleaseUpdateSource() const checkVariant = getUpdateCheckVariant(options) if (checkVariant === 'prerelease') { @@ -1303,12 +1363,63 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { }) } +async function checkForLocalBuildFromMenu(): Promise { + if (process.platform !== 'darwin') { + sendLocalBuildErrorAndRestore( + 'Local build switching is currently available only on macOS.', + true + ) + return + } + if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { + return + } + if (localBuildSelectionInProgress) { + return + } + localBuildSelectionInProgress = true + try { + const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ + import('./local-builds/local-build-switch'), + import('./local-builds/local-build-feed-server') + ]) + const candidate = await chooseLocalBuild(mainWindowRef) + if (!candidate) { + return + } + closeLocalBuildFeed() + const feed = await startLocalBuildFeed(candidate) + activeLocalBuildFeed = feed + activeUpdateSource = 'local' + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + clearAvailableUpdateContext() + activeUpdateNudgeId = null + userInitiatedCheck = true + sendStatus({ state: 'checking', userInitiated: true }) + + const updater = getAutoUpdater() + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.setFeedURL({ provider: 'generic', url: feed.url }) + const attemptId = beginUpdateCheckAttempt() + markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + userInitiatedCheck = false + sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) + } finally { + localBuildSelectionInProgress = false + } +} + export function isQuittingForUpdate(): boolean { return quittingForUpdate } export function quitAndInstall(): void { - if (pendingQuitAndInstallTimer || quitAndInstallInProgress) { + if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) { return } @@ -1319,7 +1430,7 @@ export function quitAndInstall(): void { if ( deferMacQuitUntilInstallerReady( currentStatus, - hasNewerDownloadedVersion(), + hasInstallableDownloadedVersion(), getPendingInstallVersion, sendStatus ) @@ -1441,6 +1552,10 @@ export function setupAutoUpdater( const autoUpdater = getAutoUpdater() autoUpdater.autoDownload = false + if (activeUpdateSource === 'release') { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. autoUpdater.autoInstallOnAppQuit = updateInstallMode === 'interactive' // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. @@ -1457,10 +1572,12 @@ export function setupAutoUpdater( // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. // Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download. - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) + if (activeUpdateSource === 'release') { + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } if (autoUpdaterInitialized) { return @@ -1480,7 +1597,8 @@ export function setupAutoUpdater( getUserInitiatedCheck: () => userInitiatedCheck, handleQuitAndInstallFailure, isQuitAndInstallHandoffActive, - hasNewerDownloadedVersion, + hasInstallableDownloadedVersion, + isLocalBuildCheck: () => activeUpdateSource === 'local', shouldHandleUpdaterErrorEvent, performQuitAndInstall, clearUpdateAvailableEventPending, @@ -1494,6 +1612,7 @@ export function setupAutoUpdater( shouldSuppressMissingManifestPrereleaseFallbackEvent, suppressMissingManifestPrereleaseFallbackPromiseFailure, recordCompletedUpdateCheck, + restoreReleaseUpdateSource, sendStatus, scheduleAutomaticUpdateCheck, clearBackgroundCheckLaunchPending, @@ -1544,13 +1663,13 @@ export function setupAutoUpdater( } export function downloadUpdate(): void { - if (downloadInFlight) { + if (localBuildSelectionInProgress || downloadInFlight) { return } // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. const canStart = currentStatus.state === 'available' || - (currentStatus.state === 'error' && hasNewerDownloadedVersion()) + (currentStatus.state === 'error' && hasInstallableDownloadedVersion()) if (!canStart) { return } @@ -1562,6 +1681,7 @@ export function downloadUpdate(): void { return } downloadInFlight = true + const localBuildDownload = activeUpdateSource === 'local' beginMacUpdateDownload() // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. sendStatus({ state: 'downloading', percent: 0, version }) @@ -1569,6 +1689,11 @@ export function downloadUpdate(): void { .downloadUpdate() .catch((err) => { downloadInFlight = false - sendErrorStatus(String(err?.message ?? err)) + const message = String(err?.message ?? err) + if (localBuildDownload) { + sendLocalBuildErrorAndRestore(message) + } else { + sendErrorStatus(message) + } }) } diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index cbe84536bbf..e440729f273 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -6,6 +6,7 @@ import { UpdateCard } from './UpdateCard' const openUrl = vi.fn() const download = vi.fn() +const check = vi.fn() function renderAfterAvailableStatus(): void { useAppStore.setState({ @@ -26,6 +27,7 @@ beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) openUrl.mockReset() download.mockReset() + check.mockReset() Object.defineProperty(window, 'api', { configurable: true, value: { @@ -34,7 +36,7 @@ beforeEach(() => { shell: { openUrl }, ui: { set: vi.fn().mockResolvedValue(undefined) }, updater: { - check: vi.fn(), + check, dismissNudge: vi.fn(), download, quitAndInstall: vi.fn().mockResolvedValue(undefined) @@ -94,3 +96,37 @@ describe('UpdateCard Windows signature failures', () => { ) }) }) + +describe('UpdateCard local builds', () => { + it('does not link local versions to GitHub release downloads', () => { + useAppStore.setState({ + updateStatus: { + state: 'available', + version: '1.4.100-local.1.abc', + changelog: null, + source: 'local' + }, + updateChangelog: null, + dismissedUpdateVersion: null, + updateCardCollapsed: false, + updateReassuranceSeen: true + }) + render() + + expect(screen.queryByText('Release notes')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Update' })) + expect(download).toHaveBeenCalledTimes(1) + + act(() => + useAppStore.getState().setUpdateStatus({ + state: 'error', + message: 'signature rejected', + source: 'local' + }) + ) + expect(screen.getByText('Local Build Error')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Choose Another Build' })) + expect(check).toHaveBeenCalledWith({ localBuild: true }) + }) +}) diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 0612c91fd39..1e04d8df26a 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -53,7 +53,7 @@ type ErrorCardModel = { explainer?: string /** Raw error text, shown only when the user expands "Show details". */ detail?: string - releaseUrl: string + releaseUrl?: string /** Overrides the secondary button label (defaults to "Download Manually"). */ manualLabel?: string primaryAction?: { @@ -137,6 +137,7 @@ export function UpdateCard() { // Tracks card exit so the fade-out animation plays before unmount. const [exiting, setExiting] = useState(false) const changelog: ChangelogData | null = storeChangelog + const isLocalBuild = status.source === 'local' // Why: the 'error' variant carries no version, but the card needs it for the fallback URL and dismiss; cache from states that have it. const versionRef = useRef(null) @@ -324,79 +325,104 @@ export function UpdateCard() { status.state === 'error' && isWindowsSignatureCheckUnavailableFailure(status.message) const errorCard: ErrorCardModel | null = status.state === 'error' - ? isHttp2UpdateError + ? isLocalBuild ? { - variant: 'http1Compatibility', - title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), - summary: 'Orca can retry through HTTP/1.1 compatibility mode.', - explainer: translate( - 'auto.components.UpdateCard.90559b14e3', - 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' - ), - detail: compatibilitySetupError ?? status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), + title: cachedVersion + ? translate('auto.components.UpdateCard.8cf17b10af', 'Local Build Error') + : translate('auto.components.UpdateCard.a4650b0dc4', 'Could Not Use Local Build'), + summary: cachedVersion + ? translate( + 'auto.components.UpdateCard.b1e390250d', + 'Could not complete the local build switch.' + ) + : translate( + 'auto.components.UpdateCard.d29740d175', + 'The selected build could not be used.' + ), + detail: status.message, primaryAction: { - label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), - pendingLabel: 'Restarting...', - isPending: compatibilityRelaunching, - onClick: handleEnableHttp1Compatibility + label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'), + onClick: () => { + void window.api.updater.check({ localBuild: true }) + } } } - : isSignatureMismatchError + : isHttp2UpdateError ? { - // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path. - variant: 'security', - title: translate('auto.components.UpdateCard.5b309b19f3', "Update Wasn't Installed"), - summary: translate( - 'auto.components.UpdateCard.092f09fc14', - "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." + variant: 'http1Compatibility', + title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), + summary: 'Orca can retry through HTTP/1.1 compatibility mode.', + explainer: translate( + 'auto.components.UpdateCard.90559b14e3', + 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' ), - detail: status.message, - // Why: linking the rejected version would let users bypass the publisher check by re-running it. - releaseUrl: releaseUrlForVersion(null), - manualLabel: translate( - 'auto.components.UpdateCard.c9ff9b9ec2', - 'Check official releases' - ) + detail: compatibilitySetupError ?? status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), + pendingLabel: 'Restarting...', + isPending: compatibilityRelaunching, + onClick: handleEnableHttp1Compatibility + } } - : isSignatureCheckBlockedError + : isSignatureMismatchError ? { + // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path. + variant: 'security', title: translate( - 'auto.components.UpdateCard.e944c2de43', - 'Update Verification Blocked' + 'auto.components.UpdateCard.5b309b19f3', + "Update Wasn't Installed" ), summary: translate( - 'auto.components.UpdateCard.a05992a26b', - "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." + 'auto.components.UpdateCard.092f09fc14', + "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." ), detail: status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate + // Why: linking the rejected version would let users bypass the publisher check by re-running it. + releaseUrl: releaseUrlForVersion(null), + manualLabel: translate( + 'auto.components.UpdateCard.c9ff9b9ec2', + 'Check official releases' + ) + } + : isSignatureCheckBlockedError + ? { + title: translate( + 'auto.components.UpdateCard.e944c2de43', + 'Update Verification Blocked' + ), + summary: translate( + 'auto.components.UpdateCard.a05992a26b', + "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." + ), + detail: status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: handleUpdate + } } - } - : { - // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug. - title: cachedVersion ? 'Update Error' : 'Update Check Failed', - summary: cachedVersion - ? 'Could not complete the update.' - : 'Could not check for updates.', - detail: status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), - // Why: check-time failures are often transient, so offer a Re-check instead of forcing manual download. - primaryAction: cachedVersion - ? { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate - } - : { - label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), - onClick: () => { - void window.api.updater.check({ includePrerelease: false }) + : { + // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug. + title: cachedVersion ? 'Update Error' : 'Update Check Failed', + summary: cachedVersion + ? 'Could not complete the update.' + : 'Could not check for updates.', + detail: status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + // Why: check-time failures are often transient, so offer a Re-check instead of forcing manual download. + primaryAction: cachedVersion + ? { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: handleUpdate } - } - } + : { + label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), + onClick: () => { + void window.api.updater.check({ includePrerelease: false }) + } + } + } : installError ? { title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), @@ -558,6 +584,7 @@ export function UpdateCard() { onMediaError={() => setMediaFailed(true)} onMediaLoad={() => setMediaLoaded(true)} onCollapse={handleCollapseWithAnimation} + showReleaseNotes={!isLocalBuild} /> ) } @@ -568,9 +595,10 @@ export function UpdateCard() { return null } - const releaseUrl = - ('releaseUrl' in status ? status.releaseUrl : undefined) ?? - releaseUrlForVersion(status.version) + const releaseUrl = isLocalBuild + ? undefined + : (('releaseUrl' in status ? status.releaseUrl : undefined) ?? + releaseUrlForVersion(status.version)) if (isRichMode && changelog) { return ( @@ -750,7 +778,7 @@ function SimpleCardContent({ onClose }: { version: string - releaseUrl: string + releaseUrl?: string onUpdate: () => void onClose: () => void }) { @@ -781,12 +809,14 @@ function SimpleCardContent({ {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}

- + {releaseUrl && ( + + )} + {showReleaseNotes && ( + + )}
@@ -918,7 +952,7 @@ function ErrorCardContent({ summary: string explainer?: string detail?: string - releaseUrl: string + releaseUrl?: string manualLabel?: string primaryAction?: { label: string @@ -1018,14 +1052,16 @@ function ErrorCardContent({ : primaryAction.label} )} - + {releaseUrl && ( + + )}
) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 0e4b1675223..b9e6635f36e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -168,20 +168,22 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { 'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba', 'is available. Click "Install Update" to download and install it.' )}{' '} - - {translate( - 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', - 'Release notes' - )} - + {updateStatus.source !== 'local' && ( + + {translate( + 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', + 'Release notes' + )} + + )} )} {updateStatus.state === 'not-available' && @@ -206,20 +208,22 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { 'auto.components.settings.GeneralUpdateSettingsSection.d89806cc89', 'is ready to install.' )}{' '} - - {translate( - 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', - 'Release notes' - )} - + {updateStatus.source !== 'local' && ( + + {translate( + 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', + 'Release notes' + )} + + )} )} {updateStatus.state === 'error' && diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index 1c6e282be74..9fee62dde2a 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -40,7 +40,12 @@ const CHANGELOG_URL = 'https://onorca.dev/changelog' const GITHUB_URL = 'https://github.com/stablyai/orca' const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' const X_URL = 'https://x.com/orca_build' -const NO_UPDATE_CHECK_MODIFIERS = { ctrlKey: false, metaKey: false, shiftKey: false } +const NO_UPDATE_CHECK_MODIFIERS = { + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false +} function openExternalUrl(url: string): void { void window.api.shell.openUrl(url) @@ -154,6 +159,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => { updateCheckModifiersRef.current = { + altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f5cf363fb92..44b4124a0b0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1900,7 +1900,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "Move", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e8158d2662c..8e6783cf331 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1877,7 +1877,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "Mover", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 26922f91d61..1448421ca9a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1877,7 +1877,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "移動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e80c196abd8..22a54ebb542 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1877,7 +1877,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "이동", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b88d6bf9952..390037d4296 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1877,7 +1877,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "移动", diff --git a/src/renderer/src/lib/update-check-click-options.test.ts b/src/renderer/src/lib/update-check-click-options.test.ts index 02f66401301..3bb19ea150e 100644 --- a/src/renderer/src/lib/update-check-click-options.test.ts +++ b/src/renderer/src/lib/update-check-click-options.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest' import { getUpdateCheckClickOptions, getUpdateCheckHint } from './update-check-click-options' -function clickEvent(overrides: Partial>) { +function clickEvent( + overrides: Partial> +) { return { + altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...overrides - } as Pick + } as Pick } describe('getUpdateCheckClickOptions', () => { @@ -42,9 +45,19 @@ describe('getUpdateCheckClickOptions', () => { }) }) + it('gives local build selection precedence over release channel modifiers', () => { + expect( + getUpdateCheckClickOptions(clickEvent({ altKey: true, shiftKey: true, metaKey: true }), true) + ).toEqual({ localBuild: true }) + expect(getUpdateCheckClickOptions(clickEvent({ altKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + it('formats the tooltip hint by platform', () => { expect(getUpdateCheckHint(true)).toBe( - '⇧+click checks the latest RC; ⌘+click checks the latest perf build.' + '⇧+click checks the latest RC; ⌘+click checks the latest perf build. ⌥+click chooses a local macOS build.' ) expect(getUpdateCheckHint(false)).toBe( 'Shift+click checks the latest RC; Ctrl+click checks the latest perf build.' diff --git a/src/renderer/src/lib/update-check-click-options.ts b/src/renderer/src/lib/update-check-click-options.ts index 2ee4191c34b..1734c4f1968 100644 --- a/src/renderer/src/lib/update-check-click-options.ts +++ b/src/renderer/src/lib/update-check-click-options.ts @@ -1,7 +1,7 @@ import type { UpdateCheckOptions } from '../../../shared/types' import { getShortcutPlatform } from './shortcut-platform' -type UpdateCheckClickEvent = Pick +type UpdateCheckClickEvent = Pick function isMacShortcutPlatform(): boolean { return getShortcutPlatform() === 'darwin' @@ -10,13 +10,17 @@ function isMacShortcutPlatform(): boolean { export function getUpdateCheckHint(isMac = isMacShortcutPlatform()): string { const rcClickLabel = isMac ? '⇧+click' : 'Shift+click' const perfClickLabel = isMac ? '⌘+click' : 'Ctrl+click' - return `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.` + const releaseHints = `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.` + return isMac ? `${releaseHints} ⌥+click chooses a local macOS build.` : releaseHints } export function getUpdateCheckClickOptions( event: UpdateCheckClickEvent, isMac = isMacShortcutPlatform() ): UpdateCheckOptions { + if (isMac && event.altKey) { + return { localBuild: true } + } return { includePrerelease: event.shiftKey, includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json new file mode 100644 index 00000000000..8c2bb743b44 --- /dev/null +++ b/src/shared/local-build-compatibility-contract.json @@ -0,0 +1,11 @@ +{ + "formatVersion": 1, + "appId": "com.stablyai.orca", + "stateSchemaVersion": 1, + "readableStateSchemaVersions": [1], + "daemonProtocolVersion": 28, + "attachableDaemonProtocolVersions": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28 + ] +} diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts new file mode 100644 index 00000000000..f05be487dd2 --- /dev/null +++ b/src/shared/local-build-compatibility-contract.ts @@ -0,0 +1,11 @@ +export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { + formatVersion: 1, + appId: 'com.stablyai.orca', + stateSchemaVersion: 1, + readableStateSchemaVersions: [1], + daemonProtocolVersion: 28, + attachableDaemonProtocolVersions: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28 + ] +} as const diff --git a/src/shared/local-build-compatibility.test.ts b/src/shared/local-build-compatibility.test.ts new file mode 100644 index 00000000000..45f9a21ae33 --- /dev/null +++ b/src/shared/local-build-compatibility.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + getLocalBuildCompatibilityError, + parseLocalBuildCompatibility, + type LocalBuildCompatibility +} from './local-build-compatibility' + +function target(overrides: Partial = {}): LocalBuildCompatibility { + return { + formatVersion: 1, + appId: 'com.stablyai.orca', + buildId: '1.2.3-abc-arm64', + version: '1.2.3-local.1.abc', + commit: 'abc', + stateSchemaVersion: 1, + readableStateSchemaVersions: [1], + daemonProtocolVersion: 28, + attachableDaemonProtocolVersions: [27, 28], + platform: 'darwin', + architecture: 'arm64', + ...overrides + } +} + +describe('local build compatibility', () => { + it('rejects state and live-terminal protocol incompatibilities', () => { + expect( + getLocalBuildCompatibilityError(target({ readableStateSchemaVersions: [2] }), 1, []) + ).toContain('cannot read Orca workspace state schema') + expect(getLocalBuildCompatibilityError(target(), 1, [26])).toContain( + 'cannot reconnect terminal daemon protocol 26' + ) + expect(getLocalBuildCompatibilityError(target(), 1, [27, 28])).toBeNull() + }) + + it('parses only bounded Orca compatibility contracts', () => { + expect(parseLocalBuildCompatibility(target())).toEqual(target()) + expect(() => parseLocalBuildCompatibility(target({ appId: 'other.app' }))).toThrow( + 'invalid compatibility metadata' + ) + expect(() => + parseLocalBuildCompatibility(target({ attachableDaemonProtocolVersions: [] })) + ).toThrow('invalid compatibility metadata') + }) +}) diff --git a/src/shared/local-build-compatibility.ts b/src/shared/local-build-compatibility.ts new file mode 100644 index 00000000000..203ffe46476 --- /dev/null +++ b/src/shared/local-build-compatibility.ts @@ -0,0 +1,90 @@ +import { LOCAL_BUILD_COMPATIBILITY_CONTRACT } from './local-build-compatibility-contract' + +export const LOCAL_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json' +export const LOCAL_BUILD_COMPATIBILITY_FORMAT_VERSION = + LOCAL_BUILD_COMPATIBILITY_CONTRACT.formatVersion +export const ORCA_APP_ID = LOCAL_BUILD_COMPATIBILITY_CONTRACT.appId + +export type LocalBuildArchitecture = 'arm64' | 'x64' + +export type LocalBuildCompatibility = { + formatVersion: number + appId: string + buildId: string + version: string + commit: string + stateSchemaVersion: number + readableStateSchemaVersions: number[] + daemonProtocolVersion: number + attachableDaemonProtocolVersions: number[] + platform: 'darwin' + architecture: LocalBuildArchitecture +} + +function isIntegerArray(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 64 && + value.every((entry) => Number.isSafeInteger(entry) && entry > 0) + ) +} + +export function parseLocalBuildCompatibility(value: unknown): LocalBuildCompatibility { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The selected build has invalid compatibility metadata.') + } + const record = value as Record + const architecture = record.architecture + if ( + record.formatVersion !== LOCAL_BUILD_COMPATIBILITY_FORMAT_VERSION || + record.appId !== ORCA_APP_ID || + typeof record.buildId !== 'string' || + record.buildId.length === 0 || + record.buildId.length > 200 || + typeof record.version !== 'string' || + record.version.length === 0 || + record.version.length > 100 || + typeof record.commit !== 'string' || + record.commit.length === 0 || + record.commit.length > 100 || + !Number.isSafeInteger(record.stateSchemaVersion) || + Number(record.stateSchemaVersion) <= 0 || + !isIntegerArray(record.readableStateSchemaVersions) || + !Number.isSafeInteger(record.daemonProtocolVersion) || + Number(record.daemonProtocolVersion) <= 0 || + !isIntegerArray(record.attachableDaemonProtocolVersions) || + record.platform !== 'darwin' || + (architecture !== 'arm64' && architecture !== 'x64') + ) { + throw new Error('The selected build has invalid compatibility metadata.') + } + if ( + !(record.readableStateSchemaVersions as number[]).includes( + record.stateSchemaVersion as number + ) || + !(record.attachableDaemonProtocolVersions as number[]).includes( + record.daemonProtocolVersion as number + ) + ) { + throw new Error('The selected build has inconsistent compatibility metadata.') + } + return record as LocalBuildCompatibility +} + +export function getLocalBuildCompatibilityError( + target: LocalBuildCompatibility, + currentStateSchemaVersion: number, + liveDaemonProtocols: readonly number[] +): string | null { + if (!target.readableStateSchemaVersions.includes(currentStateSchemaVersion)) { + return `This build cannot read Orca workspace state schema ${currentStateSchemaVersion}. Your workspace was not changed.` + } + const unsupportedProtocols = liveDaemonProtocols.filter( + (protocol) => !target.attachableDaemonProtocolVersions.includes(protocol) + ) + if (unsupportedProtocols.length > 0) { + return `This build cannot reconnect terminal daemon protocol ${unsupportedProtocols.join(', ')}. Close those terminals or choose a compatible build.` + } + return null +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e34ed5bc26d..cb63444b468 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2340,9 +2340,12 @@ export type ChangelogData = { export type UpdateCheckOptions = { includePrerelease?: boolean includePerfPrerelease?: boolean + localBuild?: boolean } -export type UpdateStatus = +export type UpdateSource = 'local' + +export type UpdateStatus = ( | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean } | { @@ -2365,6 +2368,7 @@ export type UpdateStatus = | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } | { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string } | { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string } +) & { source?: UpdateSource } // ─── Settings ──────────────────────────────────────────────────────── export type NotificationSettings = { From b31e66ed487384513ec28cc93ed59282ff1517e5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:40:29 -0700 Subject: [PATCH 012/203] fix(browser): survive a transient Windows lock during cookie import (#10697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): retry a transient Windows lock on the Chromium cookie snapshot copy The snapshot attempt loop only reacts to a `false` return, so a throwing copy escaped it entirely. On Windows, AV/EDR briefly opens a file literally named "Cookies" with FILE_SHARE_NONE, which turns an otherwise-fine copyFileSync into `EBUSY errno -4082 syscall=copyfile` and aborts the whole import (#9355). Route the main-database copy through the existing `copyFileWithWindowsRetry`, already used for the same AV window in #1507. It is a no-op off Windows (maxAttempts=1), so POSIX still fails fast and ENOENT is never retried on any platform. * fix(browser): degrade the cookie-import staging DB instead of aborting the import Staging exists only to back the cold-restart replay for cookies the in-memory path rejects, but three points in it were fatal to the whole import (#9355): - the staging copy from the live partition DB — also a file named "Cookies", so the same AV/EDR handle blocks it; - opening/PRAGMA-ing that staged file; - both were unguarded, so a throw escaped to the catch-all and returned `ok: false` even when every cookie could load in memory. Each is now non-fatal and diagnosed. Two invariants keep the degraded path honest: `imported++` moved out of the staging insert so the summary counts importable cookies rather than staged rows, and `setPendingCookieImport` is never called when staging is unavailable — registering a path that was never written would replay a missing or partial DB over the live partition on cold start. * fix(browser): stop a degraded cookie import from replaying a stale staged database Review round 1 found the staging-degradation path could leave an older pendingCookieImports entry registered while the import rewrote the live session, so the next cold start replayed the stale DB over fresh cookies. - add clearPendingCookieImport so a degraded import retires the old entry - degrade staging on BEGIN/insert/COMMIT failure instead of aborting the import - discard the staged cookie copy on every non-registering path - pin the stagingAvailable guard, which previously survived mutation Co-authored-by: Orca * fix(browser): report a degraded cookie import honestly instead of as a clean success Making the staging failure non-fatal introduced a silent-loss path: the import clears the live jar before loading cookies, so when staging was unavailable AND Electron rejected cookies, the user lost their old jar, got none of the new cookies, and still saw "Imported N cookies". Adds an optional `warning` to the import summary, set only on that degraded branch, and routes every cookie-import toast through a shared emitter that raises a warning toast instead of an unqualified success. Also closes three test holes found in review: - clearPendingCookieImport had no direct tests; deleting the wrong partition key survived all 10 registry persistence tests. Now covered, mutation killed. - The staging-insert-failure test was vacuous (memoryFailed === 0 suppressed registration on its own). It now forces a memory failure. - No test pinned the success-path clear; removing it survived. Now covered. * fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361) * fix(github): resolve owner/repo through SSH Host aliases (#10284) Expand OpenSSH Host → HostName via ssh -G before classifying github.com identity so PR merge works when origin is git@alias:owner/repo.git. Transport URLs stay unchanged so IdentityFile selection is preserved. Do not long-negative-cache indeterminate ssh -G failures. * fix(github): harden SSH alias resolution * Update README downloads badge * fix(persistence): fsync state writes so a rename is actually durable (#10631) * fix(persistence): fsync state writes so a rename is actually durable `Store` wrote `orca-data.json` to a temp file and renamed it. rename() is atomic for readers but says nothing about durability: without an fsync the directory entry can reach disk before the data does. After power loss or a hard crash the file can come back holding the previous state or, worse, zero bytes — and `JSON.parse('')` throws, so an empty file takes the full corrupt-file path rather than degrading. This is the same empty-file symptom as #1158 from a different cause. That issue fixed a logic path that persisted empty state and added the .bak ring as a safety net; the ring also catches this, which is why it went unnoticed. Recovery costs up to an hour of tabs/layouts/session state (backups are throttled to >=1h spacing), and a user in their first hour has no backup slot yet, so they land on defaults indistinguishable from a fresh install. Both write paths now fsync the temp file *before* the rename, then fsync the containing directory. Directory fsync is best-effort by design: Windows cannot open a directory for fsync and some filesystems reject it, so it is swallowed. The file fsync is the load-bearing part and works everywhere. Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce. The async path does not block the main thread. The syscall-order test mocks `node:fs` and counts fsync targets at the module boundary, asserting ['file', 'directory'] — proving the ordering rather than inferring it from reading the implementation, since a fsync after the rename would still pass every content assertion. * test(persistence): make the syscall proof platform-aware and actually prove the order Two problems, both found from CodeRabbit's Windows observation. The assertion hardcoded ['file', 'directory']. Directory fsync is deliberately best-effort — Windows cannot open a directory for fsync and some filesystems reject it — so on Windows the helper swallows the failure, only the file fsync is observed, and the test fails. The expectation now probes the real platform instead of assuming, keeping the guarantee tight where directory fsync works rather than dropping it everywhere. Worse, the test did not prove what its name claimed. Moving the fsync to *after* the rename still passes: the file is fsynced either way, and only fsyncs were recorded, so the correct and broken orders produced an identical log. Mutation-testing the "before rename" claim is what surfaced this — the mutation passed. The rename is now recorded in the same sequence, since it is the boundary the ordering is defined against. Re-running the same mutation fails, so the ordering claim is now backed by the test rather than asserted in a comment. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(window): stop burning macOS GPU on an invisible blur effect (#8482) (#10682) Co-authored-by: Orca * feat(sidebar): distinguish and filter CLI-created workspaces (#10712) * perf(relay): stop snapshotting the whole pending-PTY map every drain tick (#10670) * perf(sidebar): share one worktree-keyed agent orchestration index (#10678) Co-authored-by: Orca * fix(mobile): recover unreliable relay connections (#10709) * fix(mobile): recover unreliable relay connections * test(mobile): use valid raster preview fixtures --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * release: v1.4.157-rc.0 * docs(relay): trim the pending-output drain comment (#10714) Co-authored-by: Orca * fix(history): quarantine unreadable recovery generations. (#10713) * fix(history): quarantine unreadable recovery files * fix(history): preserve mixed recovery generations * fix(history): reanchor reconciled live sessions * fix(history): serialize final checkpoint queue * fix(history): drain sleep shutdowns before disconnect * fix(history): restore legacy wide sessions * fix(history): preserve malformed mixed logs * fix(history): preserve malformed log tails * refactor tests to reduce file size * refactor(history-recovery): extract freeze helper and improve test robus - Extract takeRecoveryFreeze to eliminate duplicated freeze-and-clear pattern across five call sites - Skip permission-mode tests on root CI containers (chmod 0o500 doesn't block root writes) - Replace fixed sleep with deterministic wait for queued exclusive checkpoints - Distinguish ENOENT (missing) from corrupt in history metadata reads - Add ceiling-dimension restore test and torn-tail exclusion assertion - Wrap chmod operations in try/finally to prevent leaked permissions from masking test failures - Add .catch() to checkpoint promise to prevent unhandled rejections from finally re-throws * test(history-recovery): consolidate checkpoint assertions Wait for both the checkpoint call and set clear atomically to avoid a timing race where the spy fires before the set is cleared. * fix(persistence): unbreak main by expecting the new 'cli' card property in fresh defaults (#10722) Co-authored-by: Orca * fix(sidebar): stop worktree drag from spazzing when cards resize mid-drag (#10725) * fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693) * fix(native-chat): wrap question text and option descriptions instead of truncating (#10025) * fix(mobile): pop to home when leaving a host so the back chevron animates backward (#9723) * fix(i18n/zh): correct technical literals and clear sense errors (#10048) * feat(speech): add Korean streaming zipformer STT model (#9893) Co-authored-by: Claude Opus 4.8 * feat(source-control): add copy relative path (#9018) * fix(gitlab): stop refresh button overlapping dialog close X (#9445) * fix(gitlab): stop refresh button overlapping dialog close X The GitLab item dialog's SheetContent renders its own close (X) at absolute right-4, but the header refresh button sat at the header's px-5 right padding and overlapped it. Reserve pr-10 on the header so the refresh button clears the close X, and lift it -mt-1.5 so its icon aligns with the close X on the same line. * fix(gitlab): integrate sheet controls into header --------- Co-authored-by: viniciussilva Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(sidebar): raise selected workspace contrast in dark mode (#8321) * feat(editor): toggle Word Wrap from file tab actions and Alt+Z (#10086) * feat(editor): toggle Word Wrap from file tab actions and Alt+Z Long single-line and structured files wrap by default and misalign. Surface Word Wrap on the editor more-actions menu for normal file tabs (diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can unwrap without opening Settings. Closes #9974 * fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving diff panes out of sync with the markdown actions menu. * test(editor): verify word wrap shortcut routing Cover editor/diff setting callbacks and the cross-platform Alt+Z binding. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * fix(repo-icon): detect Tauri and WebP icons (#7942) Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected. * Add bulk tab closing to mobile long-press sheets (Close Others / Left / Right) and complete the desktop tab context menus (#9323) * Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets * Fold the per-sheet Close action into the bulk-close module (session route max-lines) * fix(mobile): preserve pinned tabs during bulk close --------- Co-authored-by: Tom de Bres Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * feat: implement Cmd+Enter as commit shortcut in Source Control (#9773) * feat: implement Cmd+Enter as commit shortcut in Source Control * test: add unit tests for commit shortcut and tooltip formatting * fix: address review feedback on modifier keys and test coverage * test: split mac and windows/linux shortcut and keydown tests --------- Co-authored-by: Andres Van Reepingen Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * Add SenseVoice speech-to-text model (Korean/Japanese support) (#7436) * Add SenseVoice speech-to-text model (Korean/Japanese support) SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with Korean and Japanese support. The existing local models cover only English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is multilingual but trades accuracy for breadth. - Add 'senseVoice' to SpeechModelType - Register the sherpa-onnx SenseVoice archive in the model catalog (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout) - Handle the senseVoice type in the STT worker via createOfflineRecognizer with the senseVoice model config (auto language detection + ITN) - Add model-catalog regression tests for the new entry Co-Authored-By: Claude Opus 4.8 (1M context) * fix(speech): use int8-only SenseVoice archive * fix(speech): refresh SenseVoice catalog metadata --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: LauraGPT Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * fix(settings): show a way back to local accounts when a remote server owns provider-account scope (#8188) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * feat(speech): add Parakeet TDT-CTC 0.6B JA voice model (#8207) * Add SenseVoice speech-to-text model (Korean/Japanese support) SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with Korean and Japanese support. The existing local models cover only English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is multilingual but trades accuracy for breadth. - Add 'senseVoice' to SpeechModelType - Register the sherpa-onnx SenseVoice archive in the model catalog (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout) - Handle the senseVoice type in the STT worker via createOfflineRecognizer with the senseVoice model config (auto language detection + ITN) - Add model-catalog regression tests for the new entry Co-Authored-By: Claude Opus 4.8 (1M context) * feat(speech): add Parakeet TDT-CTC 0.6B JA to the speech model catalog * test(speech): cover stt-worker-model-config file resolution incl. single-file models * feat(speech): decode Parakeet TDT-CTC JA via sherpa-onnx nemoCtc offline recognizer * fix(speech): use int8-only SenseVoice archive * fix(speech): refresh SenseVoice catalog metadata --------- Co-authored-by: xsacdw Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: LauraGPT Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * fix(terminal): stop switch bold flash and Windows lag (#10692) * fix(terminal): stop bold flash on worktree switch Worktree hide disposes WebGL and falls back to xterm's DOM renderer. On reveal, resume ran after paint and flushed backlog against DOM first, so the first frame used heavier CSS-AA glyphs before WebGL settled. Resume in useLayoutEffect and reattach WebGL before backlog flush so the first painted frame stays on the GPU path. No cold-park policy change. Co-authored-by: Orca * fix(terminal): fit WebGL grid before backlog flush on resume Adversarial review: resume-before-flush alone wrote TUI backlog onto the transient DOM↔WebGL one-column-off metrics window. Order is now resume → fitAllRevealedPanes → flush on heavy reveal and window wake. Co-authored-by: Orca * fix(terminal): latch viewport intent before WebGL wake resume Adversarial review: wake path synced intents after resume/fit, which can re-latch a pinned viewport as followOutput. Capture before reattach and drop the post-resume re-sync on heavy reveal (outer path already latched). Co-authored-by: Orca * fix(terminal): complete visibility bookkeeping before PaneManager exists useLayoutEffect runs before the passive lifecycle creates PaneManager, so the mount-visible path never set hasCompletedVisibleResume. The first intra-worktree hide then wrongly suspended WebGL. Bookkeep completion even when managerRef is still null (extracted helper for max-lines). Co-authored-by: Orca * fix(terminal): re-sync pin geometry after resume backlog flush Keep the pre-resume intent latch (reattach must not re-latch pins as followOutput), then re-sync after flush with preservePinnedAtBottom so scrollback trim updates absolute pin lines before enforce. Co-authored-by: Orca * fix(terminal): drop same-tick post-flush intent re-sync flushTerminalOutput only queues terminal.write and returns before parse, so a same-tick re-sync read pre-parse resume/fit geometry and could overwrite pre-resume pins. Keep pre-resume latch + enforce only. Co-authored-by: Orca * fix(test): expect default worktree card properties to include cli #10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES, but the fresh default-profile assertion still omitted it and fails verify. Co-authored-by: Orca * perf(terminal): retain Windows WebGL across worktree hides * perf(terminal): bound retained WebGL contexts * fix(terminal): harden retained WebGL lifecycle * fix(terminal): preserve healthy WebGL on wake * fix(terminal): preserve reveal recovery ordering --------- Co-authored-by: Orca * fix(mobile): clear native-chat composer optimistically at send time (#10226) * fix(mobile): clear native-chat composer optimistically at send time Over relay the send RPC round trip is visible and a lost ack (or a relay/direct cutover) could strand the sent prompt in the composer forever: the unconfirmed-send deadline dropped its tracking entry, so a late transcript echo could never clear the draft. Clear the draft at send time and restore it only on a definite rejection. holdUnconfirmedSend now only manages the delivery-unconfirmed notice; it no longer touches drafts. * fix(mobile): isolate question answers from composer drafts * fix(cli): bound orchestration ask timeouts (#10689) * fix(cli): bound orchestration ask timeouts * fix(cli): harden remote timeout boundaries * fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail (#10729) * fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail Windows renderer OOM F0BKR84AHEH (0xE0000008) arrived with a 30-entry breadcrumb ring in which two `terminal_safe_fit_retry_exhausted` bursts consumed 26-90% of the slots. Every hidden pane is `display:none` -> 0x0 -> unmeasurable, so one post-reload reattach wave exhausts the retry budget once per mounted pane inside ~60ms. The bursts were also uninterpretable: `pane.id` restarts at 1 per PaneManager and there is one manager per tab, so 34 identical `paneId: 1` crumbs cannot distinguish one pane looping from 34 panes firing once. Coalesce the crumb by name and carry the live-pane census on the payload instead, so the count survives without costing 34 ring slots. Same treatment for WebGL diagnostics, which were worse off: context-loss and atlas-reset crumbs only reached a DevTools-only ring (`window.n()`), so a renderer that dies takes them with it. That bundle had three GPU-process deaths in the 65s before the renderer OOM and zero WebGL evidence - absence of instrumentation, not absence of the event. Mirror them into the crash report, coalesced per kind so a routine atlas reset cannot mask a context loss. Evidence-only: no behavior, rendering, or lifecycle path changes. Co-authored-by: Orca * perf(pane-manager): count panes without materializing public views The census runs on the crash path; getPanes() allocates a full ManagedPane projection per pane just to read .length. Co-authored-by: Orca * test(crash-reporting): pin the fit-retry burst against the 30-entry ring Reproduces the F0BKR84AHEH ring loss directly: 10 pre-crash crumbs plus a 34-crumb per-pane burst. Uncoalesced, the burst takes all 30 slots and zero pre-crash crumbs survive; coalesced, it takes one slot, all 10 survive, and the pane count rides on the payload instead of on the crumb multiplicity. Co-authored-by: Orca * fix(crash-reporting): name the WebGL census the same as the fit-retry census The context-loss crumb spread getLivePaneCensus() raw, so one ring described one measurement two ways: managers/panes here, livePanes/livePaneManagers on the fit crumb. Spreading also meant renaming the census return keys would silently reshape the crumb. Name the fields at the call site and pin them. Co-authored-by: Orca * fix(crash-reporting): keep a hot coalesce key from being the first LRU eviction The suppression path returned before the delete-then-set that re-anchors recency, so a key hit continuously never moved from its original insertion slot and became the first eviction candidate — the inverse of the LRU's stated intent. `renderer_error` keys carry message+stack identity, so one noisy render loop mints unbounded distinct keys. Within a single 30s window that churn evicted the `terminal_safe_fit_retry_exhausted` key mid-burst, un-suppressing it and re-arming the exact ring flush the coalescing exists to prevent. Re-anchor position only; `recordedAt` is left alone so the suppression window still expires on schedule rather than renewing on every hit. Found while adversarially probing the LRU claim in #10729's own description, which asserted these keys "cannot evict live keys". * fix(crash-reporting): report the newest census of a coalesced burst The suppression path wrote nothing to the ring, so a coalesced burst froze its FIRST event. Panes mount progressively, so pane 1 exhausting alone legitimately measures livePanes: 1 -- and the 33 later crumbs, each carrying a truer census, were dropped. A 34-pane wave was recorded as `livePanes: 1` with no count: the exact "one pane looping" misread that coalescing by name was introduced to prevent. The existing burst test missed this because it fed a constant census on every crumb, making frozen-first and newest-wins indistinguishable. Stash the newest payload and fold it into the ring entry the key already owns: still one slot, now reading livePanes: 34 + suppressedSinceLast: 33. Resolution is deferred to snapshot time -- sanitizing per suppressed hit of a 1459/min crash loop measured 2194 ns/op vs 185 ns/op deferred. Two follow-on defects fixed alongside: an expiring key dropped its pending payload (it loses its only handle on the ring entry), and resolving the re-emitting key's own old slot double-counted a burst. --------- Co-authored-by: Orca * fix(speech): download verified model artifacts directly (#10735) * perf(renderer): give owner-routed settings a stable identity (#10743) Co-authored-by: Orca * perf(agent-status): validate hook payloads without the JSON round trip (#10752) * fix(gpu-fallback): make the crash window rolling, not launch-anchored (#10707) * fix(gpu-fallback): make the crash window rolling, not launch-anchored Software-rendering fallback only ever considered GPU child crashes in the first 30s after launch: `if (msSinceLaunch > this.windowMs) return`. Session 12e6ee64 crashed the GPU child 4 times (242s / 920s / 926s / 946s since launch). The last three span 26.0s — inside windowMs, exactly threshold — but every one was rejected because the burst began 920s in. The renderer died of process OOM (0xE0000008) 39s later. GPU work is demand-driven, so the first heavy compositing often happens minutes into a session. What distinguishes a broken driver from normal Chromium churn is that the crashes *cluster*, not when the cluster starts. Keep a sorted array of recent crash times pruned to windowMs behind the newest, and engage when the count reaches threshold. Measured against real field telemetry (341 distinct win32 launches with >=1 GPU crash, from process_gone_suppressed breadcrumb trails): the rolling window engages on 2/341 launches (0.59%), one of which is 12e6ee64. Max GPU crashes in any single launch is 4. The closest non-firing sequence ([0, 29531, 55136, 74178] — consecutive gaps that each fit the window but never put 3 inside it) is pinned as a regression test. Also destroy the Windows tray before app.exit(0) on this path, matching the app:relaunch IPC handler — app.exit skips before-quit, and this can now fire deep into a session rather than only in the first 30s. Mutation-tested: 6 mutants (launch-anchored gate, dropped pruning, dropped monotonic clamp, cutoff =/>, dropped engaged latch), all killed by the suite. Co-authored-by: Orca * fix(gpu-fallback): ask before restarting --------- Co-authored-by: Orca * feat(sidebar): add a filter to hide detached-HEAD workspaces (#10786) Adds "Hide detached HEAD" alongside the existing sidebar filters, wired through the same pipeline as Hide CLI-created: sidebar list, Cmd+J empty-query list, workspace board, active-filter badges, Clear/Reset Filters, and persisted UI state. The predicate reuses getWorktreeGitIdentityDisplay so the filter targets exactly what the card renders a Detached HEAD badge for. Requiring a real head (not just an empty branch) keeps folder workspaces and SSH-synthesized rows — which carry both empty — out of the filter. Activating a hidden detached workspace clears the filter, matching the existing reveal escape hatch for automation- and CLI-created workspaces. Splits the filter-state describes out of visible-worktrees.test.ts into sidebar-filter-state.test.ts to stay under the max-lines budget. Co-authored-by: Orca * feat(daemon): add daemon_lifecycle replaced/retired telemetry event (#10058) * feat(daemon): add daemon_lifecycle replaced/retired telemetry event Implements STA-2376. Adds track('daemon_lifecycle', {transition, reason, live_session_count_bucket, version_skew?}) covering 'replaced' (unhealthy_resolver / stale_bundle / different_app_path / failed_health_check at daemon-init launcher sites) and 'retired' (died_respawn at the adapter respawn closures). Enum-only + .strict() + bucketed counts keep paths, versions, and raw counts off the wire; preserve-path transitions emit nothing. Cross-platform and SSH-safe; no-op in non-official builds. Test plan: affected vitest (158) green; typecheck/lint clean except pre-existing unrelated failures. * fix(daemon): prevent false lifecycle telemetry * test(daemon): restore once-ness on respawn reason assertions Keep STA-2376 reason checks without dropping concurrent-respawn coalescing coverage that prevents double died_respawn telemetry. * fix(daemon): emit replaced telemetry on runtime unhealthy_resolver respawn CodeRabbit: adapter-driven macOS resolver replacements forked a new daemon without a lifecycle event. Emit trackDaemonReplaced (not retired) so field diagnosis of #7936 covers the runtime path without mislabeling it as death. * fix(daemon): stop double-counting resolver replaces; drop redundant version_skew Three telemetry-correctness fixes to the STA-2376 daemon_lifecycle event. 1. The runtime macOS resolver respawn double-counted. doRespawn() disconnects but never kills the daemon, so the ensureRunning() that follows re-enters createOutOfProcessLauncher, which re-detects healthy + resolver-unhealthy + 0 sessions and emits the replace itself. The closure emitted a second one. It also emitted before the outcome was known, so a resolver that recovered mid-flight (or a session appearing) left a 'replaced' on the wire for a daemon the launcher went on to preserve. The launcher's emit is gated on a confirmed kill, so it is the correct sole emitter; this reverts the emit added in 1e60ca87a4. The reason plumbing stays -- it is what keeps a resolver respawn from being mislabelled died_respawn. 2. version_skew carried no information and lied to one cohort. It was present iff reason === 'stale_bundle' and always true, so it was a deterministic function of reason. isDaemonStaleForCurrentBundle also returns true when the pid file has appVersion: null -- a replace-once heuristic for pre-marker builds, where no version comparison happened at all -- so the field asserted skew for exactly the upgrade cohort the event exists to illuminate. Dropped from the schema, emitter, and call site, along with the dead branching. 3. track() is now failure-isolated in both emitters. Both call sites sit on the daemon launch/respawn path, where a throw costs the user every terminal. Tests: once-ness (toHaveBeenCalledTimes) on every emit assertion -- the old toHaveBeenCalledWith-only assertions passed under a doubled call; a regression guard that the resolver respawn closure stays silent; and a throwing-client test. Note the unit tests mock DaemonSpawner and never invoke the launcher, so no test could observe the double-emit; the once-ness assertions bound each emitter within its own seam. Known limitation, unchanged: a wedged-but-alive daemon (#8689) can still report died_respawn from the adapter and failed_health_check from the launcher -- the app cannot distinguish wedged from dead at that point. * fix(daemon): attribute the runtime resolver replace so it is not lost Round-2 review found the previous commit over-corrected. Removing the emit from the respawn closures was right about the premature emit but wrong about where the event would come from instead. doRespawn() does not kill the daemon, but it does drop its only authenticated client, and that is enough: the last fully-authenticated disconnect sets retirementRequested, and reevaluateIdleShutdown -> beginIdleShutdown runs with no grace timer, unlinking the token and PID files. So by the time ensureRunning() re-enters the launcher, the daemon is already gone -- killStaleDaemon finds no PID file, confirmedReplacement stays false, and the gate suppresses the emit. Net effect of the previous commit: zero events for a runtime macOS resolver replacement, the common case. The double-emit round 1 found was real but narrow: it needs a daemon holding non-alive sessions, which keeps host.listSessions() non-empty so isIdle() is false and the daemon survives the disconnect to be killed by the launcher. Fix: the adapter attributes the reason rather than emitting it, and the launch it triggers consumes the attribution and reports it. One emit point, exactly one event, correct reason -- whether the daemon self-retired or survived to be killed. The attribution is one-shot so a later unrelated launch cannot inherit it, and it is preferred over the launcher's own inference, which would otherwise mislabel this as failed_health_check. Also: suppress died_respawn while a manual restart is in flight. runRestartDaemon kills the daemon while the outgoing adapter is still live and undisposed, so a pane respawning on its synthetic exit billed a user action to the crash bucket. Tests: a regression test that drives the closure and the launcher across the seam the DaemonSpawner mock normally hides, with killStaleDaemon returning false to model the self-retired daemon, plus the one-shot assertion. Verified load-bearing by mutation (neutering the handoff fails it). Also reset killStaleDaemonMock's implementation in beforeEach -- mockClear left a previous test's mockResolvedValue in place, which silently disarmed the confirmedReplacement gate for every test after it. * fix(daemon): let a proven replacement reason outrank the attributed one Round-3 review. The attribution was preferred unconditionally, so a launch that independently proved a different cause reported the wrong one: resolver unhealthy at the adapter check, daemon survives the client drop (non-alive sessions keep isIdle() false), resolver recovers milliseconds later, and the launcher then proves a stale bundle or a different app path and kills on that basis -- but the event said unhealthy_resolver. A proven kill is grounded in post-kill truth, so it now wins; the attribution covers only what the gate cannot see, a daemon that self-retired leaving nothing to kill and no reason to infer. Every other case is unchanged: self-retired still reports unhealthy_resolver, and the surviving-daemon case reaches the same reason through the launcher's own inference. Also pins the invariant that makes the module-scoped one-shot safe -- the write reaches the launcher with no await in between -- at both the write and the consume, since the DaemonSpawner mock lets tests drive the two halves with an arbitrary gap and would not catch an await being introduced. Corrects the beforeEach comment from the previous commit: there was never a plain mockResolvedValue on killStaleDaemonMock in this file, so it did not silently disarm the gate for later tests. mockReset is still right -- it drops an unconsumed *Once queue, which mockClear does not. Tests: a guard that the launcher's proven reason wins over the attribution, verified load-bearing by mutation (restoring the old ordering fails it). * fix(daemon): don't let the residual health bucket absorb the resolver reason Round-4 review caught a regression from the previous commit. Letting any confirmed kill outrank the attribution was too broad: failed_health_check is not an identification, it is the residual bucket that also absorbs wedges and crashes, so preferring it discards the more specific reason the adapter already established. This is the likely shape of the incident, not a corner case. The dead macOS login session that fails the resolver probe also fails the PTY spawn probe, so checkDaemonHealth returns pty-spawn-unhealthy rather than healthy. The resolver branch is then never evaluated, and with zero live sessions the degraded preserve does not apply either, so the launch falls through to failed_health_check and kills a daemon that survived the adapter's disconnect (non-alive sessions keep it non-idle). Before this commit that reported failed_health_check and dropped unhealthy_resolver -- burying the flagship signal in the catch-all. Rank by how well each reason is evidenced instead: a confirmed kill that positively identified the daemon wins, the attribution beats the residual bucket, and the residual bucket is still reported when there is nothing better. Round 3's motivating case (a proven stale bundle must not be billed to the resolver) is unaffected and still covered. Tests: a guard for the pty-spawn-unhealthy shape, verified load-bearing by mutation (removing the residual-bucket exclusion fails it). * test(daemon): cover the confirmation path production actually uses Round-5 review. No runtime defect, but the three identified reasons were only ever proven through a mechanism the field never takes. confirmedReplacement is `(await killStaleDaemon(...)) || cleanupResult.cleaned`, and the two halves are disjoint in practice. unhealthy_resolver, stale_bundle, and different_app_path all require health === 'healthy', so cleanup reaches the daemon over RPC, shuts it down, and unlinks its pid file -- leaving nothing for killStaleDaemon to find. Production therefore confirms exclusively via .cleaned. Every test confirmed exclusively via killStaleDaemon, because the net.connect stub always errors, so cleanup always returned cleaned:false. The consequence was a silent trapdoor: simplifying the gate to `confirmedReplacement = await killStaleDaemon(...)` stops all three identified reasons from ever emitting in the field, and the whole suite stays green. Confirmed by flipping the killStaleDaemon default to false -- five tests fail, none of which are meant to be about the kill. Adds the missing case: cleanup confirms, the kill finds nothing, and different_app_path is still reported once. Verified load-bearing by mutation -- dropping the .cleaned half now fails this test specifically. Scoped the new test's mocks to *Once so the identity override cannot leak into the packaged-bundle test that follows, and hands probeSocketExists back on the way out since beforeEach only mockClear()s it. * test(daemon): cover the manual-restart retirement guard Round-6 review returned land-ready with one note: removing the !restartInFlight guard from both respawn closures left the whole suite green. That is the same silent-trapdoor shape the previous commit closed for the .cleaned gate, on a guard this PR introduced, so it gets the same treatment. The guard matters because runRestartDaemon tears the daemon down while the outgoing adapter is still live and undisposed; a pane respawning on its synthetic exit reaches the death path for what was a user action, and would bill a manual restart to the crash bucket. Drives the death from inside the restart's ensureRunning so restartInFlight is genuinely set by the code under test, rather than asserting against a flag the test poked itself, and then repeats the respawn after the restart settles to show the suppression is scoped rather than permanent. Verified load-bearing by mutation: removing the guard fails this test and nothing else. * test(daemon): close the two surviving telemetry mutations Round-7 review returned land-ready with two test-only gaps, both found by mutation and both the same trapdoor shape as the last two commits. The manual-restart guard exists in two respawn closures and only the first was covered. That is the wrong half: the restart installs its own adapter, so from the second restart onward the copy in runRestartDaemon is the one that actually runs in the field, and it could be deleted with the suite green. The test now drives a second restart through the adapter the first one installed. The privacy-invariant test only built 'replaced' payloads, so .strict() on the 'retired' member was never exercised -- someone adding a field to trackDaemonRetired after that .strict() was dropped would have reached PostHog with the test still passing. It now runs the leak set over both transitions, plus a sanity assertion that each base payload is itself valid so the rejections prove the leak and not a malformed base. Both verified load-bearing: neutering the second guard copy, and dropping .strict() from the retired member, each now fail exactly one test. * fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373) (#10065) * fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373) DaemonPtyAdapter.write() sends keystrokes via fire-and-forget client.notify(). When the daemon dies (retirement, crash, kill), the socket disconnects and the notify is silently dropped — no rejection reaches withDaemonRetry, so the dead-endpoint respawn never fires and the attached pane freezes. Only a request/reply RPC (e.g. createOrAttach from opening a new terminal) detected the death and forked a replacement. DaemonClient.notify() now reports delivery; a dropped write to a still-active session drives the shared respawn coalescer directly (reconnecting the permanent client before releasing the temporary adoption lease, mirroring withDaemonRetry's ordering), so the pane self-heals like the createOrAttach path. Cross-platform + SSH-safe: no platform assumptions, pure adapter logic. Complements (does not duplicate) #8426, which fixes the adjacent in-daemon bug where a thrown node-pty write no longer marks the handle dead. That is daemon-side; this is the app-side dropped-notify that never triggered respawn. * fix(daemon): restore adapter state after dropped-write respawn * fix(daemon): recover writes after endpoint respawn * fix(terminal): remount panes after daemon death * fix(daemon): recover sibling panes after daemon death, not just the written one When a daemon dies, its dropped-write respawn only remounted the pane whose write detected the dead endpoint. Sibling panes (alive at death but not typed into) were left frozen: stale prompt pixels, silently-dropped input, no live child, and no recovery even on later keystrokes — the exact STA-2373 frozen-typing symptom on non-triggering panes. DaemonPtyAdapter now fans a write-unavailable signal out to every active session when it recovers from a dead endpoint, emitted while the sessions are still in activeSessionIds so the renderer's liveness gate still reads them live. pty.ts forwards each to the existing pty:writeUnavailable channel, so all panes remount + re-attach through the same path the written pane already used. Adds a revert-sensitive regression test: with two sessions and only one written after the daemon dies, the sibling must also be signaled to recover. * revert(format): drop repo-wide oxfmt churn unrelated to STA-2373 A review pass ran `oxfmt --write .` across the tree, pulling seven files with no bearing on the dead-daemon respawn fix into the PR diff. Restored to origin/main byte-for-byte so the diff carries only the respawn change. * fix(daemon): snapshot active sessions before the write-unavailable fan-out A listener that kills a pane mutates activeSessionIds mid-iteration, which can skip the very sibling the fan-out exists to reach. Matches the snapshot fanoutSyntheticExits already takes. * fix(daemon): re-arm dead-endpoint recovery on every daemon death The respawn-storm latch was only released once every awaiting session rebound. Background sessions have no mounted pane, so nothing ever calls createOrAttach for them and they hold the awaiting set non-empty forever — latching the fan-out off after the first death and silently making the whole fix one-shot. Re-arm on the disconnect event instead, which fires once per established connection, so the storm guard still holds within a single incident. * fix(daemon): route the write-unavailable fan-out through the pty router Main subscribes on the routed provider, and DaemonPtyRouter is the live localProvider whenever a legacy daemon socket exists — the common case when an in-place update bumps PROTOCOL_VERSION with terminals running. It forwarded write but not onWriteUnavailable, so the fan-out reached no listener and only the written pane recovered: STA-2373 unfixed, silently. Also stop rejecting writes on adapters that cannot respawn. Legacy adapters have no respawn, so the remount reattaches to nothing and rebuilds the pane empty, losing scrollback the user could still read — worse than the pre-existing silent drop. And guard the renderer's write-unavailable handler on ptyId like its sibling data/replay handlers, so a transport that rebinds without detaching cannot remount a healthy pane. * fix(daemon): route the write-unavailable fan-out through the degraded provider DegradedDaemonPtyProvider is the live localProvider in degraded launch mode and main subscribes on it, but it forwarded onData/onExit/onReplay/ onBackgroundStreamEvent and not onWriteUnavailable — so the fan-out reached no listener and siblings stayed frozen. Same defect as the router, one provider over. The file sat at its max-lines ceiling, so make room by reusing one combineUnsubscribes helper across the three places that already repeated that loop rather than bumping the limit. Forward to the daemon adapters only: the local fallback has no dead-socket problem. * refactor(daemon): share the listener-fanout unsubscribe combination Adding onWriteUnavailable to both provider wrappers left each file at exactly 300/300 lines, so the next line anyone added would have broken max-lines with no sanctioned escape hatch. Both already repeated the same combine-unsubscribes loop, so lift it into one module: duplication drops and each file gets its headroom back. * fix(test): stop the fake emitter colliding with the private adapter emitter DaemonPtyAdapter.emitWriteUnavailable is private, so declaring a public member of the same name on a mock intersected with DaemonPtyAdapter collapsed the whole type to never — one collision produced 54 typecheck errors, taking out pre-existing assertions in both files too. Rename the fake to triggerWriteUnavailable and declare onWriteUnavailable on ProviderMock, which IPtyProvider does not carry on this branch. vitest does not typecheck, which is why a red build sat behind a green suite. * fix(quick-commands): show pointer cursor on hover (#10223) * fix(terminal): limit pre-paint WebGL resume to macOS (#10794) Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling. * fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776) * fix(ssh): connect to Linux hosts that cannot compile node-pty node-pty ships no Linux prebuilt at any architecture, so it is compiled on the remote. On a host without a C/C++ toolchain that build fails, and because both native deps install in one npm command it also took down @parcel/watcher — which does have a working Linux prebuilt — and failed the whole connection. Every Linux image without build tools was unusable. node-pty only backs remote terminals; files, git, and the editor do not need it, and a missing native dep is already non-fatal further down the deploy. So when the existing toolchain probe confirms the compiler is missing, reinstall without node-pty instead of aborting. The manifest has to drop it too — npm reconciles every dependency in package.json, not just the ones named on the command line, so naming only @parcel/watcher still rebuilds node-pty. If that reinstall also fails the actionable build-tools error is rethrown, so a host broken for some other reason still reports the toolchain gap. The relay's PTY error now names the fix rather than saying only that node-pty is unavailable. Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git, nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and spawning a terminal reports the install hint. * fix(ssh): keep the node-pty skip path honest about platform and watcher The PTY unavailable message named build tools unconditionally, but only Linux compiles node-pty — the deploy-side skip is gated on linux and the toolchain probe returns null on Windows. A Windows or macOS remote, where node-pty ships prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's own platform. The skip path returned before the install probe, so a @parcel/watcher that installs but cannot require() (glibc below the floor) connected with dead file watching and nothing logged. Probe before returning and warn; no rebuild, since node-pty provably cannot compile on that host, and never fatal. Also log the pty-less reinstall's own failure and attach it as cause — the rethrown toolchain message is built from the original npm error, so an unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now keeps the caller's resetDeps as well, so a repair reconnect still clears every dep the probe found broken. Tests: the skip-success fixture queued a chmod/probe/rebuild sequence production never runs, and the surplus slots were absorbed by launchRelay's readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12 execs production performs, and pins that no rebuild is issued. Adds the missing negative case: a gyp-shaped failure on a host whose probe reports a complete toolchain must still hard-fail rather than silently degrade. * fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path * perf(terminal): bound the PR-link carry scan to the trailing window (#10741) * fix(codex): treat non-Codex transcript as stale resume metadata (#10538) When a pane is mislabeled agent:codex but still holds a Claude transcriptPath, the Codex resume guard threw and blocked relaunch. Only hard-fail when the path claims Codex's dated rollout layout (sessions/YYYY/MM/DD/rollout-*.jsonl), under any home and without requiring the file to exist. Paths that never claimed Codex provenance return null so the pane can relaunch. Keying on rollout shape rather than trusted-home membership matters: returning null only declines to override CODEX_HOME, and the renderer has already baked 'codex resume ' into the command. A real rollout under an untrusted home would otherwise resume under whichever account is selected — and once the session bridge hardlinks rollouts across homes, codex would find that id and resume silently under the wrong account. Trust is decided upstream by findTrustedCodexSessionResume. Co-authored-by: Wooseong Kim Closes #10517 * fix(codex): cache weekly-only accounts when switching Codex accounts (#10467) * fix(codex): cache weekly-only accounts when switching Codex accounts refreshForCodexAccountChange snapshotted the outgoing account only when this.state.codex.session was populated. Weekly-only plans report no session window, so their snapshot was dropped and the account switcher's inline bars rendered empty for exactly those accounts. Accept a populated weekly window as well. #10136 made this reachable: before duration-based classification, a weekly-only quota landed in the session slot, so the gate happened to pass. Claude is intentionally untouched; it has no weekly-only plan shape. * test(rate-limits): pin that a windowless outgoing Codex account is not cached The widened weekly-only gate had no test for its lower bound: replacing it with a bare truthy check on state.codex passed all 70 tests, which would cache an empty fetching placeholder and render a blank inline bar row in the switcher. * release: v1.4.159-rc.0 * fix(terminal): kill floating, setup, and folder-workspace PTYs on tab close (#10810) * perf(terminal): coalesce per-keystroke input stamps + add typing-latency self-diagnostic (#10784) * perf(terminal): coalesce per-keystroke input stamps + add typing-latency diagnostic A user reports keystroke-echo lag on v1.4.156 that vanishes in Ghostty on the same machine, at a scale we cannot reproduce locally (nested worktrees, ~20 agents each). Static analysis across the 155..156 renderer diff found no perceptible regression, so this adds the instrument to measure it where it actually happens. Diagnostic (`window.__orcaTypingDiagnostic`, dev-console only, no shipped UI): reports keydown->paint percentiles from real typing plus a scale census — agent rows store-total vs mounted-DOM, store listener count, worktree nesting depth, the settings gating suspect paths, and the focused pane's agent and buffer mode. Nothing attaches to the keystroke path until start(), so it does not perturb the latency it measures. Coalescing: recordTerminalInput wrote the whole lastTerminalInputAtByPaneKey map on every keystroke, waking every zustand subscriber. Hibernation is a >=60s idle timeout, so the leading edge of a burst writes immediately and the rest collapse into one trailing flush. Imperative readers merge the pending stamp, and a late flush never revives a pane key teardown deleted. This write path is byte-identical in v1.4.155 and v1.4.156, so the coalescing is a general perf win, not a fix for the reported regression — the measured saving (~0.02ms/keystroke) is well below perception. * fix(diagnostic): count React store subscriptions in the listener census The census wrapped `subscribe` on the bound hook after `create()` had already run. zustand's `useStore()` reads the INNER `api.subscribe`, and `create()` copies subscribe onto the hook as a separate property slot — so patching the hook's copy counted only the 16 imperative `useAppStore.subscribe()` call sites and missed all ~2.2k React hook subscriptions, i.e. exactly the ones that scale with agent rows. The metric would have read a near-constant ~16 regardless of scale, which would have made "latency tracks listener count" read as false no matter the truth. The inner api is only reachable as the state creator's third argument, so the counter now installs there and lives in the store rather than the probe. Still per-subscribe (component mount), never per-setState: zustand notifies by iterating its listener Set directly, so this never touches the keystroke path. Tests pin both subscribe paths; reverting to the old wiring fails 3 of the 5. * fix(ssh): stop remote terminals fail-opening to local PTY (#10778) * fix(ssh): stop remote terminals fail-opening to local PTY Docker SSH watcher isolation failed because an unhydrated remote worktree spawned through the local daemon with a container-only cwd. Fail closed while the SSH owner is still loading, ignore non-PTY mux notifications before mapping params.id, and harden the Docker SSH e2e connect helper so the repo connectionId is present before terminal activation. Co-authored-by: Orca * fix(terminal): keep host-agnostic terminals off the hydration guard The unresolved-owner guard also caught floating and inline setup terminals, which have no repo row by design, regressing #10151. Scope it to repo-backed worktrees and cover the SSH hydration window with a regression test. Co-authored-by: Orca * fix(terminal): treat a local-stamped worktree as a resolved host The hydration guard keyed off "no repo row", which also withheld spawn for a local worktree whose own hostId already proves its host. Key it off "nothing names the host" instead, and cover the local-stamped case with a test. Co-authored-by: Orca * fix(terminal): recover parked panes when their host hydrates Withholding the spawn stopped the wrong-host PTY but left the pane inert: nothing bumped its generation once the repo row merged, so a remote terminal still never opened. Remount PTY-less tabs when repos:changed resolves their owner, and drop the e2e helper workaround that was masking this. Co-authored-by: Orca * fix(terminal): scope hydration remounts to panes that actually parked Keying recovery off "tab has no PTY" also matched tabs whose shell merely exited, remounting them on every repos:changed. Track the panes that withheld their spawn and consume each entry once, so recovery cannot churn a live terminal or spin on repeated refreshes. Co-authored-by: Orca * test(terminal): cover the repos:changed parked-pane remount wiring The recovery predicate was unit-tested, but nothing proved useIpcEvents actually calls it — the existing suite stubs repos.onChanged as a no-op. Drive the real listener and assert a parked pane is remounted only after its host resolves. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(memory): clarify Resource Manager accounting (#10821) * perf(agent-status): scan Command Code transcripts backward from EOF (#10742) Co-authored-by: Orca * fix(terminal): quarantine input after a replaced PTY endpoint (STA-2373) (#10811) Follow-up to #10065, which merged with this failure mode known and deferred. Recovery from a dead daemon socket reattaches to a fresh shell. Keystrokes in flight during the ~1.1s window are dropped, but everything typed after reattach lands on the new shell, so the surviving tail of a half-sent line is submitted by the user's own Enter: `echo hi; rm -rf x` arrives as `cho hi; rm -rf x` — zsh fails `cho` and still runs `rm -rf x`. Before #10065 the whole line was lost, so the executing tail is new. Quarantine the remainder of the interrupted line instead. Keyed by tab, not pane: recovery destroys the xterm being typed into and the successor pane receives the tail. Armed only from the onWriteUnavailable path; a stalled-pipeline remount keeps the same live shell, where quarantining would eat a real command. Disarms on the line terminator (CR/LF/Ctrl-C, dropped too since that is the byte that would submit the mangled line), a 700ms idle gap, or a 5s cap. The cap must not be shortened: the tail itself takes ~2.5s to type, so a shorter cap fires mid-tail and delivers the dangerous remainder to the fresh shell. The onData check sits after the query-reply branch so CPR/DSR replies still reach the shell. Live A/B QA on macOS: the bug reproduces verbatim without the fix (marker file created by the surviving tail) and is suppressed with it, with the next command still working — proving suppression rather than a dead pane. * perf(agent-status): keep the shared transcript reader's carry linear (#10777) readLastTextFromTranscriptOnce re-joined its carry buffer on every block that held no newline, so a transcript whose tail is one oversized line copied O(line^2). It backs three readers — the Claude/Codex user prompt, the Command Code assistant message, and the shared assistant-text reader — so every agent that resolves turn text from a transcript paid it. Same chunk-list carry the Command Code prompt reader already uses. Measured on a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the gap widens with the line, which is the quadratic signature. * perf(ai-vault): keep the JSONL line carry linear (#10783) consumeCompleteJsonlLines re-joined its held-over partial line with every stream chunk, so one oversized record — a large tool result — cost O(record^2). It backs the incremental parse for every resumable agent transcript, so the whole AI Vault corpus paid it. Hold the pieces in a list and join once, when a newline finally arrives. Measured on a transcript with a single oversized record: 2.13 ms -> 1.17 ms at 1 MB and 68.22 ms -> 4.35 ms at 8 MB, with byte-identical output. A transcript of ordinary records never reaches the branch. * perf(git): read both diff blobs concurrently (#10781) * perf(git): read both diff blobs concurrently The diff loaders awaited their two sides in series, so the second `git show` could not start until the first had returned. The reads are independent, so that was pure added latency on every diff the review panel opens: ~47 ms sequential vs ~24 ms concurrent, a saving of ~23 ms per diff. Covers the merge-base, commit, and staged loaders, plus the unstaged path where the working-tree read is independent of the index->HEAD chain. The unstaged left chain itself stays sequential because its second step depends on the first. The staged coalescing test asserted the sequential shape (one spawn, then the next); it now pins the contract that actually matters — eight identical reads still collapse to two spawns, one per side. * test(perf): interleave the diff-blob benchmark arms Running one strategy's whole batch before the other's lets cache warming, CPU frequency drift, and background load correlate with the strategy being measured. Alternate the arms per iteration, alternate which goes first, and report medians so that drift stays common to both. Also reject malformed env settings rather than truncating them — Number.parseInt accepts "10foo" and 3.5. Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff. * perf(mobile-sync): memoize the agent-status projection per entry (#10787) * perf(mobile-sync): memoize the agent-status projection per entry buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every status ping. setAgentStatus replaces one entry and re-spreads the map, which defeats the reference-equality skip gate, so each ping paid for every other agent's prompt, 20-entry history, and 8 KB assistant message to discover they had not changed. Memoize each row's JSON by entry identity, the cachedTabsProjection pattern already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents, 0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized rows matches whole-array stringify, which the new test pins against a verbatim copy of the old implementation. * test(perf): stop inflating the projection benchmark baseline The pre-fix arm stringified each row and parsed it back before stringifying the array, a per-row roundtrip the original never paid. That made the baseline artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x. Share one row builder between both arms, and check equivalence after a ping as well as on the cold call — a stale-row bug can only surface once the cache is actually exercised, which the cold-path check could never catch. * perf(startup): stop parsing qrcode and @linear/sdk at launch (#10788) Both are reachable only from features most users never touch, but both were in the main bundle's eager top-level require block. @linear/sdk is the sharper case: linear-sdk.ts exists solely to load that ~2.6 MB CJS bundle lazily, and a single value import in issue-relation-write.ts defeated it for everyone. That file now imports the type and goes through the loader. qrcode is only reachable from mobile pairing, and both call sites were already async, so they take a dynamic import. * fix(codex): rank the Codex resume rescan by selected account, not settings order (#10801) The legacy Codex session-id rescan (used when a persisted record has no transcriptPath) returned the FIRST trusted home holding a rollout with that id. That home becomes the pane's CODEX_HOME — i.e. it picks the ACCOUNT — and the list ended in per-account homes ordered by settings INSERTION order, so the account was decided by whichever one the user happened to add first. Ranks instead: selected account -> real system home -> shared runtime mirror -> everything else by normalized path. Both ranking inputs are required (an optional one would silently degrade to pure path order), and the selection arrives as a thunk so the common provenance-present resume never stats the ownership marker for a ranking it never runs. The mirror needs its own tier: prepareLegacySharedCodexSessionResume's guard only fires when the resolved home IS the mirror, and that guard is what migrates the rollout into ~/.codex. Without it a system-default selection silently resumed under an arbitrary account and stayed pinned there permanently once the hook stamped a transcript path. Reviewed over two independent rounds; every tier individually mutation-proved. Live-validated in a real Orca dev build by reading the spawned PTY's actual CODEX_HOME across three builds (head, base, and head-minus-the-mirror-tier). Note: fixes none of #10757's user-visible symptoms on its own — it is a correctness precondition. Verified on macOS only; Windows coverage is fixture-only. * fix(sidebar): make worktree drag reorder follow the card, not the pointer (#10845) * fix(mobile): harden native chat send budgets, streams, and stop (#10814) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * perf(source-control): share one path collator across the projection (#10850) * lint: guard the two perf bug shapes we fixed repeatedly (#10851) * fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841) * fix(terminal): avoid flash while restoring parked terminals (#10871) * Fix diff notes overlapping following lines (#7803) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * fix(terminal): clear search highlights when the find bar closes (#10872) Closing Ctrl+F left one match highlighted until the window was minimized and restored. xterm's DecorationService keys its SortedList on `decoration.marker.line`, but `SortedList.delete()` only records an index and defers compaction, while `Marker.dispose()` sets `line = -1` — mutating that same sort key. After the first disposal the array is no longer sorted, so the binary search inside `delete()` can miss a decoration that is present. It returns false, `onDecorationRemoved` never fires, and the decoration stays live and keeps painting. Repaints don't help; they faithfully re-paint a live decoration, which is why only a window cycle appeared to fix it. `clearDecorations()` disposes the active match before the match highlights, which is exactly the order that trips this. Patch `delete()` to retry once after compacting pending deletions, on the miss path only, so the common bulk delete keeps its O(log n) search and deferred batching. A 3000-trial randomized differential against upstream semantics shows no behavior change for well-ordered lists. * perf(cli): load only the handler group a command dispatches into (#10883) Co-authored-by: Orca * perf(ssh): normalize watch event paths once per fs.changed batch (#10881) Co-authored-by: Orca * feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549) * feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) Adds Orca's experimental plugin system behind a settings flag: a supervised kernel, declarative content packs (VM recipes, commands and keybindings, language packs), sandboxed iframe panels, forked worker hosts, and a Git-backed marketplace v0 with consent, provenance and kill-list enforcement. Theme, icon-theme and terminal-theme contributions are deferred to a follow-up pass. * fix(plugins): make unsupported marketplace listings unreachable by key findPlugin() backs preview/install/previewInstalledUpdate via requireListing(), so filtering only listPlugins() hid the catalog card while leaving the dead install path reachable one click later. * fix(plugins): fan Pi session-only status out to plugin subscribers The providerSessionOnly early-return in applyNormalizedStatus emitted to onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so plugins subscribed to agent.status.changed silently missed every Pi session_start event. Route both emit sites through one helper so a future early return cannot drop the plugin tap again. Co-authored-by: Orca * plugins: drop dead code and hoist duplicated trust-boundary patterns Cleanup pass over the P1 diff, no behavior change: - Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus the now-vestigial `directories`/`signal` plumbing in `collectFiles`. - Delete `resolveContainedPluginDirectory` (no callers). - Delete `plugin-content-load-pool.ts`; it reimplemented the existing `mapWithConcurrency`, whose index arg also removes the pairing wrapper in `buildPluginList`. - Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into the install-lockfile module; 11 sites hand-rolled these identically. - Point the new reliability gate at the PR instead of gitignored docs paths, matching every other gate's link form. * fix(plugins): retry plugin state renames on Windows AV/EPERM locks Six plugin write paths (lockfile, provenance, current pointer, kill list, marketplace cache, staged install dir) did a plain rename, so an antivirus or indexer holding the target open surfaced as a failed install. The repo already retries this hazard for issue #1507, but only through a sync helper; these paths are all async. Adds one bounded async retry + atomic write used by all six, and trims a consent-provenance header that restated its own JSX. * test(plugins): cover the Windows rename retry path The retry loop shipped untested: both existing cases hit the non-retry path, and the temp-cleanup test passed identically with the `finally` removed. Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke. Co-authored-by: Orca * fix(plugins): pin bundled plugin resources to LF Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived as CRLF and verify-packaged-plugin-resources rejected it — the packaged build could never pass on Windows. Reproduced locally: CRLF yields the exact CI error, LF verifies clean. Files are already LF, so nothing renormalizes. Co-authored-by: Orca * test: guard the bundled-plugin LF pin against a CRLF checkout The byte-hash mismatch only surfaced in Windows packaging CI. Assert the .gitattributes pin and that a CRLF tree is rejected, so a regression fails on any platform instead of waiting for a packaged Windows build. Co-authored-by: Orca * ci: trigger packaged-build check on bundled plugin resource changes The launch tree is byte-hashed during packaging, but no trigger path covered it — so the CRLF fix for that check would not have re-run the check. Add the resources, verifier and .gitattributes paths that can break packaging. Co-authored-by: Orca * perf(plugins): rebuild the panel frame only when its baked theme values change The revision keys the panel iframe, so every bump destroys the sandboxed frame and its in-panel state. It counted root attribute mutations, but --workspace-sidebar-live-width is written every rAF of a sidebar drag, so dragging with a panel open blanked it ~60x/sec. Compare the two values the shell actually bakes in instead. Co-authored-by: Orca * test: stop pinning a plugin name in the CRLF guard The CRLF case rewrites every launch file, so the reported mismatch is whichever plugin sorts first. P2 adds theme plugins that sort ahead of orca-navigation-shortcuts, which broke the assertion there. Co-authored-by: Orca * style: drop stray blank lines left by the rebase resolutions Both sides of the agent-hooks and orca-runtime conflicts contributed a trailing blank, which oxfmt rejects. Whitespace only. Co-authored-by: Orca * test(plugins): stop the startup budget failing on machine load P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite parallelism, so the gate flaked. Widen it to catch an order-of-magnitude regression instead; the no-worker/no-plugin-code assertions are the real guarantee. Verified a 400ms regression still fails. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882) * fix(remote): accelerate terminal recovery on resume/online (#8255) * fix(remote): accelerate shared-control and pane recovery on resume/online Narrow #8255 onto current main after #9774: fire pending shared-control reconnect timers and pane recovery backoffs on system resume and browser online, without replacing the per-pane recovery state machine or reconnect banner UX. * test(remote): cover online and occluded-resume recovery triggers * fix(remote): centralize recovery acceleration --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): unblock iOS releases and prepare 0.0.36 (#10888) * fix(mobile): block iOS uploads below the last shipped App Store version The closed-train guard looked up each candidate version's own App Store record, but a version only gets one once it is submitted for review. 0.0.34 reached TestFlight and was never submitted, so it had no record, nothing looked closed, and the patch-bump walk stopped there — while 0.0.35 had already shipped. Apple rejected the upload after a 24-minute build (90186 closed train, 90062 needs a higher CFBundleShortVersionString). Fetch the highest closed version once and treat everything at or below it as closed, comparing semver numerically so 0.0.10 outranks 0.0.9. Also read appVersionState alongside appStoreState: the latter is deprecated in App Store Connect API 3.3 and renames the shipped state to READY_FOR_DISTRIBUTION, so reading only the old field would silently find zero closed versions once Apple stops populating it. * chore(mobile): prepare 0.0.36 app.json sat at 0.0.32 while 0.0.35 shipped on the App Store, because release versions are resolved on the runner and never committed back. Close the four-version drift so the checked-in version matches reality and the iOS release no longer depends on the closed-train walk to find an open version. Bump Android versionCode 8 -> 9 in the same commit: the version is shared between platforms, and shipping 0.0.36 with the code that already shipped for 0.0.32 produces an APK that cannot install over the released build. * fix(relay): back off overloaded assignments (#10894) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * release: v1.4.160-rc.0 * perf(agent-status): strip terminal control bytes by run, not per character (#10866) * perf(agent-status): strip terminal control bytes by run, not per character stripTerminalControl built its result with a per-character `+=`, allocating a fresh string for every retained character. The Command Code status detector calls it four times per PTY chunk — the scan text, the chunk-boundary variant, and both previous-text lengths — so an agent pane paid that on every write. Control bytes are sparse in real output, so copy the spans between them instead: 2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked exhaustively over every string up to length 4 across a 13-symbol control/unicode alphabet plus 200k random strings (224,831 inputs, 0 mismatches). * docs(agent-status): condense the run-copy rationale comments Review feedback: both comments walked through the implementation. Keep one line of non-obvious rationale each, per the repo's comment guidelines. Co-authored-by: Orca * test(agent-status): correct terminal strip benchmark * test(agent-status): bound terminal strip benchmark --------- Co-authored-by: Orca * perf(usage): yield with setImmediate, not a clamped setTimeout(0) (#10892) Co-authored-by: Orca * fix(terminal): restore link hover after mouseleave (#10903) * fix(terminal): restore link hover after mouseleave * test(terminal): verify mouseleave listener cleanup * test(terminal): assert link hover listener wiring * Update README downloads badge * fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) (#10893) * fix(resource-manager): never destroy a session Orca cannot prove is idle (#8459) Resource Manager decided a session was an "orphan" from the absence of a renderer binding, then force-killed it with no prompt. Absence of a binding is not evidence a session is idle — during restore the binding map is legitimately empty, and deferred SSH sessions never appear in it at all. Live agent sessions were destroyed this way, losing unrecoverable work. Three gaps, one rule: only positive evidence authorizes destruction. - `pty:listSessions` dropped `agentSessionOwners` at the IPC boundary, so the renderer could not see the one fact that proves work is running. It now reports `hasAgentOwner`, typed once in `shared/pty-listed-session.ts` so the main handler, both preload surfaces, and the renderer cannot drift. - The binding index ignored `deferredSshSessionIdsByTabId` — sessions restore knows are live on an SSH host but has not reattached. No other binding source can see them. - The bulk-kill handler filtered sessions separately from the button's count, so the set killed could differ from the set advertised. Both now call `selectUnboundDaemonSessions`. The single-row kill path had the same defect: it skipped confirmation whenever `bound` was false. `requiresKillConfirmation` now also holds for agent-owned sessions, and snapshot-derived rows carry ownership across from the daemon list rather than reporting `false`. * fix(resource-manager): distinguish unprovable ownership from proven absence Adversarial review of the previous commit found it committed the same class of error it was fixing: it collapsed "no agent owns this" and "this provider cannot tell me" into one boolean `false`, and both destructive paths read that as proof. A daemon generation below the claim protocol, an older SSH relay, or the in-process local fallback all list no owners for a session that may well have one. `pty.ts` already encodes the rule at :613 — "only providers that serialize claims may make listing absence authoritative" — and the new IPC row ignored it. So after upgrading with a legacy daemon still holding a live agent terminal, bulk cleanup would have destroyed it: exactly #8459, one layer down. `hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`, derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes destruction, so `unknown` protects and confirms. Second defect, found independently by four review lenses: the deferred-SSH bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because the merge call site re-listed the binding fields instead of reusing the object. A deferred SSH session therefore rendered `bound: false`, and its single-row kill skipped confirmation while bulk cleanup correctly spared it. The call site now spreads `resourceSessionBindings`, and a parity test fails if any binding field is re-listed inline — the drift itself is now impossible to reintroduce quietly. The e2e ownership assertion was also weak: it checked only that a boolean arrived. It now asserts the exact arm, and that the live local provider reports `absent` rather than `unknown`, so a degenerate all-unknown implementation fails. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822) * feat(usage): price Claude 5 family and GPT-5.6 token usage Claude Opus 5, Sonnet 5, Fable 5 and Codex gpt-5.6 sol/terra/luna were absent from the usage pricing tables, so their turns aggregated tokens but reported no estimated cost. Rates from Anthropic and OpenAI published pricing. Sonnet 5 gets no long-context tier: Claude 4.6 and later bill the full 1M window flat. Sonnet 5 uses the standard $3/$15 rate, not the $2/$10 introductory rate that runs through 2026-08-31 — the table has no date dimension. * fix(usage): price the bare gpt-5.6 alias and assert Opus 4.5 separately OpenAI routes the bare `gpt-5.6` alias to Sol, but only the explicit `-sol` / `-terra` / `-luna` IDs resolved, so alias-recorded sessions still reported no cost. Match it exactly rather than by prefix so it cannot swallow the tier IDs or a future cheaper variant. Also split the Claude 5 shadowing guard into per-model breakdown assertions and add the missing Opus 4.5 fixture the test name claimed. * docs(usage): note Sonnet 5 uses standard, not introductory, rates * fix(editor): save floating workspace markdown files (#10985) * fix(worktrees): resolve a two-host project by the worktree's own host (#10634) (#10986) * fix(worktrees): resolve a two-host project by the worktree's own host (#10634) A project registered on both a local host and an SSH host permanently poisoned every one of its workspaces with "Workspace identity is ambiguous across hosts. Refresh projects and try again." Refresh could never help: nothing was stale, both host setups were valid and intentional. The error survived restarts. The ambiguity was manufactured. `resolveExactWorktreeRoute` starts from a worktree that already carries exactly one `hostId`, then throws that away and asks `resolveIndexedRepoOperationRoute` which host owns the *repo* — a question with two right answers once a project spans hosts. Only the project spans hosts; each worktree never does. Route resolution now filters repo setups to the ones matching the worktree's own host before looking for a transport, so a two-host project resolves as cleanly as a one-host project. Genuine ambiguity still returns `ambiguous`. Second half: the error escaped as an *uncaught renderer error* because passive background paths — unread marking, activity bumps — called a helper that threw. Those callers now degrade: `trySettingsForWorktreeOwner` returns null, the passive update is skipped with a warning, and local state stays consistent. Explicit user actions still surface the error. * fix(worktrees): cover every passive path and warn once for ambiguous owners Adversarial review found the routing fix sound but its coverage thin: only markWorktreeUnread had an ambiguous-owner test, so restoring the throw in clearWorktreeUnread or bumpWorktreeActivity would have reproduced the uncaught renderer error with the suite still green. Both are now covered, verified by mutation. bumpWorktreeActivity also skipped silently where the other paths warned. It now warns — but once per workspace, not per event: activity bumps fire on every PTY event, so an unbounded warn would flood the console for exactly the users already hitting this bug. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * perf(runtime): remove timer clamps from cooperative yields (#10908) * perf(runtime): remove timer clamps from cooperative yields Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time. Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments. * fix(runtime): preserve pacing and release yield callbacks Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer. Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner. * Implement robust orchestration primitives and connected-server workers (#9925) * fix(gitlab): render item descriptions and comments with document markdown variant (#9161) Co-authored-by: viniciussilva Co-authored-by: Claude Opus 4.8 (1M context) * ci: parallelize PR checks and accelerate Vite builds (#10989) * ci: parallelize and accelerate PR checks * fix(ci): make accelerated checks runtime-safe * fix(ci): address review findings * fix(ci): retry transient Electron downloads * test(ci): cover Electron download retry limits * fix(terminals): negotiate explicit close intent for paired runtimes (#10129) * fix(cmd-j): focus the destination workspace's own terminal after a jump (#10695) Co-authored-by: Orca * fix(jira): render issue description/comment images with lightbox (#8938) * fix(jira): render issue images and open them in a lightbox Jira ADF media nodes were dropped when converting descriptions/comments to Markdown, so screenshots never appeared in the Tasks drawer. Download image attachments with authenticated Jira API access, embed them as data URLs on issue/comment detail loads, and add a viewport-centered lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet. * fix(jira): open comment images in the same lightbox as description Jira issue comments still used compact markdown, so screenshots rendered but could not expand. Use the document renderer for comment bodies, add a regression test for the expand control, and sync MarkdownImageLightbox locale keys. * fix(jira): harden inline image handling * fix(jira): harden inline image discovery, escaping, and downloads Address PR review findings: correct media-attachment pairing, Server/DC attachment lookup base path, markdown-safe external URLs, wider HTML discovery with gated alt fallback, concurrent downloads outside the API semaphore, and a main-process attachment data-URL cache with lower caps. * fix(jira): Option A multi-same-name attachments and post-map media warns Fix discovery so repeated alts (image.png) get distinct attachment ids, flush resolution warns after ADF mapping using attachment-only stats, clear attachment cache on clearToken with epoch-guarded singleflight, and add Server comment path plus release-before-binary regression tests. * fix(jira): simplify comment media request skip condition Only needingCount determines whether to skip the attachment metadata request — htmlIds alone cannot produce a download without needing media. Add type annotation for mediaAttrs for clarity. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(hooks): drain POSIX hook stdin without PATH (#10885) Reviewed with an independent reproduction on macOS and Ubuntu 20.04. Fixed five test files that asserted the old literal strings, and switched the reader to `command -p cat` so it also survives hosts without /bin/cat. * fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608) A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When its parent shell survives, the emulator keeps mouse tracking, focus reporting and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR motion reports at the prompt, and the doomed process burns CPU parsing the motion firehose while it lives. Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill, daemon reattach), so an agent dying under a live shell crossed none of them. Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed return-to-shell transition, next to the sibling stale-title cleanup. That transition is gated on a real foreground-process read rather than the bare OSC 133;D, because a full-screen agent's nested command shells leak their own D onto the main PTY. The write goes through the replay guard so xterm's auto replies cannot leak to the shell as input. The reused constant already excludes ?2004l, so the bracketed-paste protection the live shell re-arms at its prompt is preserved. * fix(release): restore the Windows inner-binary signature gate (#6487) (#10719) * fix(release): restore the Windows inner-binary signature gate electron-builder 26.9+ dropped the bundled 7zip-bin package, so the gate's hardcoded node_modules/7zip-bin path stopped resolving in 1d2cd33c83. The gate is fail-open, so it swallowed the error and 11 releases shipped with no signature verification and an evidence artifact that looked clean. Resolve 7za through app-builder-lib's toolset instead, and always record a verdict so a degraded gate can't pass for a healthy one. Refs #6487 * test(release): make the signing-gate structural tests assert executed code, not text The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so the word satisfied the assertion wherever it appeared. Downgrading the resolver throw to `Write-Host "...would normally throw..."` — the exact silent fail-open this PR exists to kill — left all 11 tests green. Every span is now classified once (code / string / comment) by the same walk that pairs braces, and assertions run against the string-and-comment-blanked view. Blanking preserves length, so indices still line up across views. Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked the first catch in the step, which stopped being the gate's own once the persistence helpers grew theirs — moving the policy throw inside the try was passing again. Co-authored-by: Orca * test(release): pin the evidence filename the gate writes to the one the upload collects The upload step is `if-no-files-found: ignore`, so renaming the evidence file on one side and not the other ships a green run whose artifact silently omits the verdict — the same silent-degradation class this PR exists to close. Co-authored-by: Orca * fix(release): preserve 7za resolver failures --------- Co-authored-by: Orca Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(shortcuts): gate Cmd/Ctrl+N folder-workspace jumps on path status (#10748) Reviewed with an independent reproduction, including a live-app check. Replaced tests that mocked the module containing the fix, and reused the existing workspace activator instead of adding a 4th copy of the dispatch. * fix(pty): strip inherited Claude child-session stamps at spawn (#9961) An Orca GUI or daemon launched from inside a Claude Code session inherits CLAUDE_CODE_CHILD_SESSION / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_BRIDGE_SESSION_ID. Every spawn path spreads the host's process.env, so each terminal Orca opens is marked a nested Claude child and Claude silently disables transcript persistence — real sessions stop writing on-disk history with no visible error. Older-protocol daemons are deliberately preserved across upgrades and the auto-updater relaunch inherits the previous app's env, so one contaminated launch propagates through subsequent updates. Orca never sets these variables, so an inherited value is always poison. Add a deny constant plus an inherited-only filter merged into envToDelete at both pty spawn call sites, keeping a stamp explicitly passed in args.env. Unlike the agent-hook keys this is not gated on isDaemonHostSpawn, because the local provider and the relay host spread their own process.env too. Review fixes: mergePtyEnvDeletions is now variadic (the nested form passed a `string[] | undefined` intermediate into a `readonly string[]` parameter and did not typecheck); coverage extends to the runtime-controller spawn path, the local provider, and the SSH route, whose exact spawn-options assertion had to be updated because envToDelete is no longer ever undefined. * fix(sidebar): spin the worktree dot while Claude Code is thinking (#9040) (#10684) Co-authored-by: Orca * fix(cmd-j): restore focus when issue match routing declines (#11010) * fix(markdown): render task continuations as text (#11008) * fix(dashboard): stop the agent icon shrinking on long card titles (#11001) A bare flex item shrinks with its row, so kanban cards and the terminal dialog rendered a squashed ~9px agent icon whenever the worktree name overflowed. Wrap both in the shrink-0 span every other surface already uses. * fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799) * fix(runtime): refuse SSH hosts in project setup instead of acting locally projectHostSetup.clone and .setupExistingFolder threaded executionHostId all the way down but never used it for routing: cloneRepo runs a local mkdir plus a local gitSpawn, and addRepo probes the path with existsSync/statSync. An `ssh:` host therefore cloned and validated on the *local* machine and then registered the result as living on the SSH host. It only failed loudly here because the remote path did not exist locally. With a plausible destination the clone succeeds and writes a setup record pointing at the wrong machine. Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path, which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`. Fail closed until the RPC learns to route through the SSH providers. * test(runtime): make the SSH guard test observe the corruption it names The test asserted `gitSpawn` was never called and no repo was registered, but neither assertion could fail. `/home/brennan` is unwritable on macOS, so the pre-guard clone died at `mkdir` before reaching `gitSpawn`, and `/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact side effects under test were unreachable either way. `rejects.toThrow` also aborted the test before those lines ran. Use a real temp destination and a real temp git repo, await both calls via `.catch`, and assert the side effects before the wording. With the guard disabled the test now fails on `gitSpawn` being called once with a real `git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'` — the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is stubbed so a regression records the call instead of hitting the network. Also document the SSH restriction on `project setup-existing-folder`, which the guard now rejects. `setup-clone` already carried that note; its sibling did not. * fix(browser): localize degraded cookie import warnings --------- Co-authored-by: Orca Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: grboy6770 <2000grboy@gmail.com> Co-authored-by: ye4241 <6803102+ye4241@users.noreply.github.com> Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Co-authored-by: SoonKim Co-authored-by: Claude Opus 4.8 Co-authored-by: Ken Fukuyama Co-authored-by: SilvaViniF <108766450+SilvaViniF@users.noreply.github.com> Co-authored-by: viniciussilva Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Anishek Kamal Co-authored-by: BingZ Co-authored-by: Tom Co-authored-by: Tom de Bres Co-authored-by: Andres Van Reepingen Co-authored-by: Andres Van Reepingen Co-authored-by: xsacdw <251566507+xsacdw@users.noreply.github.com> Co-authored-by: LauraGPT Co-authored-by: reopard007 Co-authored-by: xsacdw Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> Co-authored-by: OrcaWin Co-authored-by: Nemo Co-authored-by: nolainjin Co-authored-by: David Anderson <215816+akapug@users.noreply.github.com> Co-authored-by: Vansh <77244334+vansh2408@users.noreply.github.com> --- .../browser/browser-cookie-import.test.ts | 220 +++++++++++++++++- src/main/browser/browser-cookie-import.ts | 167 +++++++++---- ...owser-session-registry.persistence.test.ts | 85 +++++++ src/main/browser/browser-session-registry.ts | 22 ++ .../browser/chromium-cookie-snapshot.test.ts | 57 +++++ src/main/browser/chromium-cookie-snapshot.ts | 10 +- .../browser-pane/BrowserImportHintButton.tsx | 7 +- .../browser-pane/BrowserToolbarMenu.tsx | 7 +- .../components/settings/BrowserProfileRow.tsx | 7 +- .../settings/BrowserUseCookieImportStep.tsx | 7 +- src/renderer/src/i18n/locales/en.json | 10 + 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 + .../lib/browser-cookie-import-toast.test.ts | 72 ++++++ .../src/lib/browser-cookie-import-toast.ts | 39 ++++ src/shared/types.ts | 5 + 18 files changed, 689 insertions(+), 66 deletions(-) create mode 100644 src/renderer/src/lib/browser-cookie-import-toast.test.ts create mode 100644 src/renderer/src/lib/browser-cookie-import-toast.ts diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 27663017097..55fae27c93e 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -6,13 +6,25 @@ const { copyFileSyncMock, execFileSyncMock, sessionFromPartitionMock, - dialogShowOpenDialogMock + dialogShowOpenDialogMock, + setPendingCookieImportMock, + clearPendingCookieImportMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), sessionFromPartitionMock: vi.fn(), - dialogShowOpenDialogMock: vi.fn() + dialogShowOpenDialogMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock, + persistUserAgent: vi.fn() + } })) vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) @@ -47,8 +59,16 @@ import { createChromiumCookieTestDatabase, encryptMacChromiumCookie } from './browser-cookie-import-test-database' -import { existsSync, writeFileSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' -import { join } from 'node:path' +import { + existsSync, + mkdirSync, + writeFileSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync +} from 'node:fs' +import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' function chromeBrowser(cookiesPath: string): DetectedBrowser { @@ -382,6 +402,8 @@ describe('importCookiesFromBrowser Chromium', () => { appGetPathMock.mockReset() appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) copyFileSyncMock.mockClear() + setPendingCookieImportMock.mockClear() + clearPendingCookieImportMock.mockClear() execFileSyncMock.mockReset() execFileSyncMock.mockImplementation(() => { throw new Error('OS credential commands are unavailable in this test') @@ -515,20 +537,198 @@ describe('importCookiesFromBrowser Chromium', () => { } }) - it('removes partial staging data when the target database copy fails', async () => { + // Why: #9355 — staging only backs the cold-restart replay, so an AV/EDR handle that blocks + // the staging copy must degrade that fallback, not abort an import the memory path can serve. + it('still imports in-memory when the target database copy fails', async () => { const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') - createChromiumCookieTestDatabase(sourceCookiesPath, []).close() + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() createChromiumCookieTestDatabase(targetCookiesPath, []).close() + // The staging copy runs before the source snapshot, so the first copy is the staging one. copyFileSyncMock.mockImplementationOnce((_source: string, destination: string) => { writeFileSync(destination, 'partial cookie database') - throw new Error('simulated copy failure') + const error = new Error('EBUSY: resource busy or locked, copyfile') as NodeJS.ErrnoException + error.code = 'EBUSY' + throw error }) - const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) - expect(result).toEqual({ ok: false, reason: 'Could not create staging cookie database.' }) - expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + // The partial staging file is still discarded, so no stale DB replays on cold start. + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — the staged file is also named "Cookies", so the same transient handle can make + // opening it throw. That was fatal too, and the count must stay truthful without a staging DB. + it('still imports in-memory when the staging database cannot be opened', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + mkdirSync(dirname(targetCookiesPath), { recursive: true }) + // A live partition DB that copies fine but is not openable as SQLite. + writeFileSync(targetCookiesPath, 'not a sqlite database') + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + // The summary counts importable cookies, not staged rows. + expect(result.ok && result.summary?.importedCookies).toBe(1) + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — registering a staged path that was never written would make the next cold start + // replay a missing or partial DB over the live partition. + it('never registers a restart replay when staging is unavailable', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + mkdirSync(dirname(targetCookiesPath), { recursive: true }) + writeFileSync(targetCookiesPath, 'not a sqlite database') + // Forces the restart fallback to be the only way these cookies could ever land. + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + // An older staged DB must not survive an import that already rewrote the live session. + expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test') + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + // Why: the jar was cleared and nothing replaced it, so this must not read as a clean success. + expect(result.ok && result.summary?.warning).toEqual({ + code: 'restart-fallback-unavailable', + loadedCookies: 0, + failedCookies: 1 + }) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — a staging write that fails mid-transaction must disable the restart fallback + // rather than abort an import whose in-memory half still works. + it('still imports in-memory when a staging insert fails', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + const targetDb = createChromiumCookieTestDatabase(targetCookiesPath, []) + // Rejects every staged row the way a corrupt index or disk error would. + targetDb.exec( + `CREATE TRIGGER reject_insert BEFORE INSERT ON cookies + BEGIN SELECT RAISE(ABORT, 'staging write failed'); END` + ) + targetDb.close() + // Why: without a memory failure, memoryFailed === 0 would suppress registration on its own and + // the assertion below would pass even if the insert failure never disabled staging. + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test') + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — a successful in-memory import must retire any older staged replay, or the next + // cold start will overwrite the live session with a stale snapshot. + it('clears a stale staged replay after an import that fully succeeds in memory', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test') + // A fully in-memory import needs no restart, so it must not warn. + expect(result.ok && result.summary?.warning).toBeUndefined() + } finally { + platformSpy.mockRestore() + } + }) + + // Why: the staged path is the restart fallback's only input, so a working staging DB must register it. + it('registers the staged database when cookies need a restart', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const [partition, stagedPath] = setPendingCookieImportMock.mock.calls[0] + expect(partition).toBe('persist:test') + expect(existsSync(stagedPath as string)).toBe(true) + } finally { + platformSpy.mockRestore() + } }) }) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 7bcfd4ea247..e7703a1f5e5 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -80,6 +80,7 @@ import { type ChromiumCookieSnapshot } from './chromium-cookie-snapshot' import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' // --------------------------------------------------------------------------- // Browser detection @@ -1449,17 +1450,24 @@ export async function importCookiesFromBrowser( stagingDir, `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` ) + // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory + // import rejects, so losing it must degrade that fallback rather than abort the import. + let stagingAvailable = false try { mkdirSync(stagingDir, { recursive: true }) - copyFileSync(liveCookiesPath, stagingCookiesPath) - } catch { + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. try { unlinkSync(stagingCookiesPath) } catch { /* best-effort */ } - return { ok: false, reason: 'Could not create staging cookie database.' } } let sourceSnapshot: ChromiumCookieSnapshot @@ -1481,6 +1489,21 @@ export async function importCookiesFromBrowser( let sourceDb: InstanceType | null = null let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } try { // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. @@ -1488,15 +1511,32 @@ export async function importCookiesFromBrowser( readOnly: true, readBigInts: true }) - stagingDb = new DatabaseSync(stagingCookiesPath) - - const targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - const colList = targetCols.join(', ') - - stagingDb.exec('DELETE FROM cookies') + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols: string[] = targetColumnInfo.map((r) => r.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + stagingDb.exec('DELETE FROM cookies') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${err}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. + discardStagingFile() + } + } const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< string, @@ -1508,13 +1548,8 @@ export async function importCookiesFromBrowser( diag(` source has ${sourceRows.length} cookies`) if (sourceRows.length === 0) { - stagingDb.close() - stagingDb = null - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } + closeStagingDb() + discardStagingFile() return { ok: false, reason: `No cookies found in ${browser.label}.` } } @@ -1526,14 +1561,9 @@ export async function importCookiesFromBrowser( ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) : null if (needsSourceKey && !sourceKey) { - stagingDb.close() - stagingDb = null + closeStagingDb() // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } + discardStagingFile() return { ok: false, reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` @@ -1577,12 +1607,29 @@ export async function importCookiesFromBrowser( const decryptedCookies: DecryptedCookie[] = [] - const placeholders = targetCols.map(() => '?').join(', ') - const insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. + let insertStmt: ReturnType['prepare']> | null = null + const disableStaging = (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + stagingAvailable = false + insertStmt = null + closeStagingDb() + discardStagingFile() + } - stagingDb.exec('BEGIN TRANSACTION') + if (stagingDb && colList && placeholders) { + try { + insertStmt = stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` + ) + stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + disableStaging(String(err)) + } + } else if (stagingAvailable) { + disableStaging('staged database exposed no cookies columns') + } for (const sourceRow of sourceRows) { const encRaw = sourceRow.encrypted_value @@ -1637,17 +1684,35 @@ export async function importCookiesFromBrowser( expirationDate: expiresUtc > 0 ? expiresUtc : undefined }) - const params = buildChromiumCookieInsertParams(targetColumnInfo, sourceRow, decryptedValue) - insertStmt.run(...params) + if (insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + decryptedValue + ) + insertStmt.run(...params) + } catch (err) { + disableStaging(String(err)) + } + } + // Why: counts importable cookies, not staged rows — the summary must stay truthful when + // the optional staging DB is unavailable. imported++ } diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) - stagingDb.exec('COMMIT') - stagingDb.close() - stagingDb = null - - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) + if (stagingDb) { + try { + stagingDb.exec('COMMIT') + closeStagingDb() + diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) + } catch (err) { + disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${imported} cookies will load in-memory only`) + } // Why: clear stale cookies first; mixing them with the imported set makes sites like Google reject the session. await targetSession.clearStorageData({ storages: ['cookies'] }) @@ -1684,16 +1749,27 @@ export async function importCookiesFromBrowser( diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`) - if (memoryFailed > 0) { + let warning: BrowserCookieImportSummary['warning'] + if (memoryFailed > 0 && stagingAvailable) { // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ + } else if (memoryFailed > 0) { + // Why: never register a path that was never written — cold start would replay a missing + // or partial DB over the live partition. + browserSessionRegistry.clearPendingCookieImport(targetPartition) + discardStagingFile() + diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) + // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: memoryLoaded, + failedCookies: memoryFailed } + } else { + // Why: this import already rewrote the live session, so an older staged DB must not replay over it. + browserSessionRegistry.clearPendingCookieImport(targetPartition) + discardStagingFile() diag(` all cookies loaded in-memory — no restart needed`) } @@ -1709,7 +1785,8 @@ export async function importCookiesFromBrowser( totalCookies: sourceRows.length, importedCookies: imported, skippedCookies: skipped, - domains: [...domainSet].sort() + domains: [...domainSet].sort(), + ...(warning ? { warning } : {}) } return { ok: true, profileId: '', summary } diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 174b5aa7715..50166f6f52e 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -235,6 +235,91 @@ describe('BrowserSessionRegistry persistence', () => { }) }) + it('clears only the requested partition and unlinks its staged database files', async () => { + const otherPartition = 'persist:orca-browser-session-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: '/staged/default', + pendingCookieImports: { + 'persist:orca-browser': '/staged/default', + [otherPartition]: '/staged/other' + }, + profiles: [] + }) + for (const suffix of ['', '-wal', '-shm']) { + fsState.files.set(`/staged/other${suffix}`, 'db') + fsState.present.add(`/staged/other${suffix}`) + fsState.files.set(`/staged/default${suffix}`, 'db') + fsState.present.add(`/staged/default${suffix}`) + } + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.clearPendingCookieImport(otherPartition) + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieImports).toEqual({ 'persist:orca-browser': '/staged/default' }) + // Why: the default partition still has a staged replay, so the legacy pointer must survive. + expect(written.pendingCookieDbPath).toBe('/staged/default') + for (const suffix of ['', '-wal', '-shm']) { + expect(fsState.present.has(`/staged/other${suffix}`)).toBe(false) + expect(fsState.present.has(`/staged/default${suffix}`)).toBe(true) + } + }) + + it('drops the legacy pointer when the default partition is the one cleared', async () => { + const otherPartition = 'persist:orca-browser-session-cccccccc-cccc-4ccc-8ccc-cccccccccccc' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: '/staged/default', + pendingCookieImports: { + 'persist:orca-browser': '/staged/default', + [otherPartition]: '/staged/other' + }, + profiles: [] + }) + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.clearPendingCookieImport('persist:orca-browser') + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieImports).toEqual({ [otherPartition]: '/staged/other' }) + expect(written.pendingCookieDbPath).toBeNull() + }) + + it('is a no-op when the partition has no pending import', async () => { + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: '/staged/default', + pendingCookieImports: { 'persist:orca-browser': '/staged/default' }, + profiles: [] + }) + fsState.files.set('/staged/default', 'db') + fsState.present.add('/staged/default') + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + const metaBefore = fsState.files.get(META_PATH) + + browserSessionRegistry.clearPendingCookieImport('persist:orca-browser-session-unknown') + + // Why: an absent key must not rewrite meta or touch another partition's staged file. + expect(fsState.files.get(META_PATH)).toBe(metaBefore) + expect(fsState.present.has('/staged/default')).toBe(true) + }) + it('restores persisted UA for non-default partitions', async () => { const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 72a888edb62..1dfd03c778e 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -290,6 +290,28 @@ class BrowserSessionRegistry { }) } + // Why: a degraded import still rewrites the live session, so an older staged DB must stop replaying over it. + clearPendingCookieImport(partition: string): void { + const meta = this.loadPersistedMeta() + if (!(partition in meta.pendingCookieImports)) { + return + } + const pendingCookieImports = { ...meta.pendingCookieImports } + const stagedPath = pendingCookieImports[partition] + delete pendingCookieImports[partition] + this.persistMeta({ + pendingCookieImports, + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null + }) + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagedPath + suffix) + } catch { + /* best-effort */ + } + } + } + persistUserAgent(partition: string, userAgent: string | null): void { const meta = this.loadPersistedMeta() const userAgentByPartition = { ...meta.userAgentByPartition } diff --git a/src/main/browser/chromium-cookie-snapshot.test.ts b/src/main/browser/chromium-cookie-snapshot.test.ts index 16fbd50a63f..f2d6e322d96 100644 --- a/src/main/browser/chromium-cookie-snapshot.test.ts +++ b/src/main/browser/chromium-cookie-snapshot.test.ts @@ -156,6 +156,63 @@ describe('createChromiumCookieSnapshot', () => { expect(readdirSync(snapshotsRoot)).toEqual([]) }) + // Why: #9355 — the attempt loop only reacts to a `false` return, so before the retry a + // transient AV/EDR lock on the main copy threw straight out of the snapshot. + it('retries a transient Windows lock on the main database copy', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + createChromiumCookieTestDatabase(sourcePath, [{ name: 'sid', value: 'locked-value' }]).close() + let mainCopyAttempts = 0 + beforeCopyMock.mockImplementation((source: string) => { + if (source !== sourcePath) { + return + } + mainCopyAttempts += 1 + if (mainCopyAttempts === 1) { + throw Object.assign(new Error('EBUSY: resource busy or locked, copyfile'), { + code: 'EBUSY' + }) + } + }) + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + try { + const snapshot = createChromiumCookieSnapshot(sourcePath, { tempRoot: root }) + const database = new DatabaseSync(snapshot.databasePath, { readOnly: true }) + const row = database.prepare('SELECT name, value FROM cookies').get() + database.close() + + expect(row).toEqual(expect.objectContaining({ name: 'sid', value: 'locked-value' })) + expect(mainCopyAttempts).toBe(2) + snapshot.cleanup() + } finally { + platformSpy.mockRestore() + } + }) + + // Why: the retry is Windows-scoped, so POSIX keeps failing fast instead of sleeping. + it('does not retry a locked copy off Windows', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + const snapshotsRoot = join(root, 'snapshots') + mkdirSync(snapshotsRoot) + createChromiumCookieTestDatabase(sourcePath, [{ name: 'sid', value: 'value' }]).close() + let mainCopyAttempts = 0 + beforeCopyMock.mockImplementation(() => { + mainCopyAttempts += 1 + throw Object.assign(new Error('EBUSY: resource busy or locked, copyfile'), { code: 'EBUSY' }) + }) + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + expect(() => createChromiumCookieSnapshot(sourcePath, { tempRoot: snapshotsRoot })).toThrow( + 'EBUSY' + ) + expect(mainCopyAttempts).toBe(1) + expect(readdirSync(snapshotsRoot)).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + it('removes its temporary directory when the source database is missing', () => { const snapshotsRoot = join(root, 'snapshots') mkdirSync(snapshotsRoot) diff --git a/src/main/browser/chromium-cookie-snapshot.ts b/src/main/browser/chromium-cookie-snapshot.ts index 4b54e98be8d..452dfca0027 100644 --- a/src/main/browser/chromium-cookie-snapshot.ts +++ b/src/main/browser/chromium-cookie-snapshot.ts @@ -1,6 +1,7 @@ -import { copyFileSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' const SNAPSHOT_ATTEMPTS = 5 @@ -83,12 +84,15 @@ function copyStableAttempt(sourcePath: string, databasePath: string): boolean { } removeAttemptFiles(databasePath) - copyFileSync(sourcePath, databasePath) + // Why: #9355 — AV/EDR briefly opens a file literally named "Cookies" with FILE_SHARE_NONE, + // so an otherwise-fine copy throws EBUSY. The attempt loop below only handles a false + // return, so a throw here would escape it entirely. + copyFileWithWindowsRetry(sourcePath, databasePath) if (walBefore) { try { // Why: SQLite only discovers a WAL whose basename exactly matches the DB. - copyFileSync(sourceWalPath, `${databasePath}-wal`) + copyFileWithWindowsRetry(sourceWalPath, `${databasePath}-wal`) } catch (error) { if (isMissingFileError(error)) { return false diff --git a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx index 96f1abab344..32af12eeb10 100644 --- a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx +++ b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Import } from 'lucide-react' import { toast } from 'sonner' +import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -89,7 +90,8 @@ export function BrowserImportHintButton({ ) if (result.ok) { const browser = detectedBrowsers.find((entry) => entry.family === browserFamily) - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.browser.pane.BrowserImportHintButton.02e89014c5', 'Imported {{value0}} cookies from {{value1}}{{value2}}.', @@ -112,7 +114,8 @@ export function BrowserImportHintButton({ setImportMenuOpen(false) const result = await importCookiesToProfile(effectiveProfileId) if (result.ok) { - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.browser.pane.BrowserImportHintButton.d40d584769', 'Imported {{value0}} cookies from file.', diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 7b0bae3619f..aa5ab16c51c 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useState } from 'react' import { toast } from 'sonner' +import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import { shouldShowBrowserImportHint } from './browser-import-hint-visibility' @@ -171,7 +172,8 @@ export function BrowserToolbarMenu({ const result = await importCookiesFromBrowser(effectiveProfileId, browserFamily, browserProfile) if (result.ok) { const browser = detectedBrowsers.find((b) => b.family === browserFamily) - toast.success( + emitBrowserCookieImportToast( + result.summary, browserProfile ? translate( 'auto.components.browser.pane.BrowserToolbarMenu.c5f0e4d3b2a1', @@ -199,7 +201,8 @@ export function BrowserToolbarMenu({ const handleImportFromFile = async (): Promise => { const result = await importCookiesToProfile(effectiveProfileId) if (result.ok) { - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.browser.pane.BrowserToolbarMenu.53bbe3dab4', 'Imported {{value0}} cookies from file.', diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx index 0d1c377c45f..9f5dc38efdb 100644 --- a/src/renderer/src/components/settings/BrowserProfileRow.tsx +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -1,5 +1,6 @@ import { Import, Loader2, Trash2 } from 'lucide-react' import { toast } from 'sonner' +import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import type { BrowserCookieImportSummary, BrowserSessionProfile } from '../../../../shared/types' import { Button } from '../ui/button' import { @@ -58,7 +59,8 @@ export function BrowserProfileRow({ .importCookiesFromBrowser(profile.id, browserFamily, browserProfile) if (result.ok) { const browser = detectedBrowsers.find((b) => b.family === browserFamily) - toast.success( + emitBrowserCookieImportToast( + result.summary, browserProfile ? translate( 'auto.components.settings.BrowserProfileRow.a3f8c2d1e0b4', @@ -88,7 +90,8 @@ export function BrowserProfileRow({ const handleImportFromFile = async (): Promise => { const result = await useAppStore.getState().importCookiesToProfile(profile.id) if (result.ok) { - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.settings.BrowserProfileRow.b4c167764d', 'Imported {{value0}} cookies from file into {{value1}}.', diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx index f73668fa908..3ac98241a12 100644 --- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx @@ -1,5 +1,6 @@ import { Import, Loader2 } from 'lucide-react' import { toast } from 'sonner' +import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { cn } from '@/lib/utils' import { Button } from '../ui/button' import { @@ -47,7 +48,8 @@ export function BrowserUseCookieImportStep({ .importCookiesFromBrowser(profileId, browserFamily, browserProfile) if (result.ok) { const browser = detectedBrowsers.find((b) => b.family === browserFamily) - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.settings.BrowserUsePane.2ea4617e3a', 'Imported {{value0}} cookies from {{value1}}{{value2}}.', @@ -66,7 +68,8 @@ export function BrowserUseCookieImportStep({ const handleImportFromFile = async (): Promise => { const result = await useAppStore.getState().importCookiesToProfile('default') if (result.ok) { - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.settings.BrowserUsePane.8f2675c2f3', 'Imported {{value0}} cookies from file.', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 44b4124a0b0..01a53e11044 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -639,6 +639,16 @@ "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } } + }, + "browser": { + "cookie": { + "import": { + "toast": { + "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", + "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again." + } + } + } } }, "hooks": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8e6783cf331..df89084b3bc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -616,6 +616,16 @@ "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } } + }, + "browser": { + "cookie": { + "import": { + "toast": { + "restartFallbackUnavailableNone": "No se pudo cargar ninguna de las {{value0}} cookies y la alternativa tras reiniciar no estaba disponible. Se reemplazaron las cookies anteriores de este perfil. Intenta importar de nuevo.", + "restartFallbackUnavailablePartial": "Se importaron {{value0}} de {{value1}} cookies. El resto no se pudo cargar y la alternativa tras reiniciar no estaba disponible. Intenta importar de nuevo." + } + } + } } }, "hooks": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1448421ca9a..62fd5330a68 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -616,6 +616,16 @@ "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } } + }, + "browser": { + "cookie": { + "import": { + "toast": { + "restartFallbackUnavailableNone": "{{value0}} 件の Cookie を読み込めず、再起動時の代替処理も利用できませんでした。このプロファイルの以前の Cookie は置き換えられました。もう一度インポートしてください。", + "restartFallbackUnavailablePartial": "{{value1}} 件中 {{value0}} 件の Cookie をインポートしました。残りは読み込めず、再起動時の代替処理も利用できませんでした。もう一度インポートしてください。" + } + } + } } }, "hooks": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 22a54ebb542..df331ec2e3c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -616,6 +616,16 @@ "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } } + }, + "browser": { + "cookie": { + "import": { + "toast": { + "restartFallbackUnavailableNone": "{{value0}}개의 쿠키를 하나도 불러오지 못했고 재시작 대체 경로도 사용할 수 없었습니다. 이 프로필의 기존 쿠키가 교체되었습니다. 다시 가져오세요.", + "restartFallbackUnavailablePartial": "쿠키 {{value1}}개 중 {{value0}}개를 가져왔습니다. 나머지는 불러오지 못했고 재시작 대체 경로도 사용할 수 없었습니다. 다시 가져오세요." + } + } + } } }, "hooks": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 390037d4296..a6075fd7fb2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -616,6 +616,16 @@ "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } } + }, + "browser": { + "cookie": { + "import": { + "toast": { + "restartFallbackUnavailableNone": "无法加载这 {{value0}} 个 Cookie 中的任何一个,且重启回退机制不可用。此配置文件之前的 Cookie 已被替换。请重试导入。", + "restartFallbackUnavailablePartial": "已导入 {{value1}} 个 Cookie 中的 {{value0}} 个。其余 Cookie 无法加载,且重启回退机制不可用。请重试导入。" + } + } + } } }, "hooks": { diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts new file mode 100644 index 00000000000..a59c7a93f03 --- /dev/null +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { successToastMock, warningToastMock } = vi.hoisted(() => ({ + successToastMock: vi.fn(), + warningToastMock: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { success: successToastMock, warning: warningToastMock } +})) + +import type { BrowserCookieImportSummary } from '../../../shared/types' +import { emitBrowserCookieImportToast } from './browser-cookie-import-toast' + +const summary: BrowserCookieImportSummary = { + totalCookies: 3, + importedCookies: 3, + skippedCookies: 0, + domains: ['example.com'] +} + +describe('emitBrowserCookieImportToast', () => { + beforeEach(() => { + successToastMock.mockReset() + warningToastMock.mockReset() + }) + + it('shows the localized total-failure warning', () => { + emitBrowserCookieImportToast( + { + ...summary, + warning: { + code: 'restart-fallback-unavailable', + loadedCookies: 0, + failedCookies: 3 + } + }, + 'Imported 3 cookies.' + ) + + expect(warningToastMock).toHaveBeenCalledWith( + 'None of the 3 cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.' + ) + expect(successToastMock).not.toHaveBeenCalled() + }) + + it('shows the localized partial-failure warning', () => { + emitBrowserCookieImportToast( + { + ...summary, + warning: { + code: 'restart-fallback-unavailable', + loadedCookies: 2, + failedCookies: 1 + } + }, + 'Imported 3 cookies.' + ) + + expect(warningToastMock).toHaveBeenCalledWith( + 'Imported 2 of 3 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.' + ) + expect(successToastMock).not.toHaveBeenCalled() + }) + + it('shows success when the import has no warning', () => { + emitBrowserCookieImportToast(summary, 'Imported 3 cookies.') + + expect(successToastMock).toHaveBeenCalledWith('Imported 3 cookies.') + expect(warningToastMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts new file mode 100644 index 00000000000..4bdd031f83f --- /dev/null +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -0,0 +1,39 @@ +import { toast } from 'sonner' +import type { BrowserCookieImportSummary } from '../../../shared/types' +import { translate } from '@/i18n/i18n' + +type CookieImportWarning = NonNullable + +function formatCookieImportWarning(warning: CookieImportWarning): string { + switch (warning.code) { + case 'restart-fallback-unavailable': + return warning.loadedCookies === 0 + ? translate( + 'auto.lib.browser.cookie.import.toast.restartFallbackUnavailableNone', + 'None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.', + { value0: warning.failedCookies } + ) + : translate( + 'auto.lib.browser.cookie.import.toast.restartFallbackUnavailablePartial', + 'Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.', + { + value0: warning.loadedCookies, + value1: warning.loadedCookies + warning.failedCookies + } + ) + } +} + +// Why: a degraded import returns ok:true with a warning, so every call site must route it to a +// warning toast instead of reporting an unqualified success (#9355). +export function emitBrowserCookieImportToast( + summary: BrowserCookieImportSummary, + successMessage: string +): void { + const warning = summary.warning + if (warning) { + toast.warning(formatCookieImportWarning(warning)) + return + } + toast.success(successMessage) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index cb63444b468..c1faa77ab4c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1035,6 +1035,11 @@ export type BrowserCookieImportSummary = { importedCookies: number skippedCookies: number domains: string[] + warning?: { + code: 'restart-fallback-unavailable' + loadedCookies: number + failedCookies: number + } } export type BrowserCookieImportResult = From 7f3c95a585c26ac762da2c743bf9a9c55e8ed0e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:40:56 -0700 Subject: [PATCH 013/203] fix(git-history): stop reading the option marker as the resolved ref name (#10906) `rev-parse --verify` swallows --end-of-options, but --symbolic-full-name deliberately echoes it -- on every git version tested, 2.25 through 2.49: $ git rev-parse --symbolic-full-name --end-of-options feature --end-of-options refs/heads/feature resolveSymbolicFullName took the first non-empty line, so it returned the literal string "--end-of-options" instead of the ref. That value flows into gitHistoryRefFromFullName, matches none of the refs/heads, refs/remotes, or refs/tags prefixes, and every named branch and tag in git history was silently categorized as a plain commit with a garbage id. Skip the marker line. Version-independent bug; no test covered it. Also pins git's echo behavior in the real-binary compatibility suite, so if a future git stops emitting the marker the reason for the skip gets re-read rather than the assumption quietly rotting. Co-authored-by: Orca --- src/shared/git-binary-compatibility.test.ts | 12 ++++ src/shared/git-history.test.ts | 62 +++++++++++++++++++++ src/shared/git-history.ts | 11 +++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 29e1068a5bf..906a9b78811 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -201,4 +201,16 @@ describeBinaryCompatibility('real Git binary compatibility', () => { expect(supports(2, 31)).toBe(false) } }) + + // Why pin this: --verify swallows --end-of-options but --symbolic-full-name echoes + // it deliberately, on every version tested (2.25 through 2.49). git-history.ts skips + // that line; if a future git stopped emitting it, the skip stays correct, but if this + // assertion ever flips the reason for the skip is worth re-reading. + it('echoes the option marker from rev-parse --symbolic-full-name', async () => { + const result = await runGit(['rev-parse', '--symbolic-full-name', '--end-of-options', 'HEAD']) + const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean) + + expect(lines[0]).toBe('--end-of-options') + expect(lines.find((line) => line !== '--end-of-options')).toMatch(/^refs\//) + }) }) diff --git a/src/shared/git-history.test.ts b/src/shared/git-history.test.ts index ec926d5f0a7..54aac202c71 100644 --- a/src/shared/git-history.test.ts +++ b/src/shared/git-history.test.ts @@ -263,3 +263,65 @@ describe('git history loader', () => { expect(executor).toHaveBeenCalledTimes(1) }) }) + +describe('symbolic full-name resolution', () => { + // Why: `rev-parse --verify` swallows --end-of-options, but --symbolic-full-name + // deliberately echoes it. Taking the first line made every branch and tag fall + // through gitHistoryRefFromFullName's prefix checks into the "commits" category. + function executorEmitting(symbolicStdout: string): GitHistoryExecutor { + return vi.fn(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('--symbolic-full-name')) { + return { stdout: symbolicStdout } + } + if (args[0] === 'rev-parse') { + return { stdout: `${HEAD_OID}\n` } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'main\n' } + } + if (args[0] === 'for-each-ref' || args[0] === 'merge-base') { + return { stdout: '' } + } + if (args[0] === 'log') { + return { stdout: logRecord({ hash: HEAD_OID, message: 'only' }) } + } + return { stdout: '' } + }) as unknown as GitHistoryExecutor + } + + it('categorizes a branch when git echoes the option marker', async () => { + const executor = executorEmitting('--end-of-options\nrefs/heads/feature\n') + + const result = await loadGitHistoryFromExecutor(executor, '/repo', { baseRef: 'feature' }) + + expect(result.baseRef).toMatchObject({ + id: 'refs/heads/feature', + name: 'feature', + category: 'branches' + }) + }) + + it('categorizes a tag when git echoes the option marker', async () => { + const executor = executorEmitting('--end-of-options\nrefs/tags/v1.0.0\n') + + const result = await loadGitHistoryFromExecutor(executor, '/repo', { baseRef: 'v1.0.0' }) + + expect(result.baseRef).toMatchObject({ id: 'refs/tags/v1.0.0', category: 'tags' }) + }) + + it('still resolves when git does not echo the marker', async () => { + const executor = executorEmitting('refs/heads/feature\n') + + const result = await loadGitHistoryFromExecutor(executor, '/repo', { baseRef: 'feature' }) + + expect(result.baseRef).toMatchObject({ id: 'refs/heads/feature', category: 'branches' }) + }) + + it('falls back to the commits category for a bare revision', async () => { + const executor = executorEmitting('--end-of-options\n') + + const result = await loadGitHistoryFromExecutor(executor, '/repo', { baseRef: 'some-rev' }) + + expect(result.baseRef).toMatchObject({ category: 'commits' }) + }) +}) diff --git a/src/shared/git-history.ts b/src/shared/git-history.ts index 9b5993fd367..e45142d4cb1 100644 --- a/src/shared/git-history.ts +++ b/src/shared/git-history.ts @@ -76,7 +76,16 @@ async function resolveSymbolicFullName( ['rev-parse', '--symbolic-full-name', '--end-of-options', ref], cwd ) - return stdout.trim().split(/\r?\n/).find(Boolean) ?? null + // Why skip the marker: --verify swallows --end-of-options, but + // --symbolic-full-name deliberately echoes it, so the first line is the marker + // rather than the ref. Taking it made every branch and tag fall through + // gitHistoryRefFromFullName's prefix checks into the "commits" category. + return ( + stdout + .trim() + .split(/\r?\n/) + .find((line) => line && line !== '--end-of-options') ?? null + ) } catch { return null } From 79ec57d045fb395cbe986b39815aedad985f64ca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:40:59 -0700 Subject: [PATCH 014/203] perf(relay): keep the PTY replay window as chunks, not a re-sliced string (#10900) * perf(relay): keep the PTY replay window as chunks, not a re-sliced string appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)` once over the 100KB cap. It runs per raw node-pty emission -- before batching -- so once a PTY saturates the window (which a long-lived shell does almost immediately) every subsequent chunk copied the whole 100KB. Reuse RecentPtyOutputBuffer, which already solved this shape in the main process: keep chunks, drop from the head, defer the join to read(). The relay's three readers are attach, adopt, and revive only. 66-205x on the append path, per PTY, on the user's SSH host. RecentPtyOutputBuffer's limit is now configurable, because the relay retains 100KB where the main process retains 64KB. One arithmetic branch still used the hardcoded constant after that change and silently under-retained (100,800 of 102,400 code units); the equivalence tests caught it before it shipped, and the suite now pins the configured limit directly. Co-authored-by: Orca * test(relay): exercise a real surrogate split; drop eval from the benchmark Review feedback, both valid: - The surrogate test never split a pair. The cap is even and a pair is two code units, so an emoji run alone always cuts on a pair boundary. A trailing single unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) -- asserted directly now, with the boundary-aligned case kept as its own test. - Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval regardless of the eslint suppression. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../scripts/relay-replay-buffer-benchmark.mjs | 218 ++++++++++++++++++ .../runtime/recent-pty-output-buffer.test.ts | 28 +++ src/main/runtime/recent-pty-output-buffer.ts | 20 +- src/relay/pty-handler.ts | 32 +-- .../pty-replay-buffer-equivalence.test.ts | 136 +++++++++++ 5 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 config/scripts/relay-replay-buffer-benchmark.mjs create mode 100644 src/relay/pty-replay-buffer-equivalence.test.ts diff --git a/config/scripts/relay-replay-buffer-benchmark.mjs b/config/scripts/relay-replay-buffer-benchmark.mjs new file mode 100644 index 00000000000..31091ede611 --- /dev/null +++ b/config/scripts/relay-replay-buffer-benchmark.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +// Benchmark: the relay's per-PTY-chunk replay buffer append (src/relay/pty-handler.ts). +// +// appendReplayBuffer did `buffered += data` then, over the cap, `buffered.slice(-CAP)`. +// Once a PTY has produced CAP bytes -- which a long-lived shell does almost immediately +// -- every subsequent chunk flattened and copied the whole 100 KB window. The append is +// called per raw node-pty emission, before batching, so it is per chunk, not per flush. +// +// The fix reuses RecentPtyOutputBuffer: keep chunks, drop from the head, and defer the +// join to read(), which only attach/adopt/revive call. +// +// Both arms are compared for an identical retained tail before timing. +// +// Run with: node config/scripts/relay-replay-buffer-benchmark.mjs +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' + +const ROUNDS = 6 +const SECONDS = Number(process.env.ORCA_REPLAY_BENCH_SECONDS ?? '1') +if (!Number.isFinite(SECONDS) || SECONDS <= 0) { + throw new Error(`ORCA_REPLAY_BENCH_SECONDS must be positive, received ${SECONDS}`) +} + +// Why re-read the sources: the claim is that the relay now appends into a chunk deque +// with the relay's own cap. If either reverts, these numbers stop meaning what they say. +const HANDLER_SOURCE = readFileSync( + new URL('../../src/relay/pty-handler.ts', import.meta.url), + 'utf8' +) +if (!/managed\.buffered\.append\(/.test(HANDLER_SOURCE)) { + throw new Error('relay no longer appends into a chunk deque; this benchmark is stale') +} +const capMatch = HANDLER_SOURCE.match(/REPLAY_BUFFER_MAX = ([\d *]+)/) +if (!capMatch) { + throw new Error('REPLAY_BUFFER_MAX not found; this benchmark is stale') +} +// The regex admits only digits, spaces, and `*`, so the literal is a plain product. +const REPLAY_BUFFER_MAX = capMatch[1] + .split('*') + .map((factor) => Number(factor.trim())) + .reduce((product, factor) => product * factor, 1) +if (!Number.isSafeInteger(REPLAY_BUFFER_MAX) || REPLAY_BUFFER_MAX <= 0) { + throw new Error(`could not read REPLAY_BUFFER_MAX from source, got ${capMatch[1]}`) +} + +// Pre-fix: rolling string, re-sliced once over the cap. +function appendString(state, data) { + if (data.length === 0) { + return state + } + const next = state + data + return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next +} + +// Post-fix: mirrors RecentPtyOutputBuffer's append/read for the relay's options. +class ChunkDeque { + constructor(limit) { + this.chunks = [] + this.headIndex = 0 + this.headOffset = 0 + this.totalLen = 0 + this.limit = limit + } + + append(data) { + if (data.length === 0) { + return + } + if (data.length >= this.limit) { + this.chunks = [data.slice(-this.limit)] + this.headIndex = 0 + this.headOffset = 0 + this.totalLen = this.limit + return + } + this.chunks.push(data) + this.totalLen += data.length + while (this.totalLen > this.limit) { + const headRemaining = this.chunks[this.headIndex].length - this.headOffset + const excess = this.totalLen - this.limit + if (headRemaining <= excess) { + this.chunks[this.headIndex] = '' + this.headIndex += 1 + this.headOffset = 0 + this.totalLen -= headRemaining + } else { + this.headOffset += excess + this.totalLen -= excess + } + } + if (this.headIndex >= 1024) { + this.chunks = this.chunks.slice(this.headIndex) + this.headIndex = 0 + } + } + + read() { + if (this.chunks.length - this.headIndex > 1) { + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + this.headOffset = 0 + } + this.chunks = [retained.join('')] + this.headIndex = 0 + } else if (this.headOffset > 0) { + this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset) + this.headOffset = 0 + } + return this.chunks[this.headIndex] ?? '' + } +} + +function makeChunks(chunkBytes, chunkCount) { + // Vary content so V8 cannot dedupe or treat the appends as loop-invariant. + return Array.from({ length: chunkCount }, (_value, index) => + `${index}:`.padEnd(chunkBytes, 'abcdefghijklmnopqrstuvwxyz') + ) +} + +// Why pre-saturate: the interesting regime is a PTY that has already filled the window, +// which is where the old form copied 100 KB on literally every chunk. Timing from empty +// would average in a cheap warm-up the real process leaves behind in milliseconds. +function saturate(chunks) { + let stringState = '' + const deque = new ChunkDeque(REPLAY_BUFFER_MAX) + const preload = 'p'.repeat(REPLAY_BUFFER_MAX) + stringState = appendString(stringState, preload) + deque.append(preload) + return { stringState, deque, chunks } +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const mid = sorted.length / 2 + return (sorted[mid - 1] + sorted[mid]) / 2 +} + +function timeString(chunks) { + let state = 'p'.repeat(REPLAY_BUFFER_MAX) + const start = performance.now() + for (const chunk of chunks) { + state = appendString(state, chunk) + } + const elapsed = performance.now() - start + if (state.length !== REPLAY_BUFFER_MAX) { + throw new Error('string arm lost its window') + } + return elapsed +} + +function timeDeque(chunks) { + const deque = new ChunkDeque(REPLAY_BUFFER_MAX) + deque.append('p'.repeat(REPLAY_BUFFER_MAX)) + const start = performance.now() + for (const chunk of chunks) { + deque.append(chunk) + } + const elapsed = performance.now() - start + return elapsed +} + +// Arms alternate which one leads so within-round drift cannot favour either. +function measure(chunks) { + timeString(chunks) + timeDeque(chunks) + const stringSamples = [] + const dequeSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + stringSamples.push(timeString(chunks)) + dequeSamples.push(timeDeque(chunks)) + } else { + dequeSamples.push(timeDeque(chunks)) + stringSamples.push(timeString(chunks)) + } + } + return { stringMs: median(stringSamples), dequeMs: median(dequeSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Relay PTY replay-buffer append, per second of output. Lower is better.') +console.log( + `cap=${(REPLAY_BUFFER_MAX / 1024).toFixed(0)} KiB rounds=${ROUNDS} (per-arm medians, pre-saturated)` +) +console.log( + `${pad('workload', 30)} ${pad('rolling str', 12)} ${pad('chunk deque', 12)} ${pad('speedup', 9)}` +) + +for (const [label, chunkBytes, chunksPerSecond] of [ + ['interactive shell 64B x200', 64, 200], + ['agent TUI 512B x400', 512, 400], + ['build log 4KiB x256 (1 MiB/s)', 4 * 1024, 256], + ['dump 8KiB x512 (4 MiB/s)', 8 * 1024, 512], + ['firehose 16KiB x1024 (16 MiB/s)', 16 * 1024, 1024] +]) { + const chunks = makeChunks(chunkBytes, Math.round(chunksPerSecond * SECONDS)) + const { stringState, deque } = saturate(chunks) + let stringTail = stringState + for (const chunk of chunks) { + stringTail = appendString(stringTail, chunk) + deque.append(chunk) + } + if (deque.read() !== stringTail) { + throw new Error(`retained tail differs for ${label}`) + } + if (stringTail.length !== REPLAY_BUFFER_MAX) { + throw new Error(`fixture never saturated the window for ${label}`) + } + const { stringMs, dequeMs } = measure(chunks) + console.log( + `${pad(label, 30)} ${pad(`${stringMs.toFixed(3)} ms`, 12)} ${pad(`${dequeMs.toFixed(3)} ms`, 12)} ${pad(`${(stringMs / dequeMs).toFixed(0)}x`, 9)}` + ) +} + +console.log( + "\nThis is per PTY, and the relay runs on the user's SSH host. Reads (attach, adopt,\nrevive) now pay the join instead, but those are rare and were already O(window)." +) diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 6c21284e163..42d9bfe30f3 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -259,3 +259,31 @@ describe('RecentPtyOutputBuffer', () => { } }) }) + +describe('configurable limit', () => { + // Why: a hardcoded RECENT_PTY_OUTPUT_LIMIT left in one arithmetic branch silently + // under-retained for any caller passing a different limit (the relay passes 100KB). + it('retains exactly the configured limit across many chunks', () => { + const limit = 1000 + const buffer = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) + let reference = '' + for (let index = 0; index < 60; index += 1) { + const chunk = `c${index}-`.repeat(9) + buffer.append(chunk) + reference = (reference + chunk).slice(-limit) + } + expect(buffer.read().length).toBe(limit) + expect(buffer.read()).toBe(reference) + }) + + it('rejects a non-positive limit', () => { + expect(() => new RecentPtyOutputBuffer({ limit: 0 })).toThrow('positive integer') + expect(() => new RecentPtyOutputBuffer({ limit: -1 })).toThrow('positive integer') + }) + + it('defaults to RECENT_PTY_OUTPUT_LIMIT', () => { + const buffer = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false }) + buffer.append('z'.repeat(RECENT_PTY_OUTPUT_LIMIT * 2)) + expect(buffer.read().length).toBe(RECENT_PTY_OUTPUT_LIMIT) + }) +}) diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index a10559b75c9..dda01e0afdf 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -26,28 +26,34 @@ export class RecentPtyOutputBuffer { // Original chunk boundaries are owed only to the one-time path-candidate // backfill; compact() ends that obligation and lets read() collapse. private preserveChunkBoundaries: boolean + // Why configurable: the relay retains a different window than the main process. + private readonly limit: number - constructor(options?: { preserveChunkBoundaries?: boolean }) { + constructor(options?: { preserveChunkBoundaries?: boolean; limit?: number }) { this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true + this.limit = options?.limit ?? RECENT_PTY_OUTPUT_LIMIT + if (!Number.isSafeInteger(this.limit) || this.limit <= 0) { + throw new Error(`RecentPtyOutputBuffer limit must be a positive integer, got ${this.limit}`) + } } append(data: string): void { if (data.length === 0) { return } - if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { - this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] + if (data.length >= this.limit) { + this.chunks = [data.slice(-this.limit)] this.headIndex = 0 this.headOffset = 0 - this.totalLen = RECENT_PTY_OUTPUT_LIMIT - this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT + this.totalLen = this.limit + this.headChunkIsPartial = data.length > this.limit return } this.chunks.push(data) this.totalLen += data.length - while (this.totalLen > RECENT_PTY_OUTPUT_LIMIT) { + while (this.totalLen > this.limit) { const headRemaining = this.chunks[this.headIndex].length - this.headOffset - const excess = this.totalLen - RECENT_PTY_OUTPUT_LIMIT + const excess = this.totalLen - this.limit if (headRemaining <= excess) { // Release the dropped chunk's reference; the slot is reclaimed on compaction. this.chunks[this.headIndex] = '' diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 606d18e6e18..578858d16b8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -48,6 +48,7 @@ import { type PtyIngressEmission } from '../shared/pty-startup-ingress' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' +import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' import { agentSessionOwnerBindingsEqual, ClaimedAgentPtyOwnerRegistry @@ -83,7 +84,9 @@ type ManagedPty = { incarnationId: string pty: IPty initialCwd: string - buffered: string + /** Why a chunk deque: rebuilding a rolling 100KB string per PTY chunk copied the + * whole window on every write once saturated. Readers are attach/adopt/revive only. */ + buffered: RecentPtyOutputBuffer /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */ killTimer?: ReturnType /** True once disposeManagedPty has run; blocks double-dispose and makes post-dispose calls fail "not found" not silently. */ @@ -484,10 +487,7 @@ export class PtyHandler { if (data.length === 0) { return } - managed.buffered += data - if (managed.buffered.length > REPLAY_BUFFER_MAX) { - managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) - } + managed.buffered.append(data) } private releaseStartupCommand(managed: ManagedPty): void { @@ -980,13 +980,12 @@ export class PtyHandler { throw new Error('agent_session_exited_during_start') } managed.agentSessionOwners = this.agentSessionOwners.listForPty(managed.id) + const adoptedReplay = result.disposition === 'adopted' ? managed.buffered.read() : '' return { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, - ...(result.disposition === 'adopted' && managed.buffered - ? { replay: managed.buffered } - : {}) + ...(adoptedReplay ? { replay: adoptedReplay } : {}) } } catch (error) { if (!physicalSpawnCommitted) { @@ -1111,7 +1110,10 @@ export class PtyHandler { incarnationId: randomUUID(), pty: term, initialCwd: cwd, - buffered: '', + buffered: new RecentPtyOutputBuffer({ + preserveChunkBoundaries: false, + limit: REPLAY_BUFFER_MAX + }), paneKey, tabId, ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), @@ -1198,14 +1200,15 @@ 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. - if (managed.buffered) { + const replay = managed.buffered.read() + if (replay) { // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. this.pendingOutputByPty.delete(id) this.clearOutputFlushTimerIfIdle() if (params.suppressReplayNotification) { - return { incarnationId: managed.incarnationId, replay: managed.buffered } + return { incarnationId: managed.incarnationId, replay } } - this.dispatcher.notify('pty.replay', { id, data: managed.buffered }) + this.dispatcher.notify('pty.replay', { id, data: replay }) } return { incarnationId: managed.incarnationId } } @@ -1552,7 +1555,10 @@ export class PtyHandler { incarnationId: randomUUID(), pty: term, initialCwd: entry.cwd, - buffered: '', + buffered: new RecentPtyOutputBuffer({ + preserveChunkBoundaries: false, + limit: REPLAY_BUFFER_MAX + }), paneKey: entry.paneKey, tabId: entry.tabId, attachIdentity: entry.attachIdentity, diff --git a/src/relay/pty-replay-buffer-equivalence.test.ts b/src/relay/pty-replay-buffer-equivalence.test.ts new file mode 100644 index 00000000000..57b35ab8480 --- /dev/null +++ b/src/relay/pty-replay-buffer-equivalence.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' +import { REPLAY_BUFFER_MAX } from './pty-handler' + +// Reference: the pre-change replay buffer, a rolling string sliced per append. +function appendStringTail(previous: string, data: string): string { + if (data.length === 0) { + return previous + } + const next = previous + data + return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next +} + +function makeBuffer(): RecentPtyOutputBuffer { + return new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit: REPLAY_BUFFER_MAX }) +} + +function replayEquals(chunks: string[]): { deque: string; reference: string } { + const buffer = makeBuffer() + let reference = '' + for (const chunk of chunks) { + buffer.append(chunk) + reference = appendStringTail(reference, chunk) + } + return { deque: buffer.read(), reference } +} + +// Deterministic PRNG so a failure is reproducible. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state * 1664525 + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +describe('relay replay buffer equivalence', () => { + it('matches the rolling-string tail below the cap', () => { + const { deque, reference } = replayEquals(['hello ', 'world', '\r\n$ ']) + expect(deque).toBe(reference) + expect(deque).toBe('hello world\r\n$ ') + }) + + it('matches once the window saturates', () => { + const chunks = Array.from({ length: 40 }, (_value, index) => `chunk-${index}-`.repeat(400)) + const { deque, reference } = replayEquals(chunks) + expect(deque.length).toBe(REPLAY_BUFFER_MAX) + expect(deque).toBe(reference) + }) + + it('matches when one append alone exceeds the cap', () => { + const { deque, reference } = replayEquals(['x'.repeat(REPLAY_BUFFER_MAX * 2 + 7)]) + expect(deque.length).toBe(REPLAY_BUFFER_MAX) + expect(deque).toBe(reference) + }) + + it('matches when an oversized append follows existing content', () => { + const { deque, reference } = replayEquals(['prefix', 'y'.repeat(REPLAY_BUFFER_MAX + 3)]) + expect(deque).toBe(reference) + expect(deque.startsWith('prefix')).toBe(false) + }) + + it('matches at exactly the cap boundary', () => { + for (const total of [REPLAY_BUFFER_MAX - 1, REPLAY_BUFFER_MAX, REPLAY_BUFFER_MAX + 1]) { + const { deque, reference } = replayEquals(['a'.repeat(total)]) + expect(deque).toBe(reference) + } + }) + + it('ignores empty appends exactly as the string form did', () => { + const { deque, reference } = replayEquals(['a', '', 'b', '', 'c']) + expect(deque).toBe(reference) + expect(deque).toBe('abc') + }) + + it('is stable across repeated reads', () => { + const buffer = makeBuffer() + for (let index = 0; index < 200; index += 1) { + buffer.append(`line ${index}\r\n`.repeat(30)) + } + expect(buffer.read()).toBe(buffer.read()) + }) + + it('keeps appending correctly after a read collapses the deque', () => { + const buffer = makeBuffer() + let reference = '' + for (let index = 0; index < 60; index += 1) { + const chunk = `mid-${index}-`.repeat(300) + buffer.append(chunk) + reference = appendStringTail(reference, chunk) + // Interleave reads: attach/adopt/revive can land at any point in the stream. + if (index % 7 === 0) { + expect(buffer.read()).toBe(reference) + } + } + expect(buffer.read()).toBe(reference) + }) + + // Why fuzz: the deque trims across chunk boundaries with a deferred head offset, + // so the risky cases are irregular chunk sizes straddling the cap repeatedly. + it('matches the rolling string across randomized chunk streams', () => { + for (let seed = 1; seed <= 40; seed += 1) { + const random = makeRandom(seed) + const buffer = makeBuffer() + let reference = '' + for (let step = 0; step < 120; step += 1) { + const size = Math.floor(random() * (REPLAY_BUFFER_MAX / 8)) + const chunk = String.fromCharCode(97 + (step % 26)).repeat(size) + buffer.append(chunk) + reference = appendStringTail(reference, chunk) + } + expect(buffer.read(), `seed ${seed}`).toBe(reference) + } + }) + + // Why surrogates: trimming by code unit can split a pair, and the string form did + // exactly the same thing. The deque must not "fix" it into a different tail. + it('splits surrogate pairs identically to the rolling string', () => { + // Why the trailing unit: the cap is even and a pair is 2 units, so an emoji run + // alone always cuts on a pair boundary. One odd unit shifts the cut mid-pair. + const { deque, reference } = replayEquals([`${'\u{1F600}'.repeat(REPLAY_BUFFER_MAX)}z`]) + expect(deque).toBe(reference) + expect(deque.length).toBe(REPLAY_BUFFER_MAX) + const leadUnit = deque.charCodeAt(0) + expect(leadUnit).toBeGreaterThanOrEqual(0xdc00) + expect(leadUnit).toBeLessThanOrEqual(0xdfff) + }) + + it('keeps a pair-aligned tail intact when the cut lands on a boundary', () => { + const { deque, reference } = replayEquals(['\u{1F600}'.repeat(REPLAY_BUFFER_MAX)]) + expect(deque).toBe(reference) + const leadUnit = deque.charCodeAt(0) + expect(leadUnit).toBeGreaterThanOrEqual(0xd800) + expect(leadUnit).toBeLessThanOrEqual(0xdbff) + }) +}) From 974447175f6628e212b3323ea92fe5c56f812df7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:00:50 -0700 Subject: [PATCH 015/203] feat(terminal): add manual parking developer action (#11016) --- src/renderer/src/components/Terminal.tsx | 28 ++++-- .../sidebar/WorktreeContextMenu.tsx | 7 ++ .../sidebar/WorktreeDeveloperMenu.test.tsx | 58 +++++++++++ .../sidebar/WorktreeDeveloperMenu.tsx | 32 ++++++ .../terminal-hidden-view-parking.test.ts | 31 ++++++ .../terminal-hidden-view-parking.ts | 20 ++++ ...e-manual-terminal-worktree-parking.test.ts | 16 +++ .../use-manual-terminal-worktree-parking.ts | 99 +++++++++++++++++++ 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 ++ .../manual-terminal-worktree-parking.test.ts | 42 ++++++++ .../lib/manual-terminal-worktree-parking.ts | 29 ++++++ 15 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx create mode 100644 src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts create mode 100644 src/renderer/src/lib/manual-terminal-worktree-parking.test.ts create mode 100644 src/renderer/src/lib/manual-terminal-worktree-parking.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 0c2982043ff..e730d4328ff 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -137,6 +137,10 @@ import { translate } from '@/i18n/i18n' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { + combineTerminalWorktreeParkIds, + useManualTerminalWorktreeParking +} from './terminal-pane/use-manual-terminal-worktree-parking' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -738,6 +742,15 @@ function Terminal(): React.JSX.Element | null { const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( () => new Set() ) + const manuallyParkedTerminalWorktreeIds = useManualTerminalWorktreeParking({ + activeView, + renderedActiveWorktreeId + }) + const effectiveParkedTerminalWorktreeIds = useMemo( + () => + combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds), + [manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds] + ) // Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) // Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab. @@ -1055,9 +1068,9 @@ function Terminal(): React.JSX.Element | null { const coldWorktreePresentationTargets = useMemo( () => activeView === 'terminal' && activeTabType === 'terminal' - ? parkedTerminalWorktreeIds + ? effectiveParkedTerminalWorktreeIds : new Set(), - [activeTabType, activeView, parkedTerminalWorktreeIds] + [activeTabType, activeView, effectiveParkedTerminalWorktreeIds] ) const { presentationByScope: worktreePresentationByScope, @@ -1088,7 +1101,7 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const parked = - !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id) + !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) if (parked) { for (const tab of tabs) { const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { @@ -1135,7 +1148,7 @@ function Terminal(): React.JSX.Element | null { backgroundMountRevision, getEffectiveLayoutForWorktree, groupsByWorktree, - parkedTerminalWorktreeIds, + effectiveParkedTerminalWorktreeIds, pendingStartupByTabId, renderedActiveWorktreeId, tabsByWorktree, @@ -2107,9 +2120,7 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = - !isVisible && - !shouldMeasureHiddenWorktree && - parkedTerminalWorktreeIds.has(workspace.id) + !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) return ( ) : null} + {!isMultiContext ? ( + <> + + + + ) : null} void +} + +const menuItems = vi.hoisted(() => ({ values: [] as MenuItemProps[] })) +const requestManualPark = vi.hoisted(() => vi.fn()) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React_ = await import('react') + const passthrough = ({ children }: { children?: ReactNode }) => + React_.createElement(React_.Fragment, null, children) + return { + DropdownMenuSub: passthrough, + DropdownMenuSubContent: passthrough, + DropdownMenuSubTrigger: passthrough, + DropdownMenuItem: (props: MenuItemProps) => { + menuItems.values.push(props) + return React_.createElement(React_.Fragment, null, props.children) + } + } +}) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/manual-terminal-worktree-parking', () => ({ + requestManualTerminalWorktreePark: requestManualPark +})) + +describe('WorktreeDeveloperMenu', () => { + beforeEach(() => { + menuItems.values = [] + requestManualPark.mockReset() + }) + + it('shows the developer submenu with the parking action', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Developer') + expect(markup).toContain('Park terminal') + expect(markup).toContain('lucide-code-xml') + expect(markup).toContain('lucide-square-parking') + }) + + it('requests parking for the context worktree', () => { + renderToStaticMarkup() + + menuItems.values[0]?.onSelect?.() + + expect(requestManualPark).toHaveBeenCalledWith('worktree-1') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx new file mode 100644 index 00000000000..a0cb257c105 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx @@ -0,0 +1,32 @@ +import { + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { CodeXml, SquareParking } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { requestManualTerminalWorktreePark } from '@/lib/manual-terminal-worktree-parking' + +export function WorktreeDeveloperMenu({ + worktreeId, + disabled +}: { + worktreeId: string + disabled: boolean +}): React.JSX.Element { + return ( + + + + {translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')} + + + requestManualTerminalWorktreePark(worktreeId)}> + + {translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')} + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index 5c8026b74d0..cb1c698fc15 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -7,6 +7,7 @@ import { TERMINAL_TAB_HOT_RETAIN_MS, TERMINAL_WORKTREE_HOT_RETAIN_MS, TERMINAL_WORKTREE_PARK_DELAY_MS, + canManuallyParkTerminalWorktreeRenderers, canParkTerminalTabRenderer, canParkTerminalWorktreeRenderers, getTerminalTabColdParkRecheckDelayMs, @@ -181,6 +182,36 @@ describe('canParkTerminalTabRenderer', () => { }) }) +describe('canManuallyParkTerminalWorktreeRenderers', () => { + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true + } + + it('bypasses visibility timing for snapshot-backed local terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + it('preserves safety gates for empty, pending, remote, and disabled terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] } + }) + ).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }] + }) + ).toBe(false) + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + }) +}) + describe('selectColdParkedTerminalWorktrees', () => { const nowMs = 500_000 diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index f4b434c948b..4701ff727a3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -107,6 +107,26 @@ export function canParkTerminalWorktreeRenderers(args: { }) } +export function canManuallyParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean +}): boolean { + return ( + args.terminalTabs.length > 0 && + canParkTerminalWorktreeRenderers({ + ...args, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs: 0, + nowMs: 0, + coldParkDelayMs: 0 + }) + ) +} + export function canParkTerminalTabRenderer(args: { worktreeId: string terminalTab: TerminalTabColdParkCandidate diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts new file mode 100644 index 00000000000..d3d9b44d544 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { combineTerminalWorktreeParkIds } from './use-manual-terminal-worktree-parking' + +describe('combineTerminalWorktreeParkIds', () => { + it('adds manually parked worktrees without dropping automatic parks', () => { + expect(combineTerminalWorktreeParkIds(new Set(['automatic']), new Set(['manual']))).toEqual( + new Set(['automatic', 'manual']) + ) + }) + + it('keeps the automatic set reference when there are no manual parks', () => { + const automatic = new Set(['automatic']) + + expect(combineTerminalWorktreeParkIds(automatic, new Set())).toBe(automatic) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts new file mode 100644 index 00000000000..2d1ed09afce --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { + MANUAL_TERMINAL_WORKTREE_PARK_EVENT, + takeAllPendingManualTerminalWorktreeParks, + takePendingManualTerminalWorktreePark, + type ManualTerminalWorktreeParkDetail +} from '@/lib/manual-terminal-worktree-parking' +import { canManuallyParkTerminalWorktreeRenderers } from './terminal-hidden-view-parking' +import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' + +function addWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { + if (current.has(worktreeId)) { + return current + } + return new Set([...current, worktreeId]) +} + +function removeWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { + if (!current.has(worktreeId)) { + return current + } + const next = new Set(current) + next.delete(worktreeId) + return next +} + +export function combineTerminalWorktreeParkIds( + automaticIds: ReadonlySet, + manualIds: ReadonlySet +): ReadonlySet { + if (manualIds.size === 0) { + return automaticIds + } + return new Set([...automaticIds, ...manualIds]) +} + +export function useManualTerminalWorktreeParking(args: { + activeView: string + renderedActiveWorktreeId: string | null +}): ReadonlySet { + const [manuallyParkedWorktreeIds, setManuallyParkedWorktreeIds] = useState>( + () => new Set() + ) + + const parkWorktree = useCallback((worktreeId: string) => { + const state = useAppStore.getState() + const terminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const canPark = + canManuallyParkTerminalWorktreeRenderers({ + worktreeId, + terminalTabs, + pendingStartupByTabId: state.pendingStartupByTabId, + parkingEnabled: state.settings?.terminalHiddenViewParking !== false + }) && terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab)) + if (!canPark) { + toast.warning( + translate( + 'auto.components.terminalPane.useManualTerminalWorktreeParking.cannotPark', + 'These terminals cannot be parked safely.' + ) + ) + return + } + setManuallyParkedWorktreeIds((current) => addWorktreeId(current, worktreeId)) + }, []) + + useEffect(() => { + const handleParkRequest = (event: Event): void => { + const worktreeId = (event as CustomEvent).detail?.worktreeId + if (!worktreeId) { + return + } + takePendingManualTerminalWorktreePark(worktreeId) + parkWorktree(worktreeId) + } + window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, handleParkRequest as EventListener) + for (const worktreeId of takeAllPendingManualTerminalWorktreeParks()) { + parkWorktree(worktreeId) + } + return () => + window.removeEventListener( + MANUAL_TERMINAL_WORKTREE_PARK_EVENT, + handleParkRequest as EventListener + ) + }, [parkWorktree]) + + useEffect(() => { + const revealedWorktreeId = args.renderedActiveWorktreeId + if (args.activeView !== 'terminal' || !revealedWorktreeId) { + return + } + setManuallyParkedWorktreeIds((current) => removeWorktreeId(current, revealedWorktreeId)) + }, [args.activeView, args.renderedActiveWorktreeId]) + + return manuallyParkedWorktreeIds +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 01a53e11044..ff9f7702a09 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4939,6 +4939,10 @@ "close": "Close", "image": "Image", "expand": "Expand image" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13963,6 +13967,11 @@ "undo": "Undo", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index df89084b3bc..a9c4997a5d5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4916,6 +4916,10 @@ "close": "Cerrar", "image": "Imagen", "expand": "Ampliar imagen" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "Deshacer", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 62fd5330a68..45a9e137069 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4916,6 +4916,10 @@ "close": "閉じる", "image": "画像", "expand": "画像を拡大" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "元に戻す", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index df331ec2e3c..1ba309520a2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4916,6 +4916,10 @@ "close": "닫기", "image": "이미지", "expand": "이미지 확대" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "실행 취소", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a6075fd7fb2..3d1a8f12598 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4916,6 +4916,10 @@ "close": "关闭", "image": "图像", "expand": "展开图像" + }, + "WorktreeDeveloperMenu": { + "developer": "Developer", + "parkTerminal": "Park terminal" } }, "shared": { @@ -13940,6 +13944,11 @@ "undo": "撤销", "widthOption": "{{value0}} px" } + }, + "terminalPane": { + "useManualTerminalWorktreeParking": { + "cannotPark": "These terminals cannot be parked safely." + } } }, "i18n": { diff --git a/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts b/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts new file mode 100644 index 00000000000..e70ca180b01 --- /dev/null +++ b/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MANUAL_TERMINAL_WORKTREE_PARK_EVENT, + requestManualTerminalWorktreePark, + takeAllPendingManualTerminalWorktreeParks, + takePendingManualTerminalWorktreePark, + type ManualTerminalWorktreeParkDetail +} from './manual-terminal-worktree-parking' + +describe('manual terminal worktree parking requests', () => { + beforeEach(() => { + takeAllPendingManualTerminalWorktreeParks() + }) + + it('dispatches the target worktree and records it for a late Terminal mount', () => { + const listener = vi.fn((event: Event) => { + expect((event as CustomEvent).detail).toEqual({ + worktreeId: 'worktree-1' + }) + }) + window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + + requestManualTerminalWorktreePark('worktree-1') + + expect(listener).toHaveBeenCalledOnce() + expect(takePendingManualTerminalWorktreePark('worktree-1')).toBe(true) + window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + }) + + it('ignores an empty worktree id', () => { + const listener = vi.fn() + window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + + requestManualTerminalWorktreePark('') + + expect(listener).not.toHaveBeenCalled() + expect(takeAllPendingManualTerminalWorktreeParks()).toEqual([]) + window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener) + }) +}) diff --git a/src/renderer/src/lib/manual-terminal-worktree-parking.ts b/src/renderer/src/lib/manual-terminal-worktree-parking.ts new file mode 100644 index 00000000000..19113da5767 --- /dev/null +++ b/src/renderer/src/lib/manual-terminal-worktree-parking.ts @@ -0,0 +1,29 @@ +export const MANUAL_TERMINAL_WORKTREE_PARK_EVENT = 'orca-manual-terminal-worktree-park' + +export type ManualTerminalWorktreeParkDetail = { + worktreeId: string +} + +const pendingWorktreeIds = new Set() + +export function requestManualTerminalWorktreePark(worktreeId: string): void { + if (!worktreeId) { + return + } + pendingWorktreeIds.add(worktreeId) + window.dispatchEvent( + new CustomEvent(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, { + detail: { worktreeId } + }) + ) +} + +export function takePendingManualTerminalWorktreePark(worktreeId: string): boolean { + return pendingWorktreeIds.delete(worktreeId) +} + +export function takeAllPendingManualTerminalWorktreeParks(): string[] { + const pending = [...pendingWorktreeIds] + pendingWorktreeIds.clear() + return pending +} From 1fd0f731fc0dd4f3fca25dfbd7768c107ba892c8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:03:58 -0700 Subject: [PATCH 016/203] fix(automations): bind agent terminal output before publishing, and launch SSH folder workspaces on their own host (#10818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): bind the agent PTY before the run tab is ever published Why: launchAgentBackgroundSession created the hidden run tab synchronously and only then awaited the agent spawn, so the store briefly held a tab with ptyId: null. Terminal.tsx re-renders on that write, and for an already-visited worktree the tab can neither cold-park nor defer — a TerminalPane mounts, finds no adopt candidate, and starts a fresh default shell. When the agent PTY finally resolves it is rebound in state but the mounted pane still holds the shell, so the user sees a bare prompt and the agent PTY is orphaned (#2989). Reserve the tab id before the spawn and create the tab already bound to the live PTY, with no await in between. * fix(terminal): resolve folder workspaces, and fail closed on a tab-id collision allWorktrees() reads only worktreesByRepo, so every folder workspace looked absent and its automation died at resolution. getKnownWorktreeById covers both. On a reserved-id collision, re-keying the tab could never work: ORCA_TAB_ID and ORCA_PANE_KEY are already baked into the spawned process, so routing and hook identity would permanently disagree. Retire the launch instead. Co-authored-by: Orca * test(automations): split the background-session suite so it stays under the 800-line cap Co-authored-by: Orca * fix(automations): route folder-workspace agent launches to their owning SSH host Review of the bind-before-publish fix surfaced a second defect on the path it newly makes reachable. A folder workspace has no repo row — its synthetic repoId is `folder-workspace:` — so `repos.find(...)` returns null and every repo-derived launch input silently degraded to a local default: connectionId null, platform CLIENT_PLATFORM, isRemote false. The automation then spawned on the user's machine with a cwd that only exists on the SSH host. Before this branch that path threw before reaching the spawn, so the bug was latent; making folder-workspace automations work is what exposes it. Host resolution now goes through resolveAgentBackgroundLaunchHost, which falls back to the workspace scope (the same getFolderWorkspaceConnectionId that ordinary terminal creation uses) when there is no repo. Ambiguous scopes — mixed local/remote children — still resolve to a local launch rather than guessing a host. Extracted to its own module rather than inlined: the added branch pushed launch-agent-background-session.ts over the 300-line oxlint cap, and a max-lines disable is forbidden. Tests: an SSH folder workspace must spawn with its connectionId and remote cwd; a local one must stay local. Reverting the fallback fails the first and leaves the rest green. * fix(automations): close folder dispatch and adoption races Read live state before adopting a reserved tab so a collision that lands during the spawn is retired instead of re-keyed. Route persisted folder-workspace dispatch through its ambiguity-aware owner, including SSH auth, remote trust, quiet-shell fallback, and WSL shell selection. --------- Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 47 ++ .../hooks/useAutomationDispatchEvents.test.ts | 179 +++++- .../src/hooks/useAutomationDispatchEvents.ts | 80 ++- .../lib/adopt-agent-background-session-tab.ts | 113 ++++ ...ent-background-session-launch-host.test.ts | 85 +++ .../agent-background-session-launch-host.ts | 71 +++ .../agent-background-session-test-state.ts | 33 +- ...ch-agent-background-session-remote.test.ts | 511 ++++++++++++++++++ .../launch-agent-background-session.test.ts | 500 +++++------------ .../lib/launch-agent-background-session.ts | 156 +++--- .../launch-worktree-background-terminals.ts | 4 +- .../lib/retire-unowned-background-terminal.ts | 15 +- .../lib/ssh-background-startup-delivery.ts | 24 +- 13 files changed, 1329 insertions(+), 489 deletions(-) create mode 100644 src/renderer/src/lib/adopt-agent-background-session-tab.ts create mode 100644 src/renderer/src/lib/agent-background-session-launch-host.test.ts create mode 100644 src/renderer/src/lib/agent-background-session-launch-host.ts create mode 100644 src/renderer/src/lib/launch-agent-background-session-remote.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6011813f236..ec51ba00102 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -6389,6 +6389,53 @@ describe('connectPanePty', () => { expect(deps.updateTabPtyId).not.toHaveBeenCalledWith('tab-1', otherTabPtyId) }) + it('fresh-spawns a shell into any PTY-less tab, so agent launches must never publish one', async () => { + // Why: #2989 depends on PTY-less tabs taking this legitimate fresh-shell path. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + return { id: opts.sessionId } + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('stray-shell-pty') + return 'stray-shell-pty' + }) + transportFactoryQueue.push(transport) + // Reproduce the pre-fix gap between createTab and PTY binding. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null + } + }, + agentLaunchConfigByPaneKey: { + [`tab-1:${LEAF_1}`]: { + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' } + } + } + } as StoreState + const deps = createDeps() + + const pane = createPane(1) + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + // Launch registration alone cannot identify a PTY to attach. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ url: '', cols: expect.any(Number) }) + ) + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'stray-shell-pty') + }) + it('spawns a fresh PTY when a restored daemon split session cannot reattach', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 562bbbbf25a..ae8f70d05dd 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -12,6 +12,9 @@ const mockOnDispatchRequested = vi.fn() const mockRendererReady = vi.fn() const mockFinalizeTerminalOwnership = vi.fn() const mockReleaseTerminalOwnership = vi.fn() +const mockSshNeedsPassphrasePrompt = vi.fn() +const mockSshGetState = vi.fn() +const mockSshConnect = vi.fn() const setupLaunch = { runnerScriptPath: '/tmp/setup.sh', @@ -25,15 +28,35 @@ const createdWorktree = { path: '/repo/worktree' } type TestWorktree = typeof createdWorktree +type TestRepo = { + id: string + connectionId: string | null + executionHostId: string | null + path: string +} const state = { activeView: 'terminal' as const, activeWorktreeId: 'wt-active', activeTabId: 'tab-active', activeTabType: 'terminal' as const, - repos: [{ id: 'repo-1', connectionId: null }], + repos: [{ id: 'repo-1', connectionId: null, executionHostId: null, path: '/repo' }] as TestRepo[], + folderWorkspaces: [] as { + id: string + projectGroupId: string + folderPath: string + connectionId: string | null + }[], + projectGroups: [] as { + id: string + connectionId: string | null + executionHostId?: string | null + }[], + worktreesByRepo: {} as Record, + detectedWorktreesByRepo: {}, agentStatusByPaneKey: {}, allWorktrees: vi.fn<() => TestWorktree[]>(() => []), + getKnownWorktreeById: vi.fn<(worktreeId: string) => TestWorktree | undefined>(() => undefined), createWorktree: mockCreateWorktree, subscribe: vi.fn(() => () => {}), setActiveView: vi.fn(), @@ -146,9 +169,13 @@ describe('useAutomationDispatchEvents setup launch', () => { state.activeWorktreeId = 'wt-active' state.activeTabId = 'tab-active' state.activeTabType = 'terminal' - state.repos = [{ id: 'repo-1', connectionId: null }] + state.repos = [{ id: 'repo-1', connectionId: null, executionHostId: null, path: '/repo' }] + state.folderWorkspaces = [] + state.projectGroups = [] + state.worktreesByRepo = {} state.agentStatusByPaneKey = {} state.allWorktrees.mockReturnValue([]) + state.getKnownWorktreeById.mockReturnValue(undefined) mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch }) mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined) mockLaunchAgentBackgroundSession.mockResolvedValue({ @@ -162,6 +189,9 @@ describe('useAutomationDispatchEvents setup launch', () => { } }) mockOnDispatchRequested.mockReturnValue(() => {}) + mockSshNeedsPassphrasePrompt.mockResolvedValue(false) + mockSshGetState.mockResolvedValue({ status: 'connected' }) + mockSshConnect.mockResolvedValue({ status: 'connected' }) vi.stubGlobal('window', { api: { automations: { @@ -172,9 +202,9 @@ describe('useAutomationDispatchEvents setup launch', () => { listRuns: vi.fn().mockResolvedValue([]) }, ssh: { - needsPassphrasePrompt: vi.fn().mockResolvedValue(false), - getState: vi.fn().mockResolvedValue({ status: 'connected' }), - connect: vi.fn() + needsPassphrasePrompt: mockSshNeedsPassphrasePrompt, + getState: mockSshGetState, + connect: mockSshConnect } }, dispatchEvent: vi.fn() @@ -319,6 +349,145 @@ describe('useAutomationDispatchEvents setup launch', () => { ) }) + it('dispatches an existing SSH folder workspace on its resolved host', async () => { + const folderWorkspace = { + id: 'folder:fw-1', + repoId: 'folder-workspace:group-1', + displayName: 'SSH folder', + path: '/srv/project' + } + state.repos = [ + { + id: 'repo-1', + connectionId: 'ssh-folder', + executionHostId: null, + path: '/srv/project/repo' + } + ] + state.folderWorkspaces = [ + { + id: 'fw-1', + projectGroupId: 'group-1', + folderPath: '/srv/project', + connectionId: 'ssh-folder' + } + ] + state.projectGroups = [{ id: 'group-1', connectionId: 'ssh-folder' }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + mockSshGetState.mockResolvedValue({ status: 'disconnected' }) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + setupDecision: 'skip', + runContext: { repoId: 'repo-1', hostId: 'ssh:ssh-folder' } + }) + ) + + expect(state.allWorktrees).not.toHaveBeenCalled() + expect(mockSshConnect).toHaveBeenCalledWith({ targetId: 'ssh-folder' }) + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: folderWorkspace.id, + prompt: 'run this' + }) + ) + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'dispatched', + workspaceId: folderWorkspace.id, + workspaceDisplayName: folderWorkspace.displayName + }) + ) + }) + + it('dispatches a local folder workspace without SSH', async () => { + const folderWorkspace = { + id: 'folder:fw-local', + repoId: 'folder-workspace:group-local', + displayName: 'Local folder', + path: '/project' + } + state.folderWorkspaces = [ + { + id: 'fw-local', + projectGroupId: 'group-local', + folderPath: '/project', + connectionId: null + } + ] + state.projectGroups = [{ id: 'group-local', connectionId: null }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + runContext: { repoId: 'repo-1', hostId: 'local' } + }) + ) + + expect(mockSshNeedsPassphrasePrompt).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: folderWorkspace.id }) + ) + }) + + it('skips a folder workspace owned by a different host', async () => { + const folderWorkspace = { + id: 'folder:fw-other', + repoId: 'folder-workspace:group-other', + displayName: 'Other host', + path: '/srv/other' + } + state.folderWorkspaces = [ + { + id: 'fw-other', + projectGroupId: 'group-other', + folderPath: '/srv/other', + connectionId: 'ssh-other' + } + ] + state.projectGroups = [{ id: 'group-other', connectionId: 'ssh-other' }] + state.getKnownWorktreeById.mockReturnValue(folderWorkspace) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: folderWorkspace.id, + runContext: { repoId: 'repo-1', hostId: 'local' } + }) + ) + + expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'skipped_unavailable' }) + ) + }) + + it('keeps detected-only non-folder workspaces unavailable', async () => { + state.getKnownWorktreeById.mockReturnValue({ + id: 'wt-detected', + repoId: 'repo-1', + displayName: 'Detected', + path: '/repo/detected' + }) + + await registerAndDispatch( + makeAutomation({ + workspaceMode: 'existing', + workspaceId: 'wt-detected' + }) + ) + + expect(state.getKnownWorktreeById).not.toHaveBeenCalled() + expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'skipped_unavailable' }) + ) + }) + it('finalizes a fresh non-reuse terminal only after completed result persistence', async () => { const order: string[] = [] let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {} diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 1679aaf1af5..d543d639ddf 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -24,6 +24,14 @@ import { import { translate } from '@/i18n/i18n' import { createBrowserUuid } from '@/lib/browser-uuid' import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership' +import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() @@ -63,8 +71,11 @@ export function useAutomationDispatchEvents(): void { } const runRepoId = getAutomationRunRepoId(automation) const repo = state.repos.find((entry) => entry.id === runRepoId) + const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '') const automationWorktree = automation.workspaceId - ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) + ? automationWorkspaceScope?.type === 'folder' + ? state.getKnownWorktreeById(automation.workspaceId) + : state.allWorktrees().find((entry) => entry.id === automation.workspaceId) : null let dispatchWorkspaceId = automation.workspaceId let dispatchWorkspaceDisplayName = @@ -97,9 +108,49 @@ export function useAutomationDispatchEvents(): void { } try { - if (repo.connectionId) { + const folderWorkspaceConnectionId = + automationWorkspaceScope?.type === 'folder' + ? getFolderWorkspaceConnectionId(state, automationWorkspaceScope.folderWorkspaceId) + : null + const folderWorkspaceHostId = + automationWorkspaceScope?.type === 'folder' && automationWorktree + ? folderWorkspaceConnectionId === undefined + ? null + : folderWorkspaceConnectionId + ? toSshExecutionHostId(folderWorkspaceConnectionId) + : getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + : null + const runHostId = + parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo) + const workspaceMatchesRunTarget = + automationWorkspaceScope?.type === 'folder' + ? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId + : !automation.runContext?.repoId || + automationWorktree?.repoId === automation.runContext.repoId + if ( + automation.workspaceMode === 'existing' && + automationWorktree && + !workspaceMatchesRunTarget + ) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) + }) + return + } + const sshTargetId = + automationWorkspaceScope?.type === 'folder' + ? (folderWorkspaceConnectionId ?? null) + : (repo.connectionId ?? null) + if (sshTargetId) { const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ - targetId: repo.connectionId + targetId: sshTargetId }) if (needsPrompt) { await markDispatchResult({ @@ -114,10 +165,10 @@ export function useAutomationDispatchEvents(): void { }) return } - const sshState = await window.api.ssh.getState({ targetId: repo.connectionId }) + const sshState = await window.api.ssh.getState({ targetId: sshTargetId }) if (sshState?.status !== 'connected') { try { - const connected = await window.api.ssh.connect({ targetId: repo.connectionId }) + const connected = await window.api.ssh.connect({ targetId: sshTargetId }) if (connected?.status !== 'connected') { throw new Error('SSH target is unavailable.') } @@ -134,25 +185,6 @@ export function useAutomationDispatchEvents(): void { } } - if ( - automation.workspaceMode === 'existing' && - automationWorktree && - automation.runContext?.repoId && - automationWorktree.repoId !== automation.runContext.repoId - ) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', - 'The target workspace is on a different host than this automation run target.' - ) - }) - return - } - if (automation.workspaceMode === 'existing' && !automationWorktree) { await markDispatchResult({ runId: run.id, diff --git a/src/renderer/src/lib/adopt-agent-background-session-tab.ts b/src/renderer/src/lib/adopt-agent-background-session-tab.ts new file mode 100644 index 00000000000..ec604137645 --- /dev/null +++ b/src/renderer/src/lib/adopt-agent-background-session-tab.ts @@ -0,0 +1,113 @@ +import { useAppStore } from '@/store' +import { makePaneKey, type PaneKey } from '../../../shared/stable-pane-id' +import type { AgentType } from '../../../shared/agent-status-types' +import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' +import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' + +type Store = ReturnType +type RegisterArgs = Parameters + +/** Reserves env-stable tab and pane identities before spawning the PTY. */ +export function reserveAgentBackgroundSessionIdentity(args: { + store: Store + agentType: AgentType + worktreeId: string + launchConfig: RegisterArgs[1] + env: Record | undefined +}): { + reservedTabId: string + leafId: string + paneKey: PaneKey + launchToken: string + launchRegistration: NonNullable + paneEnv: Record +} { + const reservedTabId = createBrowserUuid() + const leafId = createBrowserUuid() + const paneKey = makePaneKey(reservedTabId, leafId) + const launchToken = createBrowserUuid() + const launchRegistration = { + agentType: args.agentType, + launchToken, + tabId: reservedTabId, + leafId + } + args.store.registerAgentLaunchConfig(paneKey, args.launchConfig, launchRegistration) + return { + reservedTabId, + leafId, + paneKey, + launchToken, + launchRegistration, + paneEnv: { + ...args.env, + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: reservedTabId, + ORCA_WORKTREE_ID: args.worktreeId, + ORCA_AGENT_LAUNCH_TOKEN: launchToken + } + } +} + +/** Publishes a hidden run tab already bound to its live PTY (#2989). */ +export async function adoptAgentBackgroundSessionTab(args: { + store: Store + worktreeId: string + reservedTabId: string + ptyId: string + paneKey: PaneKey + launchConfig: RegisterArgs[1] + launchRegistration: NonNullable + runtimeTarget: RuntimeClientTarget + runtimeTerminalHandle: string | null + onRetire: () => void + title?: string +}): Promise<{ + tab: ReturnType + paneKey: PaneKey + terminalOwnership: ReturnType +} | null> { + const { store, reservedTabId, ptyId, launchRegistration } = args + // The worktree can disappear while its PTY spawn is pending. + if ( + await retireUnownedTerminal({ + owner: { worktreeId: args.worktreeId }, + ptyId, + runtimeTarget: args.runtimeTarget, + runtimeTerminalHandle: args.runtimeTerminalHandle, + onRetire: args.onRetire + }) + ) { + return null + } + // Re-keying would desynchronize env-baked routing identities; fail closed. + if (isTerminalTabPresent(useAppStore.getState(), reservedTabId)) { + store.clearAgentLaunchConfig(args.paneKey) + args.onRetire() + await retireProvider({ + ptyId, + runtimeTarget: args.runtimeTarget, + runtimeTerminalHandle: args.runtimeTerminalHandle + }) + return null + } + const tab = store.createTab(args.worktreeId, undefined, undefined, { + id: reservedTabId, + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + const paneKey = args.paneKey + store.registerAgentLaunchConfig(paneKey, args.launchConfig, launchRegistration) + const terminalOwnership = bindAutomationTerminal( + tab, + paneKey, + ptyId, + args.runtimeTarget.kind, + args.title + ) + return { tab, paneKey, terminalOwnership } +} diff --git a/src/renderer/src/lib/agent-background-session-launch-host.test.ts b/src/renderer/src/lib/agent-background-session-launch-host.test.ts new file mode 100644 index 00000000000..86fd8577fae --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-launch-host.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentBackgroundLaunchHost } from './agent-background-session-launch-host' + +function makeFolderHostState(args: { + connectionId: string | null + folderPath: string + repos?: { + id: string + connectionId: string | null + path: string + projectGroupId: string + }[] +}) { + return { + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath: args.folderPath, + connectionId: args.connectionId + } + ], + projectGroups: [ + { + id: 'group-1', + parentGroupId: null, + connectionId: args.connectionId + } + ], + repos: args.repos ?? [] + } +} + +describe('resolveAgentBackgroundLaunchHost', () => { + it('keeps an authoritative local folder owner local', () => { + const host = resolveAgentBackgroundLaunchHost({ + store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never, + worktreeId: 'folder:folder-1', + worktreePath: '/project', + repo: null + }) + + expect(host).toMatchObject({ + connectionId: null, + isRemote: false, + expectedConnectionId: null + }) + }) + + it('fails closed when folder ownership is ambiguous', () => { + const store = makeFolderHostState({ + connectionId: 'ssh-1', + folderPath: '/project', + repos: [ + { + id: 'repo-local', + connectionId: null, + path: '/project/repo', + projectGroupId: 'group-1' + } + ] + }) + + expect(() => + resolveAgentBackgroundLaunchHost({ + store: store as never, + worktreeId: 'folder:folder-1', + worktreePath: '/project', + repo: null + }) + ).toThrow('unavailable or ambiguous') + }) + + it('uses Linux startup quoting for a local WSL folder', () => { + const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project' + const host = resolveAgentBackgroundLaunchHost({ + store: makeFolderHostState({ connectionId: null, folderPath }) as never, + worktreeId: 'folder:folder-1', + worktreePath: folderPath, + repo: null + }) + + expect(host.platform).toBe('linux') + }) +}) diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts new file mode 100644 index 00000000000..7919f9f3af5 --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-launch-host.ts @@ -0,0 +1,71 @@ +import type { useAppStore } from '@/store' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { isWslUncPath } from '../../../shared/wsl-paths' + +type LaunchStore = ReturnType +type LaunchRepo = LaunchStore['repos'][number] + +export type AgentBackgroundLaunchHost = { + /** SSH connection to spawn on, or null for a local launch. */ + connectionId: string | null + /** Platform whose shell quoting and CLI naming the startup plan must target. */ + platform: NodeJS.Platform + isRemote: boolean + /** Accepted status connection; undefined preserves unknown-owner behavior. */ + expectedConnectionId: string | null | undefined +} + +function resolveFolderWorkspaceConnectionIdForLaunch( + store: LaunchStore, + worktreeId: string +): string | null | undefined { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type !== 'folder') { + return undefined + } + return getFolderWorkspaceConnectionId(store, parsed.folderWorkspaceId) +} + +/** Resolves folder launch ownership from workspace scope when no repo row exists. */ +export function resolveAgentBackgroundLaunchHost(args: { + store: LaunchStore + worktreeId: string + worktreePath: string | undefined + repo: LaunchRepo | null | undefined +}): AgentBackgroundLaunchHost { + const { store, worktreeId, worktreePath, repo } = args + if (repo) { + return { + connectionId: repo.connectionId ?? null, + platform: getAgentLaunchPlatformForRepo( + repo, + repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) + ), + isRemote: repoIsRemote(repo), + expectedConnectionId: repo.connectionId ?? null + } + } + const folderWorkspaceConnectionId = resolveFolderWorkspaceConnectionIdForLaunch(store, worktreeId) + const isFolderWorkspace = parseWorkspaceKey(worktreeId)?.type === 'folder' + if (isFolderWorkspace && folderWorkspaceConnectionId === undefined) { + throw new Error('The target folder workspace host is unavailable or ambiguous.') + } + return { + connectionId: folderWorkspaceConnectionId ?? null, + platform: folderWorkspaceConnectionId + ? isWindowsAbsolutePathLike(worktreePath ?? '') + ? 'win32' + : 'linux' + : isWslUncPath(worktreePath ?? '') + ? 'linux' + : CLIENT_PLATFORM, + isRemote: Boolean(folderWorkspaceConnectionId), + expectedConnectionId: isFolderWorkspace ? (folderWorkspaceConnectionId ?? null) : undefined + } +} diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index b338493c411..85def0ffadb 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -24,7 +24,14 @@ export type AgentBackgroundSessionTestState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } }[] - repos: { id: string; connectionId: string | null; path: string }[] + repos: { id: string; connectionId: string | null; path: string; projectGroupId?: string | null }[] + folderWorkspaces: { + id: string + projectGroupId: string + folderPath: string + connectionId?: string | null + }[] + projectGroups: { id: string; parentGroupId?: string | null; connectionId?: string | null }[] worktreesByRepo: Record< string, { id: string; repoId: string; projectId: string; path: string; displayName: string }[] @@ -35,6 +42,7 @@ export type AgentBackgroundSessionTestState = { sshConnectionStates: Map transientClearedAgentStatusConnectionIds: Record allWorktrees: () => { id: string; repoId: string; path: string }[] + getKnownWorktreeById: (worktreeId: string) => { id: string; path: string } | undefined createTab: TestMock setTabCustomTitle: TestMock updateTabPtyId: TestMock @@ -87,6 +95,8 @@ export function createAgentBackgroundSessionTestState(mocks: { } ] }, + folderWorkspaces: [] as AgentBackgroundSessionTestState['folderWorkspaces'], + projectGroups: [] as AgentBackgroundSessionTestState['projectGroups'], tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, terminalLayoutsByTabId: {} as Record< string, @@ -96,6 +106,8 @@ export function createAgentBackgroundSessionTestState(mocks: { sshConnectionStates: new Map(), transientClearedAgentStatusConnectionIds: {} as Record, allWorktrees: () => state.worktreesByRepo['repo-1'], + getKnownWorktreeById: (worktreeId: string) => + state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId), createTab: mocks.createTab, setTabCustomTitle: mocks.setTabCustomTitle, updateTabPtyId: mocks.updateTabPtyId, @@ -120,6 +132,8 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi } state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + state.folderWorkspaces = [] + state.projectGroups = [] state.worktreesByRepo = { 'repo-1': [ { @@ -136,6 +150,9 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi state.ptyIdsByTabId = {} state.sshConnectionStates = new Map() state.transientClearedAgentStatusConnectionIds = {} + // Why: restored here so a test that stubs folder-workspace lookup cannot leak it forward. + state.getKnownWorktreeById = (worktreeId: string) => + state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId) } export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTestState): void { @@ -146,6 +163,13 @@ export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTes } } +/** The tab id reserved before the spawn; the run tab adopts it once the PTY is live. */ +export function expectReservedAgentBackgroundTabId(spawn: TestMock): string { + const tabId = spawn.mock.calls[0]?.[0]?.tabId + expect(tabId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE) + return tabId +} + export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string { const spawnArgs = spawn.mock.calls[0]?.[0] const paneKey = spawnArgs?.env?.ORCA_PANE_KEY @@ -153,7 +177,7 @@ export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string { expect(typeof paneKey).toBe('string') expect(typeof leafId).toBe('string') expect(leafId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE) - expect(paneKey).toBe(`tab-1:${leafId}`) + expect(paneKey).toBe(`${expectReservedAgentBackgroundTabId(spawn)}:${leafId}`) return paneKey } @@ -208,8 +232,9 @@ export function resetAgentBackgroundSessionTestHarness(args: { (args.runtimeCall as unknown as (value: unknown) => unknown)(request) ) resetAgentBackgroundSessionTestState(args.state) - args.createTab.mockImplementation(() => { - const tab = { id: 'tab-1', title: 'Terminal 1' } + // Why: production reserves the tab id before the spawn; honoring options.id mirrors createTab's adoption contract. + args.createTab.mockImplementation((_worktreeId, _groupId, _shellOverride, options) => { + const tab = { id: options?.id ?? 'tab-1', title: 'Terminal 1' } args.state.tabsByWorktree['wt-1'].push(tab) return tab }) diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts new file mode 100644 index 00000000000..6ae4071ba55 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -0,0 +1,511 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE, + createAgentBackgroundSessionTestState, + resetAgentBackgroundSessionTestHarness, + useRemoteAgentBackgroundRuntime +} from '@/lib/agent-background-session-test-state' + +const mockSpawn = vi.fn() +const mockKill = vi.fn() +const mockWrite = vi.fn() +const mockRuntimeEnvironmentCall = vi.fn() +const mockRuntimeEnvironmentTransportCall = vi.fn() +const mockRuntimeEnvironmentSubscribe = vi.fn() +const mockCreateTab = vi.fn() +const mockSetTabCustomTitle = vi.fn() +const mockUpdateTabPtyId = vi.fn() +const mockCloseTab = vi.fn() +const mockSetTabLayout = vi.fn() +const mockRegisterAgentLaunchConfig = vi.fn() +const mockRegisterEagerPtyBuffer = vi.fn() +const mockSubscribeToPtyData = vi.fn() +const mockSubscribeToPtyExit = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() +const mockMarkTrusted = vi.fn() +const mockDispatchEvent = vi.fn() +const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() +const state = createAgentBackgroundSessionTestState({ + createTab: mockCreateTab, + setTabCustomTitle: mockSetTabCustomTitle, + updateTabPtyId: mockUpdateTabPtyId, + closeTab: mockCloseTab, + setTabLayout: mockSetTabLayout, + registerAgentLaunchConfig: mockRegisterAgentLaunchConfig +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state, + subscribe: vi.fn(() => () => {}) + } +})) + +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) + +vi.mock('@/lib/agent-launch-platform', () => ({ + getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, + subscribeToPtyExit: mockSubscribeToPtyExit +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: mockSubscribeToPtyData +})) + +describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', () => { + beforeEach(() => { + resetAgentBackgroundSessionTestHarness({ + state, + createTab: mockCreateTab, + closeTab: mockCloseTab, + getLaunchPlatform: mockGetAgentLaunchPlatformForRepo, + runtimeCall: mockRuntimeEnvironmentCall, + runtimeTransportCall: mockRuntimeEnvironmentTransportCall, + runtimeSubscribe: mockRuntimeEnvironmentSubscribe, + subscribeToData: mockSubscribeToPtyData, + subscribeToExit: mockSubscribeToPtyExit, + setTabLayout: mockSetTabLayout, + updateTabPtyId: mockUpdateTabPtyId, + dispatchEvent: mockDispatchEvent, + kill: mockKill, + markTrusted: mockMarkTrusted, + spawn: mockSpawn, + write: mockWrite + }) + }) + + it('closes a runtime terminal when its worktree disappears before creation resolves', async () => { + useRemoteAgentBackgroundRuntime(state) + let resolveCreate!: (result: { + ok: true + result: { terminal: { handle: string; worktreeId: string; title: null } } + }) => void + const createResult = new Promise<{ + ok: true + result: { terminal: { handle: string; worktreeId: string; title: null } } + }>((resolve) => { + resolveCreate = resolve + }) + mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.createAgentSession') { + return createResult + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const launch = launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run remotely' + }) + await vi.waitFor(() => + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.createAgentSession' }) + ) + ) + state.worktreesByRepo['repo-1'] = [] + resolveCreate({ + ok: true, + result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } } + }) + + await expect(launch).resolves.toBeNull() + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.close', + params: { terminal: 'terminal-after-close' } + }) + ) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled() + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) + + it('forwards Hermes startup queries through SSH command transport', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'remote automation prompt' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), + connectionId: 'ssh-1', + env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' }) + }) + ) + }) + + it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( + "claude '--dangerously-skip-permissions' 'run the automation'" + ) + expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "claude '--dangerously-skip-permissions' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", + startupCommandDelivery: 'shell-ready' + }) + ) + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + expect(mockWrite).not.toHaveBeenCalled() + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('falls back when an SSH shell produces no observable startup data', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + vi.advanceTimersByTime(1_550) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + state.settings = { + agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + title: 'Nightly audit' + }) + + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" + }) + ) + expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('user@remote repo % ') + vi.advanceTimersByTime(50) + expect(mockWrite).not.toHaveBeenCalled() + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not rearm SSH background startup delivery after exit cleanup', async () => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation', + title: 'Nightly audit' + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void + exitSidecar(0) + + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(50) + + expect(mockWrite).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('creates background sessions on the active runtime environment', async () => { + useRemoteAgentBackgroundRuntime(state) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const result = await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).not.toHaveBeenCalled() + const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params + const leafId = params?.placement?.leafId + const tabId = params?.placement?.tabId + expect(leafId).toMatch(UUID_RE) + expect(tabId).toMatch(UUID_RE) + // Why: background launches have no explicit recipe override, so remote host settings win. + expect(params).not.toHaveProperty('agentArgs') + expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith( + `${tabId}:${leafId}`, + { + agentCommand: "claude '--dangerously-skip-permissions'", + agentArgs: '--dangerously-skip-permissions', + agentEnv: {} + }, + { + agentType: 'claude', + launchToken: expect.stringMatching(UUID_RE), + tabId, + leafId + } + ) + expect(mockSetTabLayout).toHaveBeenCalledWith( + tabId, + expect.objectContaining({ + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' } + }) + ) + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.createAgentSession', + params: expect.objectContaining({ + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), + worktree: 'id:wt-1', + agent: 'claude', + prompt: 'run the automation', + promptDelivery: 'auto-submit', + placement: { tabId, leafId }, + presentation: 'background' + }), + timeoutMs: 15_000 + }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'remote:env-1@@terminal-1') + expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'terminal.multiplex', + params: {} + }), + expect.any(Object) + ) + expect(result).toMatchObject({ + tabId, + paneKey: `${tabId}:${leafId}`, + ptyId: 'remote:env-1@@terminal-1', + terminalOwnership: null + }) + }) + + it('preserves the legacy background spawn on an old remote host', async () => { + useRemoteAgentBackgroundRuntime(state) + mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { + if (request.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + }) + } + return Promise.resolve({ + id: 'create', + ok: true, + result: { terminal: { handle: 'legacy-terminal-1' } } + }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run remotely' + }) + ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' }) + + expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.create', + params: expect.objectContaining({ + worktree: 'id:wt-1', + command: "claude '--dangerously-skip-permissions' 'run remotely'", + launchAgent: 'claude', + presentation: 'background' + }) + }) + ) + }) + + it('closes a created runtime terminal when its data subscription fails', async () => { + useRemoteAgentBackgroundRuntime(state) + mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).rejects.toThrow('subscription failed') + + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.close', + params: { terminal: 'terminal-1' }, + timeoutMs: undefined + }) + const tabId = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params?.placement?.tabId + expect(tabId).toMatch(UUID_RE) + expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'remote:env-1@@terminal-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^${tabId}:`)) + ) + expect(mockCloseTab).toHaveBeenCalledWith(tabId, { + recordInteraction: false, + reason: 'cleanup' + }) + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) + + it('spawns an SSH folder-workspace automation on the owning host, not locally', async () => { + // Folder workspaces have no repo row, so launch ownership comes from their scope. + state.repos = [ + { id: 'repo-1', connectionId: 'ssh-1', path: '/srv/proj/api', projectGroupId: 'grp-1' } + ] + state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: 'ssh-1' }] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/srv/proj', connectionId: 'ssh-1' } + ] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/srv/proj' } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'folder:fw-1', + prompt: 'run the automation' + }) + + expect(mockMarkTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/srv/proj', + connectionId: 'ssh-1' + }) + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1', cwd: '/srv/proj' }) + ) + }) + + it('keeps a local folder workspace on the local host', async () => { + state.repos = [ + { id: 'repo-1', connectionId: null, path: '/home/me/proj/api', projectGroupId: 'grp-1' } + ] + state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: null }] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/home/me/proj', connectionId: null } + ] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/home/me/proj' } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'folder:fw-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: null, cwd: '/home/me/proj' }) + ) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index a82996c97f7..78fd3a91f6e 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -4,9 +4,9 @@ import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE, createAgentBackgroundSessionTestState, + expectReservedAgentBackgroundTabId, expectStableAgentBackgroundPaneSpawn, - resetAgentBackgroundSessionTestHarness, - useRemoteAgentBackgroundRuntime + resetAgentBackgroundSessionTestHarness } from '@/lib/agent-background-session-test-state' const mockSpawn = vi.fn() @@ -36,10 +36,11 @@ const state = createAgentBackgroundSessionTestState({ setTabLayout: mockSetTabLayout, registerAgentLaunchConfig: mockRegisterAgentLaunchConfig }) +let currentStoreState = state vi.mock('@/store', () => ({ useAppStore: { - getState: () => state, + getState: () => currentStoreState, subscribe: vi.fn(() => () => {}) } })) @@ -68,6 +69,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ describe('launchAgentBackgroundSession', () => { beforeEach(() => { + currentStoreState = state resetAgentBackgroundSessionTestHarness({ state, createTab: mockCreateTab, @@ -88,7 +90,7 @@ describe('launchAgentBackgroundSession', () => { }) }) - it('spawns a PTY immediately and adopts it in an inactive tab', async () => { + it('spawns a PTY first and creates the inactive tab already bound to it', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ @@ -98,14 +100,21 @@ describe('launchAgentBackgroundSession', () => { title: 'Nightly audit' }) + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + // A store-visible PTY-less run tab fresh-spawns a shell (#2989). + expect(mockSpawn.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateTab.mock.invocationCallOrder[0] ?? 0 + ) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + id: tabId, + initialPtyId: 'pty-1', activate: false, recordInteraction: false }) expect(mockDispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, - detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } + detail: { worktreeId: 'wt-1', tabIds: [tabId] } }) ) expect(mockUpdateTabPtyId.mock.invocationCallOrder[0]).toBeLessThan( @@ -116,18 +125,18 @@ describe('launchAgentBackgroundSession', () => { cwd: '/repo/worktree', command: "claude '--dangerously-skip-permissions' 'run the automation'", env: expect.objectContaining({ - ORCA_TAB_ID: 'tab-1', + ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: 'wt-1' }), connectionId: null, worktreeId: 'wt-1', - tabId: 'tab-1' + tabId }) ) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) - const leafId = paneKey.slice('tab-1:'.length) + const leafId = paneKey.slice(`${tabId}:`.length) expect(mockSetTabLayout).toHaveBeenCalledWith( - 'tab-1', + tabId, expect.objectContaining({ root: { type: 'leaf', leafId }, activeLeafId: leafId, @@ -147,17 +156,17 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn.mock.calls[0]?.[0].launchToken).toBe( mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN ) - expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', { + expect(mockSetTabCustomTitle).toHaveBeenCalledWith(tabId, 'Nightly audit', { recordInteraction: false }) - expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') + expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1') expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function)) expect(mockSubscribeToPtyExit).toHaveBeenCalledWith('pty-1', expect.any(Function)) - expect(result).toMatchObject({ tabId: 'tab-1', paneKey, ptyId: 'pty-1' }) + expect(result).toMatchObject({ tabId, paneKey, ptyId: 'pty-1' }) }) - it('does not mount the tab while the explicit PTY spawn is unresolved', async () => { + it('does not create or mount the tab while the explicit PTY spawn is unresolved', async () => { let resolveSpawn!: (result: { id: string }) => void mockSpawn.mockReturnValueOnce( new Promise<{ id: string }>((resolve) => { @@ -173,18 +182,24 @@ describe('launchAgentBackgroundSession', () => { }) await Promise.resolve() - expect(mockCreateTab).toHaveBeenCalled() + // Publishing a PTY-less tab here reproduces #2989. + expect(mockCreateTab).not.toHaveBeenCalled() expect(mockDispatchEvent).not.toHaveBeenCalled() resolveSpawn({ id: 'pty-slow' }) await expect(launch).resolves.toMatchObject({ ptyId: 'pty-slow' }) - expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-slow') + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + expect(mockCreateTab.mock.calls[0]?.[3]).toMatchObject({ + id: tabId, + initialPtyId: 'pty-slow' + }) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'pty-slow') expect(mockDispatchEvent).toHaveBeenCalledWith( - expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } }) + expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: [tabId] } }) ) }) - it('kills a local PTY when its tab closes before spawn resolves', async () => { + it('kills a local PTY when its worktree disappears before spawn resolves', async () => { let resolveSpawn!: (result: { id: string }) => void mockSpawn.mockReturnValueOnce( new Promise<{ id: string }>((resolve) => { @@ -198,64 +213,66 @@ describe('launchAgentBackgroundSession', () => { worktreeId: 'wt-1', prompt: 'run slowly' }) - await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) - state.tabsByWorktree['wt-1'] = [] + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledOnce()) + state.worktreesByRepo['repo-1'] = [] resolveSpawn({ id: 'pty-after-close' }) await expect(launch).resolves.toBeNull() expect(mockKill).toHaveBeenCalledWith('pty-after-close') + expect(mockCreateTab).not.toHaveBeenCalled() expect(mockUpdateTabPtyId).not.toHaveBeenCalled() expect(mockSubscribeToPtyData).not.toHaveBeenCalled() expect(mockDispatchEvent).not.toHaveBeenCalled() }) - it('closes a runtime terminal when its tab closes before creation resolves', async () => { - useRemoteAgentBackgroundRuntime(state) - let resolveCreate!: (result: { - ok: true - result: { terminal: { handle: string; worktreeId: string; title: null } } - }) => void - const createResult = new Promise<{ - ok: true - result: { terminal: { handle: string; worktreeId: string; title: null } } - }>((resolve) => { - resolveCreate = resolve - }) - mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { - if (args.method === 'terminal.createAgentSession') { - return createResult - } - return Promise.resolve({ ok: true, result: {} }) - }) + it('launches into a folder workspace that is absent from worktreesByRepo throughout', async () => { + // Folder workspaces never appear in worktreesByRepo. + state.worktreesByRepo['repo-1'] = [] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/tmp/folder-workspace' } + ] + state.projectGroups = [{ id: 'grp-1', connectionId: null }] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' + ? { id: 'folder:fw-1', path: '/tmp/folder-workspace' } + : undefined const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const launch = launchAgentBackgroundSession({ agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run remotely' - }) - await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) - await vi.waitFor(() => - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( - expect.objectContaining({ method: 'terminal.createAgentSession' }) - ) - ) - state.tabsByWorktree['wt-1'] = [] - resolveCreate({ - ok: true, - result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } } + worktreeId: 'folder:fw-1', + prompt: 'run in a folder workspace' }) - await expect(launch).resolves.toBeNull() - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + await expect(launch).resolves.toMatchObject({ ptyId: 'pty-1' }) + expect(mockKill).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledOnce() + }) + + it('launches a local WSL folder through wsl.exe', async () => { + const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project' + state.worktreesByRepo['repo-1'] = [] + state.folderWorkspaces = [ + { id: 'fw-wsl', projectGroupId: 'grp-wsl', folderPath, connectionId: null } + ] + state.projectGroups = [{ id: 'grp-wsl', connectionId: null }] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-wsl' ? { id: worktreeId, path: folderPath } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'folder:fw-wsl', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ - method: 'terminal.close', - params: { terminal: 'terminal-after-close' } + cwd: folderPath, + shellOverride: 'wsl.exe', + command: "claude '--dangerously-skip-permissions' 'run the automation'" }) ) - expect(mockUpdateTabPtyId).not.toHaveBeenCalled() - expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled() - expect(mockDispatchEvent).not.toHaveBeenCalled() }) it('records effective launch config returned by local PTY spawn', async () => { @@ -274,11 +291,12 @@ describe('launchAgentBackgroundSession', () => { }) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) - const leafId = paneKey.slice('tab-1:'.length) + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + const leafId = paneKey.slice(`${tabId}:`.length) expect(mockRegisterAgentLaunchConfig).toHaveBeenLastCalledWith(paneKey, effectiveLaunchConfig, { agentType: 'claude', launchToken: mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN, - tabId: 'tab-1', + tabId, leafId }) }) @@ -317,7 +335,7 @@ describe('launchAgentBackgroundSession', () => { command: "claude '--dangerously-skip-permissions' 'don'\\''t use powershell quoting'", connectionId: null, worktreeId: 'wt-1', - tabId: 'tab-1' + tabId: expect.stringMatching(UUID_RE) }) ) }) @@ -440,13 +458,16 @@ describe('launchAgentBackgroundSession', () => { const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void sidecar(0) - expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') - expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^${tabId}:`)) + ) expect(onExit).toHaveBeenCalledWith('pty-1', 0) expect(unsubscribe).toHaveBeenCalled() }) - it('removes the inactive tab if PTY spawn fails', async () => { + it('leaves no tab behind if PTY spawn fails', async () => { mockSpawn.mockRejectedValueOnce(new Error('spawn failed')) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -458,11 +479,63 @@ describe('launchAgentBackgroundSession', () => { }) ).rejects.toThrow('spawn failed') - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { + // Why: the tab is only created once a PTY is live, so a failed spawn has nothing to close. + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockCloseTab).not.toHaveBeenCalled() + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`^${expectReservedAgentBackgroundTabId(mockSpawn)}:`)) + ) + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + }) + + it('closes the adopted tab if binding fails after the PTY is live', async () => { + mockSubscribeToPtyData.mockImplementationOnce(() => { + throw new Error('subscribe failed') + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).rejects.toThrow('subscribe failed') + + const tabId = expectReservedAgentBackgroundTabId(mockSpawn) + expect(mockCloseTab).toHaveBeenCalledWith(tabId, { recordInteraction: false, reason: 'cleanup' }) - expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockKill).toHaveBeenCalledWith('pty-1') + }) + + it('retires the launch instead of adopting a colliding tab id', async () => { + mockSpawn.mockImplementationOnce((args: { tabId: string }) => { + currentStoreState = { + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + 'wt-1': [{ id: args.tabId, title: 'Squatter' }] + } + } + return Promise.resolve({ id: 'pty-1' }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await expect( + launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + ).resolves.toBeNull() + + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockKill).toHaveBeenCalledWith('pty-1') + expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith( + expectStableAgentBackgroundPaneSpawn(mockSpawn) + ) }) it('submits prompts for stdin-after-start agents in background mode', async () => { @@ -479,7 +552,7 @@ describe('launchAgentBackgroundSession', () => { ) expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( expect.objectContaining({ - tabId: 'tab-1', + tabId: expectReservedAgentBackgroundTabId(mockSpawn), content: 'run the automation', agent: 'aider', submit: true @@ -523,299 +596,4 @@ describe('launchAgentBackgroundSession', () => { }) ) }) - - it('forwards Hermes startup queries through SSH command transport', async () => { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'hermes', - worktreeId: 'wt-1', - prompt: 'remote automation prompt' - }) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.objectContaining({ - command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'), - connectionId: 'ssh-1', - env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' }) - }) - ) - }) - - it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( - "claude '--dangerously-skip-permissions' 'run the automation'" - ) - expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r" - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", - startupCommandDelivery: 'shell-ready' - }) - ) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - state.settings = { - agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null, - terminalMainSideEffectAuthority: undefined - } - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" - }) - ) - expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" - ) - } finally { - vi.useRealTimers() - } - }) - - it('does not rearm SSH background startup delivery after exit cleanup', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void - exitSidecar(0) - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('creates background sessions on the active runtime environment', async () => { - useRemoteAgentBackgroundRuntime(state) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - const result = await launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - - expect(mockSpawn).not.toHaveBeenCalled() - const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params - const leafId = params?.placement?.leafId - expect(leafId).toMatch(UUID_RE) - // Why: background launches have no explicit recipe override, so remote host settings win. - expect(params).not.toHaveProperty('agentArgs') - expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith( - `tab-1:${leafId}`, - { - agentCommand: "claude '--dangerously-skip-permissions'", - agentArgs: '--dangerously-skip-permissions', - agentEnv: {} - }, - { - agentType: 'claude', - launchToken: expect.stringMatching(UUID_RE), - tabId: 'tab-1', - leafId - } - ) - expect(mockSetTabLayout).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' } - }) - ) - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'terminal.createAgentSession', - params: expect.objectContaining({ - clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), - worktree: 'id:wt-1', - agent: 'claude', - prompt: 'run the automation', - promptDelivery: 'auto-submit', - placement: { tabId: 'tab-1', leafId }, - presentation: 'background' - }), - timeoutMs: 15_000 - }) - expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') - expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() - expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith( - expect.objectContaining({ - selector: 'env-1', - method: 'terminal.multiplex', - params: {} - }), - expect.any(Object) - ) - expect(result).toMatchObject({ - tabId: 'tab-1', - paneKey: `tab-1:${leafId}`, - ptyId: 'remote:env-1@@terminal-1', - terminalOwnership: null - }) - }) - - it('preserves the legacy background spawn on an old remote host', async () => { - useRemoteAgentBackgroundRuntime(state) - mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { - if (request.method === 'status.get') { - return Promise.resolve({ - id: 'status', - ok: true, - result: { - runtimeId: 'old-runtime', - graphStatus: 'ready', - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 2, - capabilities: [] - } - }) - } - return Promise.resolve({ - id: 'create', - ok: true, - result: { terminal: { handle: 'legacy-terminal-1' } } - }) - }) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await expect( - launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run remotely' - }) - ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' }) - - expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'terminal.create', - params: expect.objectContaining({ - worktree: 'id:wt-1', - command: "claude '--dangerously-skip-permissions' 'run remotely'", - launchAgent: 'claude', - presentation: 'background' - }) - }) - ) - }) - - it('closes a created runtime terminal when its data subscription fails', async () => { - useRemoteAgentBackgroundRuntime(state) - mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await expect( - launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - ).rejects.toThrow('subscription failed') - - expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'terminal.close', - params: { terminal: 'terminal-1' }, - timeoutMs: undefined - }) - expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') - expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/)) - expect(mockCloseTab).toHaveBeenCalledWith('tab-1', { - recordInteraction: false, - reason: 'cleanup' - }) - expect(mockDispatchEvent).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 9ab4a5e684d..f65204d6558 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -4,18 +4,15 @@ import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult } from '@/lib/agent-background-session-contract' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host' import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, @@ -25,9 +22,7 @@ import { import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' -import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' -import { createBrowserUuid } from '@/lib/browser-uuid' +import { retireProvider } from '@/lib/retire-unowned-background-terminal' import { createRuntimeAgentBackgroundTerminal } from '@/lib/runtime-agent-background-create' import { subscribeToRuntimeTerminalData, @@ -38,42 +33,48 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startu import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' -import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' +import type { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' +import { + adoptAgentBackgroundSessionTab, + reserveAgentBackgroundSessionIdentity +} from '@/lib/adopt-agent-background-session-tab' import { createBackgroundAgentStatusConsumer } from '@/lib/background-agent-status-consumer' +import { isWslUncPath } from '../../../shared/wsl-paths' export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs ): Promise { const { agent, worktreeId, prompt, launchSource, title, onData, onExit, onAgentStatus } = args const store = useAppStore.getState() - const worktree = store.allWorktrees().find((entry) => entry.id === worktreeId) + // Folder workspaces exist only in getKnownWorktreeById (#2989). + const worktree = store.getKnownWorktreeById(worktreeId) const repo = worktree ? store.repos.find((entry) => entry.id === worktree.repoId) : null if (!worktree) { throw new Error('The target workspace is no longer available.') } + const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) + const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + // Folder launch ownership cannot be derived from a repo row (#2989). + const launchHost = resolveAgentBackgroundLaunchHost({ + store, + worktreeId, + worktreePath: worktree.path, + repo + }) const preflight = TUI_AGENT_CONFIG[agent].preflightTrust if (preflight && worktree.path && window.api.agentTrust?.markTrusted) { try { await window.api.agentTrust.markTrusted({ preset: preflight, - workspacePath: worktree.path + workspacePath: worktree.path, + ...(launchHost.connectionId ? { connectionId: launchHost.connectionId } : {}) }) } catch { - // Best-effort: continue with launch. The user can still accept the trust menu. + // Best-effort: the user can still accept the trust prompt. } } - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} - const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) - const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) - const launchPlatform = repo - ? getAgentLaunchPlatformForRepo( - repo, - repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) - ) - : CLIENT_PLATFORM - // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - // `orca-ide` rename must not be applied for remote launches. - const isRemote = repo ? repoIsRemote(repo) : false + const { platform: launchPlatform, isRemote } = launchHost const startupShell = resolveLocalWindowsAgentStartupShell({ platform: launchPlatform, isRemote, @@ -99,37 +100,17 @@ export async function launchAgentBackgroundSession( return null } - // Why: automation runs should start without revealing the workspace. - // Spawn the PTY immediately, then attach an inactive tab to the live session. - const tab = store.createTab(worktreeId, undefined, undefined, { - activate: false, - recordInteraction: false - }) - // Why: agent hook callbacks are keyed by pane, and background automation - // tabs never mount a TerminalPane to inject this env for us. createBrowserUuid - // (not crypto.randomUUID) because the latter is undefined in non-secure - // browser contexts — the LAN web client served over plain HTTP. - const leafId = createBrowserUuid() - const paneKey = makePaneKey(tab.id, leafId) - const launchToken = createBrowserUuid() - const launchRegistration = { - agentType: agent, - launchToken, - tabId: tab.id, - leafId - } - store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, launchRegistration) - // Why: `title` labels the tab/worktree entry. Pane titles render as an - // in-terminal title row, so background sessions must not persist it there. - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId)) - const paneEnv = { - ...startupPlan.env, - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: tab.id, - ORCA_WORKTREE_ID: worktreeId, - ORCA_AGENT_LAUNCH_TOKEN: launchToken - } - const sshConnectionId = repo?.connectionId ?? null + // A hidden run tab must never be store-visible without its PTY (#2989). + const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = + reserveAgentBackgroundSessionIdentity({ + store, + agentType: agent, + worktreeId, + launchConfig: startupPlan.launchConfig, + env: startupPlan.env + }) + let paneKey = makePaneKey(reservedTabId, leafId) + const sshConnectionId = launchHost.connectionId const sshStartupDelivery = createSshBackgroundStartupDelivery({ command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: @@ -147,6 +128,7 @@ export async function launchAgentBackgroundSession( let ptyId = '', runtimeTerminalHandle: string | null = null let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined + let tab: ReturnType | null = null let exitHandled = false, eagerPtyBuffer: EagerPtyHandle | null = null let terminalOwnership: ReturnType = null @@ -160,7 +142,9 @@ export async function launchAgentBackgroundSession( unsubscribeExit() unsubscribeData() sshStartupDelivery.clear() - useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) + if (tab) { + useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) + } useAppStore.getState().clearAgentLaunchConfig(paneKey) onExit?.(exitPtyId, code) } @@ -174,7 +158,7 @@ export async function launchAgentBackgroundSession( paneKey, launchToken, mainOwnsAgentStatusWrites, - expectedConnectionId: repo ? (repo.connectionId ?? null) : undefined, + expectedConnectionId: launchHost.expectedConnectionId, runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null, getPtyId: () => ptyId, onAgentStatus @@ -192,7 +176,7 @@ export async function launchAgentBackgroundSession( const created = await createRuntimeAgentBackgroundTerminal({ environmentId: runtimeTarget.environmentId, worktreeId, - tabId: tab.id, + tabId: reservedTabId, leafId, agent, ...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), @@ -216,6 +200,7 @@ export async function launchAgentBackgroundSession( rows: 40, cwd: worktree.path, command: startupPlan.launchCommand, + ...(!sshConnectionId && isWslUncPath(worktree.path) ? { shellOverride: 'wsl.exe' } : {}), ...(!startupPlan.startupCommandDelivery ? {} : { startupCommandDelivery: startupPlan.startupCommandDelivery }), @@ -225,7 +210,7 @@ export async function launchAgentBackgroundSession( launchAgent: agent, connectionId: sshConnectionId, worktreeId, - tabId: tab.id, + tabId: reservedTabId, leafId, telemetry: { agent_kind: tuiAgentToAgentKind(agent), @@ -236,25 +221,29 @@ export async function launchAgentBackgroundSession( ptyId = result.id returnedLaunchConfig = result.launchConfig } - if ( - await retireUnownedTerminal({ - tabId: tab.id, - ptyId, - runtimeTarget, - runtimeTerminalHandle, - onRetire: () => { - exitHandled = true - sshStartupDelivery.clear() - store.clearAgentLaunchConfig(paneKey) - } - }) - ) { + const adopted = await adoptAgentBackgroundSessionTab({ + store, + worktreeId, + reservedTabId, + ptyId, + paneKey, + launchConfig: returnedLaunchConfig ?? startupPlan.launchConfig, + launchRegistration, + runtimeTarget, + runtimeTerminalHandle, + onRetire: () => { + exitHandled = true + sshStartupDelivery.clear() + store.clearAgentLaunchConfig(paneKey) + }, + ...(title ? { title } : {}) + }) + if (!adopted) { return null } - if (returnedLaunchConfig) { - store.registerAgentLaunchConfig(paneKey, returnedLaunchConfig, launchRegistration) - } - terminalOwnership = bindAutomationTerminal(tab, paneKey, ptyId, runtimeTarget.kind, title) + tab = adopted.tab + paneKey = adopted.paneKey + terminalOwnership = adopted.terminalOwnership if (agent === 'command-code' && hasPrompt && !isFollowupPath) { // Why: Command Code does not expose a prompt-start hook; seed working for // hidden prompt launches so sidebar/activity surfaces do not stay idle. @@ -297,6 +286,7 @@ export async function launchAgentBackgroundSession( // alive regardless of whether the tab is hidden or mounted. unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) } + sshStartupDelivery.armFallback(ptyId) // Why: bind the explicit PTY and ownership before mount; an earlier mount // can double-spawn, while later tracking can miss user takeover. @@ -312,19 +302,23 @@ export async function launchAgentBackgroundSession( // A failure between them must not strand an invisible runtime terminal. exitHandled = true terminalOwnership?.release() + const createdTab = tab runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) - runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId)) + if (createdTab) { + runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId)) + } runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey)) if (ptyId) { await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle }) } - // Why: a launch-failure cleanup close is not a user close — keep it out of - // the Cmd+Shift+T reopen stack. - runBestEffortAgentBackgroundCleanups(() => - store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' }) - ) + if (createdTab) { + // Cleanup closes must not enter the reopen stack. + runBestEffortAgentBackgroundCleanups(() => + store.closeTab(createdTab.id, { recordInteraction: false, reason: 'cleanup' }) + ) + } throw error } } diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 85a02598484..4c4c1603d0b 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -183,7 +183,7 @@ async function createBackgroundTab(args: { } if ( await retireUnownedTerminal({ - tabId: tab.id, + owner: { tabId: tab.id }, ptyId, runtimeTarget: { kind: 'local' } }) @@ -215,7 +215,7 @@ async function addSetupSplit(args: { }) if ( await retireUnownedTerminal({ - tabId: args.tab.tabId, + owner: { tabId: args.tab.tabId }, ptyId: setupPtyId, runtimeTarget: { kind: 'local' } }) diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts index 7a7f128125a..40a0b0afb60 100644 --- a/src/renderer/src/lib/retire-unowned-background-terminal.ts +++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts @@ -3,17 +3,24 @@ import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc- import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' export async function retireUnownedTerminal(args: { - tabId: string + /** Present tab id, or `{ worktreeId }` for a launch whose tab is created after the spawn. */ + owner: { tabId: string } | { worktreeId: string } ptyId: string runtimeTarget: RuntimeClientTarget runtimeTerminalHandle?: string | null onRetire?: () => void }): Promise { - if (isTerminalTabPresent(useAppStore.getState(), args.tabId)) { + const state = useAppStore.getState() + const owner = args.owner + const isOwned = + 'tabId' in owner + ? isTerminalTabPresent(state, owner.tabId) + : // Folder workspaces exist only in getKnownWorktreeById. + state.getKnownWorktreeById(owner.worktreeId) !== undefined + if (isOwned) { return false } - // Why: close can win while provider creation is in flight, before the - // returned handle is bindable to store state or visible to tab retirement. + // Close can win before the provider is bindable to store state. args.onRetire?.() await retireProvider(args) return true diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts index eb178fd5964..f117ca447b7 100644 --- a/src/renderer/src/lib/ssh-background-startup-delivery.ts +++ b/src/renderer/src/lib/ssh-background-startup-delivery.ts @@ -14,6 +14,7 @@ type SshBackgroundStartupDeliveryOptions = { export type SshBackgroundStartupDelivery = { handleData(data: string): string + armFallback(ptyId: string): void schedule(ptyId: string): void clear(): void } @@ -51,22 +52,28 @@ export function createSshBackgroundStartupDelivery( } } + const armFallback = (ptyId: string): void => { + lastPtyId = ptyId + if (!pendingCommand || fallbackTimer !== null) { + return + } + fallbackTimer = setTimeout(() => { + fallbackTimer = null + startupShellReady = true + schedule(ptyId) + }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) + } + const schedule = (ptyId: string): void => { lastPtyId = ptyId if (!pendingCommand) { return } if (!startupShellReady) { - if (fallbackTimer === null) { - // Why: hidden SSH sessions can use shells that cannot emit Orca's - // marker. Prefer readiness, but never drop the startup command forever. - fallbackTimer = setTimeout(() => { - fallbackTimer = null - markShellReady() - }, SSH_SHELL_READY_STARTUP_FALLBACK_MS) - } + armFallback(ptyId) return } + clearFallbackTimer() clearInjectTimer() injectTimer = setTimeout(() => { injectTimer = null @@ -102,6 +109,7 @@ export function createSshBackgroundStartupDelivery( } return scanned.output }, + armFallback, schedule, clear() { clearInjectTimer() From f48cb78646804bf8aa74b93f2f4fd097cf014606 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:09:31 -0700 Subject: [PATCH 017/203] perf(store): index worktrees and tabs once per hydration loop (#10891) * perf(store): index worktrees and tabs once per hydration loop Four sites in hydrateWorkspaceSession and reconnectPersistedTerminals rebuilt a flattened array inside a loop and linearly searched it: Object.values(worktreesByRepo).flat().find((e) => e.id === worktreeId) That is O(rows x ids) for O(rows + ids) distinct work. Build one first-wins index per loop instead. Neither loop sets state or awaits, so a single index over the store snapshot is valid for every iteration. 54.5x at a real 10-repo / 423-worktree session with 188 pending reconnects; 2.1x on a one-repo session. This runs synchronously on renderer cold start and gates workspaceSessionReady, which blocks terminal pane mounting. First-wins matters: Array.prototype.find returns the first match, so an index that overwrote on collision would resolve a different repo for a duplicated worktree id. Both the tests and the benchmark fixture carry a deliberate cross-repo duplicate so that difference is observable. Co-authored-by: Orca * test(store): count the generated worktree rows instead of multiplying Review feedback: the table printed repoCount * worktreesPerRepo, which misses the duplicate id makeStore injects for multi-repo cases (that duplicate is what makes first-wins observable). Count the generated map, and say plainly that the fixtures are synthetic at real-world scale rather than a replay of a real session. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../hydrate-worktree-lookup-benchmark.mjs | 178 ++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 44 ++--- .../store/slices/worktree-by-id-index.test.ts | 85 +++++++++ .../src/store/slices/worktree-by-id-index.ts | 34 ++++ 4 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 config/scripts/hydrate-worktree-lookup-benchmark.mjs create mode 100644 src/renderer/src/store/slices/worktree-by-id-index.test.ts create mode 100644 src/renderer/src/store/slices/worktree-by-id-index.ts diff --git a/config/scripts/hydrate-worktree-lookup-benchmark.mjs b/config/scripts/hydrate-worktree-lookup-benchmark.mjs new file mode 100644 index 00000000000..76b737501e3 --- /dev/null +++ b/config/scripts/hydrate-worktree-lookup-benchmark.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect. +// +// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree) +// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids) +// distinct work. The fix builds one first-wins index per loop. +// +// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady, +// which blocks terminal pane mounting, so the cost is paid before the first frame. +// +// Both arms produce the resolved rows and are compared for equality before timing, so an +// index that resolved differently could not be reported as a win. +// +// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs +import { performance } from 'node:perf_hooks' + +const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60') +const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10') +const ROUNDS = 6 + +for (const [name, value] of [ + ['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_HYDRATE_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Pre-fix: re-flatten and linear-search per id. +function resolveByFlatten(worktreesByRepo, ids) { + const resolved = [] + for (const id of ids) { + const worktree = Object.values(worktreesByRepo) + .flat() + .find((entry) => entry.id === id) + resolved.push(worktree ? worktree.repoId : null) + } + return resolved +} + +// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts. +function resolveByIndex(worktreesByRepo, ids) { + const index = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + if (!index.has(worktree.id)) { + index.set(worktree.id, worktree) + } + } + } + const resolved = [] + for (const id of ids) { + const worktree = index.get(id) + resolved.push(worktree ? worktree.repoId : null) + } + return resolved +} + +function makeStore(repoCount, worktreesPerRepo) { + const worktreesByRepo = {} + for (let repo = 0; repo < repoCount; repo += 1) { + const repoId = `repo-${repo}` + worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({ + id: `${repoId}/wt-${index}`, + repoId, + path: `/Users/dev/worktrees/${repoId}/wt-${index}`, + branch: `feature/branch-${index}` + })) + } + // Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on + // collision would resolve a different repo. Without a collision in the fixture that + // difference is unobservable and the equality check below would pass a broken index. + if (repoCount > 1) { + const [firstRepo, secondRepo] = Object.keys(worktreesByRepo) + worktreesByRepo[secondRepo] = [ + { ...worktreesByRepo[firstRepo][0], repoId: secondRepo }, + ...worktreesByRepo[secondRepo] + ] + } + return worktreesByRepo +} + +// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so +// the real workload includes ids that scan the whole list without matching -- the worst +// case for the linear arm, and the one the code comments call out explicitly. +function makeIds(worktreesByRepo, count) { + const all = Object.values(worktreesByRepo).flat() + const ids = Array.from({ length: count }, (_value, index) => + index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id + ) + // Always look up the duplicated id, so first-wins is exercised, not just present. + ids[1] = all[0].id + return ids +} + +function timeArm(resolve, worktreesByRepo, ids) { + let sink = 0 + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + // Consume the result so V8 cannot drop the call as dead. + sink += resolve(worktreesByRepo, ids).length + } + const elapsed = (performance.now() - start) / ITERATIONS + if (sink === -1) { + throw new Error('unreachable') + } + return elapsed +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const mid = sorted.length / 2 + return (sorted[mid - 1] + sorted[mid]) / 2 +} + +// Arms alternate which one leads so within-round drift cannot favour either. +function measure(worktreesByRepo, ids) { + for (let index = 0; index < WARMUP; index += 1) { + resolveByFlatten(worktreesByRepo, ids) + resolveByIndex(worktreesByRepo, ids) + } + const flattenSamples = [] + const indexSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids)) + indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids)) + } else { + indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids)) + flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids)) + } + } + return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Session-hydration worktree lookup, per cold start. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`) +console.log( + `${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}` +) + +// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine +// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the +// generator spreads worktrees evenly and injects one duplicate id, so treat the counts +// as "about this scale", not a replay. +for (const [repoCount, worktreesPerRepo, idCount] of [ + [1, 5, 5], + [3, 20, 20], + [10, 42, 188], + [10, 100, 400] +]) { + const worktreesByRepo = makeStore(repoCount, worktreesPerRepo) + const ids = makeIds(worktreesByRepo, idCount) + const flattenResult = resolveByFlatten(worktreesByRepo, ids) + const indexResult = resolveByIndex(worktreesByRepo, ids) + if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) { + throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`) + } + if (!flattenResult.some((value) => value !== null)) { + throw new Error(`fixture resolved nothing at ${repoCount} repos`) + } + if (!flattenResult.some((value) => value === null)) { + throw new Error(`fixture had no absent ids at ${repoCount} repos`) + } + // Count the generated rows rather than multiplying: makeStore injects a duplicate + // id for multi-repo cases, so the product would misreport the fixture by one. + const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0) + const { flattenMs, indexMs } = measure(worktreesByRepo, ids) + console.log( + `${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.' +) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5c880618505..a0cfcea982f 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -34,6 +34,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem @@ -3210,13 +3211,13 @@ export const createTerminalSlice: StateCreator // Why: preserve each tab's prior ptyId so reconnect passes it as sessionId to the daemon's createOrAttach, triggering reattach instead of a fresh spawn. const pendingReconnectPtyIdByTabId: Record = {} + const placeholderWorktreeById = buildWorktreeByIdIndex( + runtimeSessionPlaceholders.worktreesByRepo + ) + const placeholderRepoById = buildByIdIndex(runtimeSessionPlaceholders.repos) for (const worktreeId of pendingReconnectWorktreeIds) { - const worktree = Object.values(runtimeSessionPlaceholders.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree - ? runtimeSessionPlaceholders.repos.find((entry) => entry.id === worktree.repoId) - : null + const worktree = placeholderWorktreeById.get(worktreeId) + const repo = worktree ? placeholderRepoById.get(worktree.repoId) : null if (repo?.connectionId) { continue } @@ -3304,6 +3305,11 @@ export const createTerminalSlice: StateCreator nextEverActivated.add(activeWorktreeId) } + // Why indexed: the layout map below looks up a tab per persisted layout, and + // re-flattening tabsByWorktree per entry is O(tabs x layouts). + const allTabs = Object.values(tabsByWorktree).flat() + const tabById = buildByIdIndex(allTabs) + return { activeRepoId, activeWorktreeId, @@ -3328,11 +3334,7 @@ export const createTerminalSlice: StateCreator // 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. worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1, - ptyIdsByTabId: Object.fromEntries( - Object.values(tabsByWorktree) - .flat() - .map((tab) => [tab.id, []] as const) - ), + 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. terminalLayoutsByTabId: Object.fromEntries( Object.entries(session.terminalLayoutsByTabId) @@ -3340,9 +3342,7 @@ export const createTerminalSlice: StateCreator .map(([tabId, layout]) => { // Why: old sessions can contain renderer-local pane:1-style leaf ids; normalize before runtime/mobile surfaces read them. const normalized = normalizeTerminalLayoutSnapshot(layout).snapshot - const tab = Object.values(tabsByWorktree) - .flat() - .find((entry) => entry.id === tabId) + const tab = tabById.get(tabId) const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized const activeLeafId = sanitized.root ? resolvePtyBoundActiveLeafId({ @@ -3382,12 +3382,14 @@ export const createTerminalSlice: StateCreator // 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. let reconnectedTabsByWorktree: Record | null = null let reconnectedPtyIdsByTabId: Record | null = null + // Why indexed: the loop neither sets state nor awaits, so one index over the + // whole store snapshot serves every iteration. + const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo) + const repoById = buildByIdIndex(get().repos) for (const worktreeId of ids) { const tabs = tabsByWorktree[worktreeId] ?? [] - const worktree = Object.values(get().worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? get().repos.find((entry) => entry.id === worktree.repoId) : null + const worktree = worktreeById.get(worktreeId) + const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. const sshState = repo?.connectionId ? get().sshConnectionStates.get(repo.connectionId) : null const sshConnected = repo?.connectionId != null && sshState?.status === 'connected' @@ -3441,12 +3443,10 @@ 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. const deferredSshSessionIdsByTabId: Record = {} for (const worktreeId of ids) { - const worktree = Object.values(get().worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) + const worktree = worktreeById.get(worktreeId) // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = repoId ? get().repos.find((entry) => entry.id === repoId) : null + const repo = repoId ? (repoById.get(repoId) ?? null) : null if (!repo?.connectionId) { continue } diff --git a/src/renderer/src/store/slices/worktree-by-id-index.test.ts b/src/renderer/src/store/slices/worktree-by-id-index.test.ts new file mode 100644 index 00000000000..1c5d28e6295 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-by-id-index.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' + +function worktree(id: string, repoId: string): Worktree { + return { id, repoId, path: `/repos/${repoId}/${id}`, branch: id } as Worktree +} + +// Reference: the pre-change lookup these indexes replace. +function findByFlatten(worktreesByRepo: Record, id: string): Worktree | null { + return ( + Object.values(worktreesByRepo) + .flat() + .find((entry) => entry.id === id) ?? null + ) +} + +describe('buildWorktreeByIdIndex', () => { + const worktreesByRepo = { + repoA: [worktree('wt-1', 'repoA'), worktree('wt-2', 'repoA')], + repoB: [worktree('wt-3', 'repoB')], + repoC: [] + } + + // Why: the index replaces a per-iteration flatten+find, so the contract that + // matters is that it resolves exactly what that find resolved. + it('resolves every id identically to flatten-and-find', () => { + for (const id of ['wt-1', 'wt-2', 'wt-3', 'missing', '']) { + expect(buildWorktreeByIdIndex(worktreesByRepo).get(id) ?? null).toEqual( + findByFlatten(worktreesByRepo, id) + ) + } + }) + + it('returns undefined for an id in no repo', () => { + expect(buildWorktreeByIdIndex(worktreesByRepo).get('nope')).toBeUndefined() + }) + + it('handles an empty map', () => { + expect(buildWorktreeByIdIndex({}).size).toBe(0) + }) + + it('skips repos with no worktrees', () => { + expect(buildWorktreeByIdIndex(worktreesByRepo).size).toBe(3) + }) + + // Why first-wins: Array.prototype.find returns the first match, and repo order is + // Object.values order. A duplicate id across repos must resolve the same way. + it('keeps the first entry when an id appears in two repos', () => { + const duplicated = { + repoA: [worktree('shared', 'repoA')], + repoB: [worktree('shared', 'repoB')] + } + expect(buildWorktreeByIdIndex(duplicated).get('shared')?.repoId).toBe('repoA') + expect(buildWorktreeByIdIndex(duplicated).get('shared')).toEqual( + findByFlatten(duplicated, 'shared') + ) + }) +}) + +describe('buildByIdIndex', () => { + it('resolves every id identically to find', () => { + const rows = [ + { id: 'a', n: 1 }, + { id: 'b', n: 2 }, + { id: 'c', n: 3 } + ] + for (const id of ['a', 'b', 'c', 'zzz']) { + expect(buildByIdIndex(rows).get(id)).toEqual(rows.find((row) => row.id === id)) + } + }) + + it('keeps the first row when ids repeat', () => { + const rows = [ + { id: 'dup', n: 1 }, + { id: 'dup', n: 2 } + ] + expect(buildByIdIndex(rows).get('dup')).toEqual(rows.find((row) => row.id === 'dup')) + expect(buildByIdIndex(rows).get('dup')?.n).toBe(1) + }) + + it('handles an empty list', () => { + expect(buildByIdIndex([]).size).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/worktree-by-id-index.ts b/src/renderer/src/store/slices/worktree-by-id-index.ts new file mode 100644 index 00000000000..a7eaf17239e --- /dev/null +++ b/src/renderer/src/store/slices/worktree-by-id-index.ts @@ -0,0 +1,34 @@ +import type { Worktree } from '../../../../shared/types' + +/** + * Flattens worktreesByRepo into an id lookup. + * + * Why: session hydration and terminal reconnect loop over pending worktree ids and + * previously re-flattened + linear-searched the whole map per iteration, which is + * O(worktrees x ids) for O(worktrees + ids) distinct work. First entry wins, matching + * the `.find()` it replaces. + */ +export function buildWorktreeByIdIndex( + worktreesByRepo: Record +): Map { + const index = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + if (!index.has(worktree.id)) { + index.set(worktree.id, worktree) + } + } + } + return index +} + +/** Same first-wins contract as `buildWorktreeByIdIndex`, for any id-bearing row. */ +export function buildByIdIndex(rows: readonly T[]): Map { + const index = new Map() + for (const row of rows) { + if (!index.has(row.id)) { + index.set(row.id, row) + } + } + return index +} From 6943638053861ae8288673e8c90c9f2d9f75ac11 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:10:45 -0700 Subject: [PATCH 018/203] perf(terminal): scan output frames by code unit, not per code point (#10915) * perf(terminal): scan output frames by code unit, not per code point iterateTerminalOutputFrameChunks walked `for (const part of data)`, materializing a 1-2 character string per code point and calling terminalStreamByteLength on each, while accumulating the frame text with `chunk += part`. The accumulator was never needed: `chunk` only ever reconstructs the contiguous substring data[chunkStart..end), and `startSeq + chunkStartOffset + chunk.length` collapses algebraically to `startSeq + end`. Track two integer offsets and emit data.slice(chunkStart, end) instead, computing UTF-8 width inline from charCodeAt. Also short-circuits the cap gate on UTF-16 length before measuring UTF-8 bytes, which is sound because UTF-8 length is never below UTF-16 length. 2.4-6.1x across payload shapes, stable across reruns. This runs per terminal output batch and per snapshot chunk. Extracted to its own module along a real seam (the chunk-emission concern plus its two types and cap gate); methods/terminal.ts shrinks by 85 lines. No max-lines suppression added and the baseline is untouched. Co-authored-by: Orca * fix(terminal): preserve chunk sequence rounding Co-authored-by: Orca --------- Co-authored-by: Orca --- .../terminal-output-frame-chunk-benchmark.mjs | 347 +++++++++++++ src/main/runtime/rpc/methods/terminal.ts | 97 +--- ...al-output-frame-chunks-equivalence.test.ts | 477 ++++++++++++++++++ .../rpc/terminal-output-frame-chunks.ts | 113 +++++ 4 files changed, 943 insertions(+), 91 deletions(-) create mode 100644 config/scripts/terminal-output-frame-chunk-benchmark.mjs create mode 100644 src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts create mode 100644 src/main/runtime/rpc/terminal-output-frame-chunks.ts diff --git a/config/scripts/terminal-output-frame-chunk-benchmark.mjs b/config/scripts/terminal-output-frame-chunk-benchmark.mjs new file mode 100644 index 00000000000..6dd6364524c --- /dev/null +++ b/config/scripts/terminal-output-frame-chunk-benchmark.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +// Benchmark: iterateTerminalOutputFrameChunks, which every byte of remote terminal +// output passes through on its way to a mobile/remote-desktop multiplex stream. +// +// The pre-fix loop was `for (const part of data)`: V8 materializes a fresh 1-2 code +// unit string per code point, then measureClipboardTextByteLength re-walked that +// string through codePointAt to get its UTF-8 width, and the chunk was rebuilt with +// `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the +// same per-code-point walk over the whole payload a second time. +// +// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point +// string, chunk text taken as data.slice(chunkStart, end), and a gate of +// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a +// string's UTF-8 length is never below its UTF-16 length. +// +// BOTH arms run the complete production path, encodeTerminalStreamText included, and +// their emitted frames (base64 + seq + opcode) are compared before any timing, so an +// arm that split differently or renumbered a seq cannot be reported as a win. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import nodeModule from 'node:module' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +// terminal-stream-protocol.ts declares a TS enum, which Node's default strip-only +// loader rejects; re-exec once with type transformation rather than re-modelling +// the opcodes here (a hand copy could drift from the wire contract). +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +// The app's TS sources import siblings without an extension; Node's ESM resolver needs it. +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40') +const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8') +const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6') + +for (const [name, value] of [ + ['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP], + ['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (ROUNDS % 2 !== 0) { + throw new Error(`ORCA_FRAME_CHUNK_BENCH_ROUNDS must be even so each arm leads equally`) +} + +const CHUNK_SOURCE = readFileSync( + new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url), + 'utf8' +) +const CLIPBOARD_SOURCE = readFileSync( + new URL('../../src/shared/clipboard-text.ts', import.meta.url), + 'utf8' +) + +// Why re-read the sources: this benchmark's claim is that the OLD arm paid a +// measureClipboardTextByteLength call per code point and the NEW one pays a single +// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming +// the function would satisfy a substring check. +for (const [source, label, marker] of [ + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'], + [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('], + [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'text.codePointAt(index)'] +]) { + if (!source.includes(marker)) { + throw new Error(`${label} no longer contains \`${marker}\`; this benchmark is stale`) + } +} +if (CHUNK_SOURCE.includes('for (const part of data)')) { + throw new Error( + 'terminal-output-frame-chunks.ts still iterates code points as strings; this benchmark is stale' + ) +} + +const { TERMINAL_STREAM_CHUNK_BYTES } = await import( + new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href +) +const { measureClipboardTextByteLength } = await import( + new URL('../../src/shared/clipboard-text.ts', import.meta.url).href +) +const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import( + new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href +) +const { iterateTerminalOutputFrameChunks } = await import( + new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href +) + +// Pre-fix arm: the exact code that shipped, including the second full walk in the gate. +function* iterateBefore(data, meta) { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if ( + !measureClipboardTextByteLength(data, { stopAfterBytes: TERMINAL_STREAM_CHUNK_BYTES }) + .exceededLimit + ) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq - rawLength : undefined + let chunk = '' + let chunkBytes = 0 + let chunkStartOffset = 0 + let offset = 0 + let delayedChunk = null + + const takeChunk = () => { + if (!chunk) { + return null + } + const chunkSeq = canPreserveChunkSeq ? startSeq + chunkStartOffset + chunk.length : undefined + const current = { text: chunk, seq: chunkSeq } + chunk = '' + chunkBytes = 0 + chunkStartOffset = offset + return current + } + + for (const part of data) { + const partBytes = measureClipboardTextByteLength(part).byteLength + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + } + chunk += part + chunkBytes += partBytes + offset += part.length + } + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } + } +} + +// The multiplex stream consumes every frame's bytes/seq/opcode; charge both arms for it. +let frameChecksum = 0 +function drain(iterate, data, meta) { + let frames = 0 + let bytes = 0 + let seqSum = 0 + for (const frame of iterate(data, meta)) { + frames += 1 + bytes += frame.bytes.byteLength + seqSum += frame.seq ?? 0 + } + frameChecksum = Math.imul(frameChecksum ^ (frames + bytes + seqSum), 16777619) >>> 0 + return frames +} + +function describeFrames(iterate, data, meta) { + const shapes = [] + for (const frame of iterate(data, meta)) { + shapes.push( + `${Buffer.from(frame.bytes).toString('base64')}|${frame.seq ?? 'u'}|${frame.opcode ?? 'u'}` + ) + } + return shapes.join('\n') +} + +const SURROGATE_PAIR = '\u{1f600}' +const LONE_HIGH = '\ud83d' + +function repeatTo(unit, codeUnits) { + let out = '' + while (out.length < codeUnits) { + out += unit + } + return out.slice(0, out.length - (out.length % unit.length)) +} + +// A realistic agent-TUI line: SGR runs, a wide glyph, a currency sign, an emoji. +const TUI_LINE = + '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed, 0 failed\r\n' + +const fixtures = [ + { + label: 'ascii 4KiB (typical batch)', + data: 'x'.repeat(4 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: `ascii ${TERMINAL_STREAM_CHUNK_BYTES}B (at cap)`, + data: 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'ascii 64KiB (batch cap, 2 chunks)', + data: 'x'.repeat(64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'mixed TUI 64KiB (2 chunks)', + data: repeatTo(TUI_LINE, 64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + // seq without an explicit rawLength: the batcher's ordinary shape. + label: 'mixed TUI 64KiB (implicit raw)', + data: repeatTo(TUI_LINE, 64 * 1024), + meta: () => ({ seq: 5_000_000 }) + }, + { + label: 'emoji 64KiB (4-byte, 2 chunks)', + data: repeatTo(SURROGATE_PAIR, 32 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'lone surrogates 64KiB', + data: repeatTo(LONE_HIGH, 64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'ascii 512KiB (snapshot chunking)', + data: 'x'.repeat(512 * 1024), + meta: () => undefined + } +] + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +// Why interleaved with an alternating lead: running one arm's whole batch first lets +// CPU frequency drift correlate with whichever arm is being measured. Elsewhere in this +// effort that alone reported 23.3x for a real 6.7x. +function measureInterleaved(data, meta) { + for (let index = 0; index < WARMUP; index += 1) { + drain(iterateBefore, data, meta) + drain(iterateTerminalOutputFrameChunks, data, meta) + } + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + const runBefore = () => { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + drain(iterateBefore, data, meta) + } + beforeSamples.push((performance.now() - start) / ITERATIONS) + } + const runAfter = () => { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + drain(iterateTerminalOutputFrameChunks, data, meta) + } + afterSamples.push((performance.now() - start) / ITERATIONS) + } + if (round % 2 === 0) { + runBefore() + runAfter() + } else { + runAfter() + runBefore() + } + } + return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.') +console.log( + `iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (alternating lead, per-arm median)` +) +console.log( + `${pad('fixture', 34)} ${pad('frames', 7)} ${pad('per-part', 11)} ${pad('scanned', 11)} ${pad('speedup', 9)}` +) + +let comparedFixtures = 0 +for (const fixture of fixtures) { + const meta = fixture.meta(fixture.data) + const before = describeFrames(iterateBefore, fixture.data, meta) + const after = describeFrames(iterateTerminalOutputFrameChunks, fixture.data, meta) + if (before !== after) { + throw new Error(`frames differ for ${fixture.label}`) + } + const frames = before.split('\n').length + // Guard against a fixture that never reaches the chunking loop; then both arms would + // just be measuring the gate and the comparison above would prove nothing about it. + if (fixture.data.length > TERMINAL_STREAM_CHUNK_BYTES && frames < 2) { + throw new Error(`${fixture.label} never split; fixture does not exercise the chunk loop`) + } + comparedFixtures += 1 + const { beforeMs, afterMs } = measureInterleaved(fixture.data, meta) + console.log( + `${pad(fixture.label, 34)} ${pad(frames, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + `\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}` +) +console.log( + 'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.' +) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1261a2de4cf..11848a2f0db 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -17,6 +17,11 @@ import { encodeTerminalStreamText, type TerminalStreamFrame } from '../../../../shared/terminal-stream-protocol' +import { + iterateTerminalOutputFrameChunks, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from '../terminal-output-frame-chunks' import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' import { @@ -49,8 +54,7 @@ import { TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_OUTPUT_BATCH_MAX_BYTES, - TERMINAL_STREAM_CHUNK_BYTES + TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../shared/terminal-multiplex-flow-control' import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' @@ -139,19 +143,6 @@ type TerminalOutputChunk = { meta?: TerminalOutputMeta } -type TerminalOutputMeta = { - seq?: number - rawLength?: number - transformed?: boolean - cwd?: string -} - -type TerminalOutputFrameChunk = { - bytes: Uint8Array - seq?: number - opcode?: TerminalStreamOpcode -} - function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { push: (data: string, meta?: TerminalOutputMeta) => void flush: () => void @@ -240,82 +231,6 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp } } -function* iterateTerminalOutputFrameChunks( - data: string, - meta?: TerminalOutputMeta -): Generator { - const rawLength = meta?.rawLength ?? data.length - if (meta?.transformed || rawLength !== data.length) { - yield { - opcode: TerminalStreamOpcode.OutputSpan, - bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), - seq: meta?.seq - } - return - } - if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { - yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } - return - } - const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' - const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined - let chunk = '' - let chunkBytes = 0 - let chunkStartOffset = 0 - let offset = 0 - let delayedChunk: { text: string; seq?: number } | null = null - - const takeChunk = (): { text: string; seq?: number } | null => { - if (!chunk) { - return null - } - const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined - const current = { text: chunk, seq: chunkSeq } - chunk = '' - chunkBytes = 0 - chunkStartOffset = offset - return current - } - - for (const part of data) { - const partBytes = terminalStreamByteLength(part) - if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { - const nextChunk = takeChunk() - if (nextChunk) { - if (shouldDelayFinalSeq) { - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text) } - } - delayedChunk = nextChunk - } else { - yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } - } - } - } - chunk += part - chunkBytes += partBytes - offset += part.length - } - const finalChunk = takeChunk() - if (shouldDelayFinalSeq) { - // Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets. - if (finalChunk) { - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text) } - } - delayedChunk = finalChunk - } - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } - } - return - } - if (finalChunk) { - yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } - } -} - function isTerminalInputLockedForClient( runtime: OrcaRuntimeService, ptyId: string, diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts new file mode 100644 index 00000000000..f43f7cb93fa --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it } from 'vitest' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' +import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { + iterateTerminalOutputFrameChunks, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from './terminal-output-frame-chunks' + +// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. +// It accumulates `chunk += part` over `for (const part of data)` and measures each +// code point through the shared clipboard measurer. +function legacyByteLength(data: string): number { + return measureClipboardTextByteLength(data).byteLength +} + +function legacyByteLengthExceeds(data: string, maxBytes: number): boolean { + return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit +} + +function* legacyIterateTerminalOutputFrameChunks( + data: string, + meta?: TerminalOutputMeta +): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if (!legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined + let chunk = '' + let chunkBytes = 0 + let chunkStartOffset = 0 + let offset = 0 + let delayedChunk: { text: string; seq?: number } | null = null + + const takeChunk = (): { text: string; seq?: number } | null => { + if (!chunk) { + return null + } + const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined + const current = { text: chunk, seq: chunkSeq } + chunk = '' + chunkBytes = 0 + chunkStartOffset = offset + return current + } + + for (const part of data) { + const partBytes = legacyByteLength(part) + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + } + chunk += part + chunkBytes += partBytes + offset += part.length + } + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } + } +} + +type FrameShape = { base64: string; seq: number | 'undefined'; opcode: number | 'undefined' } + +function describeFrames(frames: Iterable): FrameShape[] { + const out: FrameShape[] = [] + for (const frame of frames) { + out.push({ + base64: Buffer.from(frame.bytes).toString('base64'), + seq: frame.seq ?? 'undefined', + opcode: frame.opcode ?? 'undefined' + }) + } + return out +} + +function expectEquivalent(data: string, meta: TerminalOutputMeta | undefined, label: string): void { + const legacy = describeFrames(legacyIterateTerminalOutputFrameChunks(data, meta)) + const next = describeFrames(iterateTerminalOutputFrameChunks(data, meta)) + expect(next, label).toEqual(legacy) +} + +const SURROGATE_PAIR = '\u{1f600}' +const LONE_HIGH = '\ud83d' +const LONE_LOW = '\ude00' +// Extremes of both surrogate ranges: U+10000 (D800 DC00) and U+10FFFF (DBFF DFFF). +const FIRST_ASTRAL = '\u{10000}' +const LAST_ASTRAL = '\u{10ffff}' +const SURROGATE_EDGES = [ + '\ud800', + '\udbff', + '\udc00', + '\udfff', + FIRST_ASTRAL, + LAST_ASTRAL, + '\udbff\udbff', + '\ud800\udbff', + '\udfff\udc00' +] + +// Meta shapes exercised against every fixture: no meta, seq-preserved (rawLength === +// data.length), the delayed-final-seq path (rawLength !== data.length -> OutputSpan), +// transformed, and cwd-only. +function metaShapesFor(data: string): { label: string; meta: TerminalOutputMeta | undefined }[] { + return [ + { label: 'no-meta', meta: undefined }, + { label: 'seq-only', meta: { seq: 5_000_000 } }, + { label: 'seq+rawLength=len', meta: { seq: 9_000, rawLength: data.length } }, + { label: 'seq+rawLength!=len', meta: { seq: 9_000, rawLength: data.length + 7 } }, + { label: 'rawLength!=len only', meta: { rawLength: data.length + 3 } }, + { label: 'transformed', meta: { seq: 42, rawLength: data.length, transformed: true } }, + { label: 'cwd-only', meta: { cwd: '/home/dev/orca' } }, + { label: 'seq=0', meta: { seq: 0, rawLength: data.length } } + ] +} + +function sweepAll(data: string, label: string): void { + for (const shape of metaShapesFor(data)) { + expectEquivalent(data, shape.meta, `${label} [${shape.label}]`) + } +} + +// A fixture whose seq path is only observable when rawLength maps 1:1 to UTF-16 +// offsets; forcing that shape is what makes the algebraic collapse testable. +function seqPreservingMeta(data: string, seq: number): TerminalOutputMeta { + return { seq, rawLength: data.length } +} + +function escapeUnits(value: string): string { + const units: string[] = [] + for (let index = 0; index < value.length; index += 1) { + units.push(`U+${value.charCodeAt(index).toString(16).toUpperCase()}`) + } + return units.join(' ') +} + +function repeatToLength(unit: string, codeUnits: number): string { + let out = '' + while (out.length < codeUnits) { + out += unit + } + return out.slice(0, out.length - (out.length % unit.length)) +} + +// Deterministic PRNG so a fuzz failure is reproducible from the seed alone. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 || 1 + return () => { + state ^= state << 13 + state >>>= 0 + state ^= state >>> 17 + state ^= state << 5 + state >>>= 0 + return state / 0x1_0000_0000 + } +} + +const FUZZ_ALPHABET = [ + 'a', + 'z', + '\r', + '\n', + '\u00e9', + '\u20ac', + SURROGATE_PAIR, + LONE_HIGH, + LONE_LOW, + '\u0000', + '\u001b' +] + +function randomText(random: () => number, parts: number): string { + let out = '' + for (let index = 0; index < parts; index += 1) { + out += FUZZ_ALPHABET[Math.floor(random() * FUZZ_ALPHABET.length)] + } + return out +} + +describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => { + it('matches on the small/no-chunking sizes', () => { + for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) { + sweepAll('a'.repeat(size), `ascii ${size}`) + } + }) + + it('matches across 1B..200KiB ASCII payloads', () => { + for (const size of [ + 1, + 100, + TERMINAL_STREAM_CHUNK_BYTES, + TERMINAL_STREAM_CHUNK_BYTES + 1, + TERMINAL_STREAM_CHUNK_BYTES * 2, + TERMINAL_STREAM_CHUNK_BYTES * 3 + 17, + 100 * 1024, + 200 * 1024 + ]) { + sweepAll('x'.repeat(size), `ascii ${size}`) + } + }) + + it('preserves legacy addition rounding for unsafe sequence values', () => { + const data = 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 1) + const seq = 9_007_199_254_740_994 + const meta = seqPreservingMeta(data, seq) + + expectEquivalent(data, meta, 'unsafe seq rounding') + const frames = [...iterateTerminalOutputFrameChunks(data, meta)] + expect(frames.at(-1)?.seq).toBe(9_007_199_254_740_992) + }) + + it('matches on multi-byte-only payloads at every UTF-8 width', () => { + // UTF-8 width boundaries: 1|2 at U+0080, 2|3 at U+0800, 3|4 at U+10000. + for (const unit of [ + '\u00e9', + '\u20ac', + SURROGATE_PAIR, + '\u0080', + '\u07ff', + '\u0800', + '\uffff' + ]) { + for (const codeUnits of [ + TERMINAL_STREAM_CHUNK_BYTES / 2, + TERMINAL_STREAM_CHUNK_BYTES, + TERMINAL_STREAM_CHUNK_BYTES + 64, + 90 * 1024 + ]) { + const data = repeatToLength(unit, codeUnits) + sweepAll(data, `unit=${JSON.stringify(unit)} codeUnits=${codeUnits}`) + } + } + }) + + it('matches with lone surrogates, including a trailing lone high surrogate', () => { + const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5) + for (const data of [ + LONE_HIGH, + LONE_LOW, + LONE_HIGH + LONE_HIGH, + LONE_LOW + LONE_HIGH, + filler + LONE_HIGH, + filler + LONE_LOW, + LONE_HIGH + filler, + LONE_LOW + filler, + filler + LONE_HIGH + filler, + // Reversed pair: never a valid pair, so both halves must stay 3-byte replacements. + filler + LONE_LOW + LONE_HIGH + filler, + repeatToLength(LONE_HIGH, 60 * 1024), + repeatToLength(LONE_LOW + LONE_HIGH, 60 * 1024) + ]) { + sweepAll(data, `lone-surrogate len=${data.length}`) + } + }) + + it('matches at both ends of both surrogate ranges (U+D800..U+DBFF, U+DC00..U+DFFF)', () => { + const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5) + for (const edge of SURROGATE_EDGES) { + for (const data of [ + edge, + filler + edge, + edge + filler, + filler + edge + filler, + repeatToLength(edge, 40 * 1024) + ]) { + sweepAll(data, `surrogate-edge ${escapeUnits(edge)} len=${data.length}`) + } + // Land the split inside the edge sequence itself. + for (let offset = -4; offset <= 4; offset += 1) { + const data = `${'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + offset)}${edge}${'b'.repeat(8)}` + expectEquivalent(data, undefined, `surrogate-edge ${escapeUnits(edge)} offset=${offset}`) + expectEquivalent( + data, + seqPreservingMeta(data, 500_000 + data.length), + `surrogate-edge ${escapeUnits(edge)} offset=${offset} seq` + ) + } + } + }) + + it('sweeps a chunk boundary across a surrogate pair at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + const data = `${'a'.repeat(prefixBytes)}${SURROGATE_PAIR}${'b'.repeat(64)}` + sweepAll(data, `pair boundary offset=${offset}`) + expectEquivalent( + data, + seqPreservingMeta(data, 1_000_000 + data.length), + `pair boundary offset=${offset} seq-preserved` + ) + } + }) + + it('sweeps a chunk boundary across a multi-byte run at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + // 3-byte run straddling the cap: the split point cannot land mid-code-point. + const data = `${'a'.repeat(prefixBytes)}${'\u20ac'.repeat(32)}${'b'.repeat(16)}` + sweepAll(data, `multibyte boundary offset=${offset}`) + } + }) + + it('sweeps a chunk boundary across a lone surrogate at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + for (const lone of [LONE_HIGH, LONE_LOW]) { + const data = `${'a'.repeat(prefixBytes)}${lone}${'b'.repeat(64)}` + sweepAll(data, `lone ${lone === LONE_HIGH ? 'high' : 'low'} boundary offset=${offset}`) + } + // Lone high surrogate as the very last code unit of the payload. + const trailing = `${'a'.repeat(prefixBytes)}${LONE_HIGH}` + sweepAll(trailing, `trailing lone high offset=${offset}`) + } + }) + + it('sweeps 2-byte and 4-byte code points across a 1-code-unit window at the cap', () => { + for (const unit of ['\u00e9', SURROGATE_PAIR]) { + for ( + let pad = TERMINAL_STREAM_CHUNK_BYTES - 6; + pad <= TERMINAL_STREAM_CHUNK_BYTES + 2; + pad += 1 + ) { + const data = `${'a'.repeat(pad)}${unit.repeat(8)}${'z'.repeat(8)}` + expectEquivalent(data, undefined, `window unit=${unit} pad=${pad}`) + expectEquivalent( + data, + seqPreservingMeta(data, 777 + data.length), + `window unit=${unit} pad=${pad} seq` + ) + expectEquivalent( + data, + { seq: 777, rawLength: data.length + 1 }, + `window unit=${unit} pad=${pad} delayed` + ) + } + } + }) + + it('matches on the delayed-final-seq path across many chunk counts', () => { + // rawLength !== data.length routes to OutputSpan; force the multi-chunk delayed + // path by keeping rawLength === data.length but seq present with transformed=false, + // then separately assert the true delayed shape (canPreserveChunkSeq=false). + for (const chunkCount of [1, 2, 3, 5, 9]) { + const data = `${'m'.repeat(TERMINAL_STREAM_CHUNK_BYTES * chunkCount)}${SURROGATE_PAIR}tail` + expectEquivalent(data, { seq: 4242 }, `delayed chunks=${chunkCount} seq-only`) + expectEquivalent(data, { seq: 4242, rawLength: 1 }, `delayed chunks=${chunkCount} raw=1`) + expectEquivalent(data, undefined, `delayed chunks=${chunkCount} no-meta`) + } + }) + + it('matches on realistic mixed terminal output', () => { + const line = '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed\r\n' + for (const repeats of [1, 200, 2000, 6000]) { + const data = line.repeat(repeats) + sweepAll(data, `mixed repeats=${repeats}`) + } + }) + + it('fuzzes 4000 short random payloads over the surrogate/control alphabet', () => { + const random = makeRandom(0x5eed_1234) + for (let trial = 0; trial < 4000; trial += 1) { + const data = randomText(random, Math.floor(random() * 40)) + expectEquivalent(data, undefined, `fuzz-small trial=${trial}`) + expectEquivalent( + data, + { seq: 31337, rawLength: data.length }, + `fuzz-small seq trial=${trial}` + ) + } + }) + + it('fuzzes 800 near-cap payloads whose split point lands in the random region', () => { + const random = makeRandom(0x1234_5eed) + for (let trial = 0; trial < 800; trial += 1) { + const fillerLength = TERMINAL_STREAM_CHUNK_BYTES - 6 + Math.floor(random() * 12) + const data = 'q'.repeat(fillerLength) + randomText(random, 1 + Math.floor(random() * 24)) + expectEquivalent(data, undefined, `fuzz-cap trial=${trial}`) + expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) + expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) + } + }) + + it('keeps every emitted frame within the wire cap and reassembles to the input', () => { + const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}` + const frames = [...iterateTerminalOutputFrameChunks(data, seqPreservingMeta(data, 999_999))] + expect(frames.length).toBeGreaterThan(4) + for (const frame of frames) { + expect(frame.bytes.byteLength).toBeLessThanOrEqual(TERMINAL_STREAM_CHUNK_BYTES) + } + expect(Buffer.concat(frames.map((frame) => Buffer.from(frame.bytes))).toString('utf8')).toBe( + Buffer.from(new TextEncoder().encode(data)).toString('utf8') + ) + // Seqs must be strictly increasing and end at the meta high-water mark. + const seqs = frames.map((frame) => frame.seq!) + expect(seqs.every((seq, index) => index === 0 || seq > seqs[index - 1]!)).toBe(true) + expect(seqs.at(-1)).toBe(999_999) + }) + + it('emits exactly one frame when the payload fits the cap in bytes but not naively', () => { + // 3-byte code points: 16384 code units = 49152 bytes = exactly the cap. + const exact = '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3) + expect(Buffer.byteLength(exact, 'utf8')).toBe(TERMINAL_STREAM_CHUNK_BYTES) + expect([...iterateTerminalOutputFrameChunks(exact)]).toHaveLength(1) + expect([...legacyIterateTerminalOutputFrameChunks(exact)]).toHaveLength(1) + const overByOne = `${exact}a` + expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) + expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) + }) + + // The chunking loop is only reached when rawLength === data.length and transformed + // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and + // therefore shouldDelayFinalSeq unconditionally false. The branch survives here + // (it also survived in the pre-optimization code) purely as defence in depth. + it('never reaches the delayed-final-seq branch for any meta shape', () => { + for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { + for (const seq of [undefined, 0, 5] as (number | undefined)[]) { + for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { + for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { + const meta: TerminalOutputMeta = {} + if (seq !== undefined) { + meta.seq = seq + } + if (rawDelta !== undefined) { + meta.rawLength = data.length + rawDelta + } + if (transformed !== undefined) { + meta.transformed = transformed + } + const rawLength = meta.rawLength ?? data.length + const reachesChunkLoop = !meta.transformed && rawLength === data.length + const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' + expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) + } + } + } + } + }) +}) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks.ts b/src/main/runtime/rpc/terminal-output-frame-chunks.ts new file mode 100644 index 00000000000..f040934a565 --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-frame-chunks.ts @@ -0,0 +1,113 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' + +export type TerminalOutputMeta = { + seq?: number + rawLength?: number + transformed?: boolean + cwd?: string +} + +export type TerminalOutputFrameChunk = { + bytes: Uint8Array + seq?: number + opcode?: TerminalStreamOpcode +} + +// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is. +function exceedsTerminalStreamChunkBytes(data: string): boolean { + return ( + data.length > TERMINAL_STREAM_CHUNK_BYTES || + Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES + ) +} + +export function* iterateTerminalOutputFrameChunks( + data: string, + meta?: TerminalOutputMeta +): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if (!exceedsTerminalStreamChunkBytes(data)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + // Unreachable past the OutputSpan branch above (rawLength === data.length there), but kept + // as defence in depth: it is the only shape that can carry a seq the chunk offsets can't map. + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined + let chunkStart = 0 + let chunkBytes = 0 + let delayedChunk: { text: string; seq?: number } | null = null + + // Why two offsets instead of an accumulator: the emitted text is always the contiguous + // substring from chunkStart to end. Keep the legacy addition order for unsafe sequence values. + const takeChunk = (end: number): { text: string; seq?: number } => { + const text = data.slice(chunkStart, end) + const current = { + text, + seq: canPreserveChunkSeq ? startSeq! + chunkStart + text.length : undefined + } + chunkStart = end + chunkBytes = 0 + return current + } + + let index = 0 + while (index < data.length) { + const code = data.charCodeAt(index) + let width = 1 + let partBytes = 3 + if (code < 0x80) { + partBytes = 1 + } else if (code < 0x800) { + partBytes = 2 + } else if ( + code >= 0xd800 && + code <= 0xdbff && + // Past the end charCodeAt is NaN, which masks to 0 — a trailing lone surrogate stays 3 bytes. + (data.charCodeAt(index + 1) & 0xfc00) === 0xdc00 + ) { + partBytes = 4 + width = 2 + } + // Why chunkBytes > 0: keeps a code point wider than the whole cap in its own frame + // instead of splitting an empty one forever. + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk(index) + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + chunkBytes += partBytes + index += width + } + // A split always advances past its boundary, so the tail after the loop is never empty. + const finalChunk = takeChunk(data.length) + if (shouldDelayFinalSeq) { + // Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets. + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: meta.seq } + return + } + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } +} From a72068015f75446e299251ca3f7d0d4e2ba4fc40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:13:00 -0700 Subject: [PATCH 019/203] fix(panes): stop a stray touch hijacking a mouse divider drag (#11013) Each pointer type has its own primary pointer, so a finger on a touchscreen arrives with isPrimary true while a mouse drag is in flight and satisfies the WSLg fallback in isActiveDragPointer. Exclude touch from that fallback; the pen path WSLg needs is unchanged and a touch-started drag still matches by pointerId. --- .../src/lib/pane-manager/pane-divider-drag.ts | 7 +- .../pane-divider-stray-touch.test.ts | 208 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index a01a49de988..073cc29e874 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -220,9 +220,12 @@ export function attachDividerDrag( // Why: WSLg's RDP input path reports press/release as a `mouse` pointer but // streams motion as a `pen` pointer with a different pointerId, so a strict - // pointerId match drops every move. Any primary pointer continues the drag. + // pointerId match drops every move. Any primary pointer continues the drag, + // except touch: each pointer type has its own primary, so a stray finger + // would otherwise hijack an in-flight mouse/pen drag. A touch-started drag + // still matches by pointerId. const isActiveDragPointer = (e: PointerEvent): boolean => - e.pointerId === activePointerId || e.isPrimary + e.pointerId === activePointerId || (e.isPrimary && e.pointerType !== 'touch') const onPointerMove = (e: PointerEvent): void => { if (!dragging || !isActiveDragPointer(e) || !prevEl || !nextEl) { diff --git a/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts new file mode 100644 index 00000000000..0a5d0c967e0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + flushAnimationFrames: () => void +} + +function createPaneElement(width: number): PaneElement { + return { + style: {}, + classList: { contains: vi.fn(() => false) }, + dispatchEvent: vi.fn(() => true), + getBoundingClientRect: vi.fn(() => ({ + left: 0, + top: 0, + right: width, + bottom: 200, + width, + height: 200 + })), + querySelectorAll: vi.fn(() => []) + } as unknown as PaneElement +} + +function createPointerEvent(args: Partial): PointerEvent { + return { + preventDefault: vi.fn(), + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + clientX: 0, + clientY: 0, + ...args + } as unknown as PointerEvent +} + +function createDividerDragHarness(): DividerDragHarness { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + const previousPane = createPaneElement(100) + const nextPane = createPaneElement(300) + const divider = { + style: { setProperty: vi.fn() }, + classList: { add: vi.fn(), remove: vi.fn() }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (dividerListeners.get(event) === listener) { + dividerListeners.delete(event) + } + }), + setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)), + hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)), + releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { createElement: vi.fn(() => divider) }) + vi.stubGlobal('window', { + addEventListener: vi.fn((event: string, listener: EventListener) => { + windowListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (windowListeners.get(event) === listener) { + windowListeners.delete(event) + } + }) + }) + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + animationFrames.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => animationFrames.delete(frameId)) + ) + const onLayoutChanged = vi.fn() + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged }) + + return { + divider, + dividerListeners, + windowListeners, + previousPane, + nextPane, + onLayoutChanged, + flushAnimationFrames: () => { + for (const [frameId, callback] of animationFrames) { + animationFrames.delete(frameId) + callback(16) + } + } + } +} + +// Each pointer type has its own primary pointer, so a finger on a touchscreen +// arrives with isPrimary true while a mouse drag is already in flight. +const STRAY_TOUCH = { pointerId: 42, pointerType: 'touch', isPrimary: true } as const + +function startMouseDrag(harness: DividerDragHarness): void { + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.( + createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 180 }) + ) + harness.flushAnimationFrames() +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('divider drag pointer-type isolation', () => { + it('ignores stray touch motion during an active mouse drag', () => { + const harness = createDividerDragHarness() + startMouseDrag(harness) + expect(harness.previousPane.style.flex).toBe('180 1 0%') + + harness.windowListeners.get('pointermove')?.( + createPointerEvent({ ...STRAY_TOUCH, clientX: 320 }) + ) + harness.flushAnimationFrames() + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.nextPane.style.flex).toBe('220 1 0%') + }) + + it('does not commit the layout when a stray touch lifts mid mouse drag', () => { + const harness = createDividerDragHarness() + startMouseDrag(harness) + + harness.windowListeners.get('pointerup')?.(createPointerEvent({ ...STRAY_TOUCH, clientX: 320 })) + + expect(harness.onLayoutChanged).not.toHaveBeenCalled() + expect(harness.divider.classList.remove).not.toHaveBeenCalledWith('is-dragging') + expect(harness.windowListeners.has('pointermove')).toBe(true) + + harness.windowListeners.get('pointerup')?.( + createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 180 }) + ) + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) + + it('does not revert the layout when a stray touch is cancelled mid mouse drag', () => { + const harness = createDividerDragHarness() + harness.previousPane.style.flex = '2 1 0%' + harness.nextPane.style.flex = '3 1 0%' + startMouseDrag(harness) + + harness.windowListeners.get('pointercancel')?.( + createPointerEvent({ ...STRAY_TOUCH, clientX: 320 }) + ) + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.windowListeners.has('pointermove')).toBe(true) + }) + + it('still continues a mouse drag from a primary pen pointer (WSLg relay)', () => { + const harness = createDividerDragHarness() + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 1, pointerType: 'mouse', isPrimary: true, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.( + createPointerEvent({ pointerId: 19, pointerType: 'pen', isPrimary: true, clientX: 180 }) + ) + harness.windowListeners.get('pointerup')?.( + createPointerEvent({ pointerId: 1, pointerType: 'mouse', isPrimary: true, clientX: 180 }) + ) + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.nextPane.style.flex).toBe('220 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) + + it('drives a touch-started drag from its own pointerId', () => { + const harness = createDividerDragHarness() + const touchDrag = { pointerId: 7, pointerType: 'touch', isPrimary: true } as const + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ ...touchDrag, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ ...touchDrag, clientX: 180 })) + harness.flushAnimationFrames() + harness.windowListeners.get('pointerup')?.(createPointerEvent({ ...touchDrag, clientX: 180 })) + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.nextPane.style.flex).toBe('220 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) +}) From 025c242f0c2476882516f4ff07cc1ba5ef4a7f89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:13:23 -0700 Subject: [PATCH 020/203] feat(dashboard): tint agent cards by state, show the project as an icon, name the chat (#11012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): tint agent cards by state, show the project as an icon, name the chat Three glanceability changes to the agent board: - The "Needs You" signal moves from the column border onto the cards themselves, and done agents get the same treatment in green. Idle cards that simply aren't running stay neutral, so a tint always means "this one wants you". - The repo is now its own icon with the name in a tooltip, instead of a mono label that truncated and competed with the worktree name. Icons ride the snapshot keyed by repoId — image icons are data URLs, and the snapshot republishes several times a second. - The user-message line is labelled with the tab's conversation name rather than "You", resolved through the same getAgentRowConversationName the sidebar's agent rows use. Status-only titles still fall back to "You". * refactor(dashboard): head the card with the session name, move the worktree beside the project The conversation name now sits next to the agent icon as the card's heading rather than prefixing the user-message line, and the worktree drops to the footer beside the project icon. The message line reads "You" again — the name moved up, so keeping it there said the same thing twice. Cards without a resolvable session name keep the worktree as the heading, and the footer omits it rather than repeating it. * fix(dashboard): thread settings into every snapshot builder caller Adding `settings` to DashboardSnapshotState left three callers constructing it without one. The in-window drawer's was a real defect, not just a type error: useLiveDashboardSnapshot derives its own snapshot rather than receiving the relayed one, so a dropped slice silently blanks generated conversation names in the drawer while the pop-out shows them. Bucket counts pass null deliberately — they never render a conversation name, so the sidebar stays unsubscribed from settings. Covers the drawer's wiring with a test, since `settings: null` type-checks and would blank names again without failing loudly. * test(dashboard): complete the terminal layout fixture TerminalLayoutSnapshot requires expandedLeafId; the neighbouring builder test hides this behind an `as unknown as` cast on the whole state object. --- .../ipc/dashboard-payload-validation.test.ts | 50 ++++++ src/main/ipc/dashboard-payload-validation.ts | 28 ++- .../AgentKanbanBoard.test.tsx | 35 +++- .../dashboard-popout/AgentKanbanBoard.tsx | 109 ++++++------ .../dashboard-popout/AgentKanbanCard.test.tsx | 165 ++++++++++++++++-- .../dashboard-popout/AgentKanbanCard.tsx | 83 +++++++-- .../build-dashboard-snapshot.test.ts | 67 +++++++ .../dashboard/build-dashboard-snapshot.ts | 30 +++- .../dashboard/useAgentBucketCounts.ts | 5 +- .../useDashboardPopoutBridge.test.tsx | 1 + .../dashboard/useDashboardPopoutBridge.ts | 2 + .../useLiveDashboardSnapshot.test.ts | 126 +++++++++++++ .../dashboard/useLiveDashboardSnapshot.ts | 6 +- src/shared/dashboard-snapshot.ts | 15 +- 14 files changed, 635 insertions(+), 87 deletions(-) create mode 100644 src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 3ae3e85fdf1..afdf4b3661c 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -50,6 +50,56 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('accepts repo icons a pop-out can safely render, and rejects the rest', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + repoIconsByRepoId: { + 'repo-1': { type: 'lucide', name: 'Rocket' }, + 'repo-2': null, + 'repo-3': { + type: 'image', + src: 'https://github.com/anthropics.png?size=64', + source: 'github' + } + } + }) + ).toBe(true) + // Absent entirely: a pop-out on older code still gets its snapshot. + expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: undefined })).toBe(true) + + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + repoIconsByRepoId: { + 'repo-1': { type: 'image', src: 'javascript:alert(1)', source: 'file' } + } + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + repoIconsByRepoId: { 'repo-1': { type: 'nonsense' } } + }) + ).toBe(false) + expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBe(false) + }) + + it('bounds the conversation name', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], conversationName: 'Sparse-checkout parser' }] + }) + ).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], conversationName: 'x'.repeat(1_025) }] + }) + ).toBe(false) + }) + it('requires complete bounded reveal routing', () => { expect( isDashboardRevealAgentArgs({ diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 54168d1904a..69abed2c930 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -1,4 +1,5 @@ import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot' +import { sanitizeRepoIcon } from '../../shared/repo-icon' import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, @@ -7,6 +8,7 @@ import { } from '../../shared/agent-status-types' const MAX_DASHBOARD_CARDS = 1_000 +const MAX_DASHBOARD_REPO_ICONS = 500 const MAX_ID_LENGTH = 4_096 const MAX_LABEL_LENGTH = 1_024 const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle']) @@ -50,7 +52,28 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot isFiniteNumber(snapshot.generatedAt) && Array.isArray(snapshot.cards) && snapshot.cards.length <= MAX_DASHBOARD_CARDS && - snapshot.cards.every(isDashboardCard) + snapshot.cards.every(isDashboardCard) && + isDashboardRepoIcons(snapshot.repoIconsByRepoId) + ) +} + +/** Repo icons reach the pop-out's ``, so each one must survive the + * same sanitizer the settings picker writes through. */ +function isDashboardRepoIcons(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entries = Object.entries(value as Record) + return ( + entries.length <= MAX_DASHBOARD_REPO_ICONS && + entries.every( + ([repoId, icon]) => + isBoundedString(repoId, MAX_ID_LENGTH) && + (icon === null || sanitizeRepoIcon(icon) !== undefined) + ) ) } @@ -80,6 +103,7 @@ function isDashboardCard(value: unknown): boolean { (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && typeof card.unseen === 'boolean' && - isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) + isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) && + isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) ) } diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index 9a5eed15216..941d3cfec03 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { AgentKanbanBoard } from './AgentKanbanBoard' // Stub the card and dialog so the board test stays free of xterm / Radix @@ -11,10 +12,12 @@ import { AgentKanbanBoard } from './AgentKanbanBoard' vi.mock('./AgentKanbanCard', () => ({ AgentKanbanCard: ({ card, + repoIcon, now, onOpenTerminal }: { card: DashboardCard + repoIcon?: RepoIcon | null now: number onOpenTerminal: (card: DashboardCard) => void }) => ( @@ -23,6 +26,7 @@ vi.mock('./AgentKanbanCard', () => ({ data-bucket={card.bucket} data-unseen={card.unseen} data-now={now} + data-repo-icon={repoIcon === null ? 'none' : JSON.stringify(repoIcon)} onClick={() => onOpenTerminal(card)} > {card.worktreeName} @@ -70,8 +74,11 @@ function card(overrides: Partial): DashboardCard { } } -function renderBoard(cards: DashboardCard[]): void { - const snapshot: DashboardSnapshot = { generatedAt: 1, cards } +function renderBoard( + cards: DashboardCard[], + repoIconsByRepoId?: Record +): void { + const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId } render() } @@ -108,6 +115,30 @@ describe('AgentKanbanBoard', () => { expect(screen.getByText('3 total')).toBeTruthy() }) + it('leaves every column border neutral now that cards carry the state color', () => { + renderBoard([card({ bucket: 'attention' })]) + for (const column of document.querySelectorAll('section')) { + expect(column.className).toContain('border-border/60') + expect(column.className).not.toContain('amber') + } + }) + + it('routes each card its own repo icon', () => { + renderBoard( + [ + card({ repoId: 'r1', worktreeName: 'from-r1' }), + card({ repoId: 'r2', worktreeName: 'from-r2' }), + card({ repoId: 'r3', worktreeName: 'from-r3' }) + ], + { r1: { type: 'lucide', name: 'Rocket' }, r2: null } + ) + + expect(screen.getByText('from-r1').dataset.repoIcon).toBe('{"type":"lucide","name":"Rocket"}') + expect(screen.getByText('from-r2').dataset.repoIcon).toBe('none') + // Unknown repo → the card's own default glyph, never another repo's icon. + expect(screen.getByText('from-r3').dataset.repoIcon).toBe('none') + }) + it('shows "None" for empty columns', () => { renderBoard([card({ bucket: 'working' })]) // attention and idle are empty → two "None" placeholders. diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 01c46072c9d..b43fc971efb 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -6,7 +6,9 @@ import { type DashboardCard, type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { cn } from '@/lib/utils' +import { TooltipProvider } from '@/components/ui/tooltip' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { AgentKanbanCard } from './AgentKanbanCard' import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' @@ -58,22 +60,20 @@ function groupByBucket(cards: DashboardCard[]): Record | undefined now: number onOpenTerminal: (card: DashboardCard) => void }): React.JSX.Element { - const highlight = bucket === 'attention' && cards.length > 0 return ( -
+ // Why: attention no longer tints the whole column — the cards inside carry + // their own state color, so a column border would double-signal it. +
{bucketLabel(bucket)} @@ -92,6 +92,7 @@ function KanbanColumn({ @@ -192,54 +193,60 @@ export function AgentKanbanBoard({ }, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent]) return ( -
-
-

- {translate('dashboardPopout.title', 'Agents')} -

- - {translate('dashboardPopout.total', '{{count}} total', { - count: snapshot.cards.length - })} - - {headerActions || onClose ? ( -
- {headerActions} - {onClose ? ( - - ) : null} -
- ) : null} -
-
- {/* Why: columns share the window width up to a readable cap; mx-auto + // Why: the pop-out is its own React root with no app-level provider, and the + // card's repo tooltip needs one in both hosts. Nesting inside the main + // window's provider is harmless. + +
+
+

+ {translate('dashboardPopout.title', 'Agents')} +

+ + {translate('dashboardPopout.total', '{{count}} total', { + count: snapshot.cards.length + })} + + {headerActions || onClose ? ( +
+ {headerActions} + {onClose ? ( + + ) : null} +
+ ) : null} +
+
+ {/* Why: columns share the window width up to a readable cap; mx-auto centers the capped board so leftover space splits evenly instead of pooling on the right. In overflow the auto margins collapse to 0, keeping the left edge reachable while scrolling. */} -
- {DASHBOARD_BUCKET_ORDER.map((bucket) => ( - - ))} +
+ {DASHBOARD_BUCKET_ORDER.map((bucket) => ( + + ))} +
+
- -
+
) } diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index ebcfee3d26b..16739e15d99 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' +import { TooltipProvider } from '@/components/ui/tooltip' import { AgentKanbanCard } from './AgentKanbanCard' const agentIconRender = vi.fn() @@ -42,6 +44,24 @@ function card(overrides: Partial = {}): DashboardCard { } } +function renderCard(props: { + card: DashboardCard + now: number + repoIcon?: RepoIcon | null + onOpenTerminal?: () => void +}): ReturnType { + return render( + + + + ) +} + describe('AgentKanbanCard', () => { beforeEach(async () => { await i18n.changeLanguage('en') @@ -53,9 +73,7 @@ describe('AgentKanbanCard', () => { }) it('does not render an invented age when the start time is unknown', () => { - render( - - ) + renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 }) expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument() }) @@ -66,47 +84,160 @@ describe('AgentKanbanCard', () => { dotState: 'waiting', askSummary: 'Approve deploy?' }) - const { container, rerender } = render( - - ) + const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 }) expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument() expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1) rerender( - + + + ) expect(screen.getByTestId('state-dot')).toBeInTheDocument() expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull() }) + it('tints attention amber and done green, leaving every other state neutral', () => { + const { container: attention } = renderCard({ + card: card({ bucket: 'attention', dotState: 'waiting' }), + now: 2_000 + }) + expect(attention.querySelector('button')?.className).toContain('border-amber-500/40') + + cleanup() + const { container: done } = renderCard({ + card: card({ bucket: 'idle', dotState: 'done' }), + now: 2_000 + }) + expect(done.querySelector('button')?.className).toContain('border-emerald-500/40') + + cleanup() + const { container: idle } = renderCard({ + card: card({ bucket: 'idle', dotState: 'idle' }), + now: 2_000 + }) + const idleClassName = idle.querySelector('button')?.className ?? '' + expect(idleClassName).toContain('border-border/60') + expect(idleClassName).not.toContain('emerald') + expect(idleClassName).not.toContain('amber') + }) + + it('heads the card with the conversation name and drops the worktree to the footer', () => { + const { container } = renderCard({ + card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }), + now: 2_000 + }) + + const [header, footer] = [ + container.querySelector('button')!.firstElementChild!, + container.querySelector('button')!.lastElementChild! + ] + expect(header).toHaveTextContent('Sparse-checkout parser') + expect(header).not.toHaveTextContent('dashboard-review') + expect(footer).toHaveTextContent('dashboard-review') + // The message line is attributed to the user again — the name moved up. + expect(screen.getByText('You')).toBeInTheDocument() + }) + + it('heads the card with the worktree when no name resolves, without repeating it', () => { + const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 }) + + expect(screen.getAllByText('dashboard-review')).toHaveLength(1) + expect(container.querySelector('button')!.firstElementChild).toHaveTextContent( + 'dashboard-review' + ) + }) + + it('shows the repo as an icon labelled with its name instead of inline text', () => { + renderCard({ + card: card(), + now: 2_000, + repoIcon: { type: 'emoji', emoji: '🐳' } + }) + + expect(screen.getByLabelText('Orca')).toBeInTheDocument() + expect(screen.getByText('🐳')).toBeInTheDocument() + }) + it('skips structured-clone rerenders until visible card data or its age changes', () => { const onOpenTerminal = vi.fn() const initial = card({ startedAt: 1_000 }) + const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' } const { rerender } = render( - + + + ) expect(agentIconRender).toHaveBeenCalledTimes(1) expect(screen.getByText('1m')).toBeInTheDocument() - rerender() + // A fresh structured clone of identical data — including the repo icon. + rerender( + + + + ) expect(agentIconRender).toHaveBeenCalledTimes(1) rerender( - + + + ) expect(agentIconRender).toHaveBeenCalledTimes(2) expect(screen.getByText('2m')).toBeInTheDocument() }) - it('updates the relative age when the UI language changes', async () => { - render( - + it('rerenders when the repo icon changes', () => { + const onOpenTerminal = vi.fn() + const initial = card({ startedAt: 1_000 }) + const { rerender } = render( + + + ) + expect(agentIconRender).toHaveBeenCalledTimes(1) + + rerender( + + + + ) + expect(agentIconRender).toHaveBeenCalledTimes(2) + }) + + it('updates the relative age when the UI language changes', async () => { + renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 }) expect(screen.getByText('2m')).toBeInTheDocument() await act(async () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index e8d08b525d6..9262077e17d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -4,8 +4,11 @@ import { MessageCircleQuestion } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { AgentStateDot } from '@/components/AgentStateDot' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' /** Compact "started N ago" (the card is glanceable — coarse units are fine). */ @@ -53,12 +56,34 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.finishedAt === b.finishedAt && a.stateChangedAt === b.stateChangedAt && a.unseen === b.unseen && - a.askSummary === b.askSummary + a.askSummary === b.askSummary && + a.conversationName === b.conversationName ) } +/** Structural — the icon arrives inside a fresh structured clone each publish, + * so identity alone would re-render every card several times a second. */ +function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean { + if (a === b) { + return true + } + if (!a || !b || a.type !== b.type) { + return false + } + if (a.type === 'lucide') { + return a.name === (b as typeof a).name + } + if (a.type === 'emoji') { + return a.emoji === (b as typeof a).emoji + } + const image = b as typeof a + return a.src === image.src && a.source === image.source && a.label === image.label +} + type AgentKanbanCardProps = { card: DashboardCard + /** The card repo's icon. null renders the default folder glyph. */ + repoIcon?: RepoIcon | null now: number /** Opens the board-level terminal dialog. The dialog is NOT owned by the * card: bucket moves remount the card, and an embedded dialog would close @@ -68,8 +93,23 @@ type AgentKanbanCardProps = { /** One agent on the kanban board. Clicking opens the board's live terminal dialog. */ export const AgentKanbanCard = memo( - function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element { + function AgentKanbanCard({ + card, + repoIcon = null, + now, + onOpenTerminal + }: AgentKanbanCardProps): React.JSX.Element { useTranslation() + // Why: the two outcomes worth scanning for get a tinted card — amber for + // "answer me", green for "finished, look at it". Everything else stays + // neutral so the tint keeps meaning something. + const needsYou = card.bucket === 'attention' + const isDone = card.dotState === 'done' + // Why: the session's own name heads the card. Without one the worktree is + // the best heading left — and then the footer drops it rather than say it + // twice. + const heading = card.conversationName ?? card.worktreeName + const worktreeInFooter = card.conversationName !== undefined return (