From 3fba1952c8096d9ecac3fef20a01e7e671db4b4b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:47:05 +0000 Subject: [PATCH 001/220] perf(tab-bar): a change to one tab no longer re-renders every tab (#24261) With many tabs open, a change to any one tab (a retitle, an agent finishing, a tab switch, a git status write, or a browser tab update on SSH and web clients) re-rendered every tab in the strip, so the strip stuttered. Each tab is now a memoized row that re-renders only when its own values change, with stable handlers, a stable drag id list and stable drag sensor options. Editor tabs get their own git status, and mirrored browser tabs keep their page-id list while the ids don't change. Part of #24241: opening, closing or reordering a tab still re-renders every tab once. --- .../components/tab-bar/EditorFileTab.test.tsx | 2 +- .../src/components/tab-bar/EditorFileTab.tsx | 10 +- ...Bar.client-hosted-row-active-state.test.ts | 2 +- .../tab-bar/TabBar.context-menu.test.ts | 5 + .../src/components/tab-bar/TabBar.tsx | 8 +- .../components/tab-bar/TabBarItemRow.test.tsx | 379 ++++++++++++++++++ .../src/components/tab-bar/TabBarItemRow.tsx | 197 +++++++++ .../components/tab-bar/tab-bar-item-model.ts | 19 + ...urface.client-hosted-active-state.test.tsx | 166 -------- .../tab-bar/tab-bar-item-surface.tsx | 312 ++++---------- .../components/tab-bar/tab-bar-surface.tsx | 10 +- .../tab-bar/tab-title-tooltip.test.tsx | 3 +- .../tab-bar/use-tab-bar-item-actions.ts | 92 +++++ .../use-tab-bar-item-projection.test.tsx | 95 +++++ .../tab-bar/use-tab-bar-item-projection.ts | 10 +- .../tab-group/useTabDragSplit.test.ts | 20 + .../components/tab-group/useTabDragSplit.ts | 12 +- ...-session-tabs-sync-mirror-identity.test.ts | 32 ++ .../mirrored-browser-tabs.ts | 8 +- tests/e2e/helpers/runtime-types.ts | 3 + tests/e2e/helpers/tab-render-recorder.ts | 80 ++++ .../tab-strip-tab-render-isolation.spec.ts | 97 +++++ 22 files changed, 1142 insertions(+), 420 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabBarItemRow.tsx delete mode 100644 src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx create mode 100644 src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts create mode 100644 src/renderer/src/components/tab-bar/use-tab-bar-item-projection.test.tsx create mode 100644 tests/e2e/helpers/tab-render-recorder.ts create mode 100644 tests/e2e/tab-strip-tab-render-isolation.spec.ts diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 8f80a6ce670..f8d930b044c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -260,7 +260,7 @@ async function renderEditorFileTab( hasTabsToRight: false, hasTabsToLeft: false, tabCount: 1, - statusByRelativePath: new Map(), + gitStatus: null, onActivate, onClose: () => {}, onCloseOthers: () => {}, diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index a9ee9eb37cd..34d173c246a 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable' import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { basename, normalizeRelativePath } from '@/lib/path' +import { basename } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' @@ -40,7 +40,7 @@ export default function EditorFileTab({ hasTabsToRight, hasTabsToLeft, tabCount, - statusByRelativePath, + gitStatus: tabStatus, onActivate, onClose, onCloseOthers, @@ -59,7 +59,7 @@ export default function EditorFileTab({ hasTabsToRight: boolean hasTabsToLeft: boolean tabCount: number - statusByRelativePath: Map + gitStatus: GitFileStatus | null onActivate: () => void onClose: () => void onCloseOthers: () => void @@ -196,10 +196,6 @@ export default function EditorFileTab({ [file.filePath] ) - const tabStatus = - file.relativePath === 'All Changes' - ? null - : (statusByRelativePath.get(normalizeRelativePath(file.relativePath)) ?? null) const tabStatusColor = tabStatus ? STATUS_COLORS[tabStatus] : undefined const tabLabel = getEditorDisplayLabel(file) diff --git a/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts b/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts index c856727e289..41bd5d796af 100644 --- a/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts @@ -107,7 +107,7 @@ async function renderTerminalStrip(): Promise | null> { onSetTabColor: () => {}, onTogglePaneExpand: () => {} }), - 'SortableTab' + 'TabBarItemRow' ) } diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 8b96951efc4..312dccfafed 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -53,6 +53,11 @@ const useAppStoreMock = vi.fn( vi.mock('react', async () => await stubHeadlessReact()) vi.mock('zustand/react/shallow', () => stubShallowSelector()) +// The headless React stub has no dispatcher for the hook each tab row subscribes to language changes with. +vi.mock('react-i18next', async () => ({ + ...(await vi.importActual>('react-i18next')), + useTranslation: () => ({}) +})) vi.mock('lucide-react', async () => (await import('./lucide-icon-stub-fixture')).stubEveryIcon()) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 923afdf7ba7..99e22b3f29e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -7,6 +7,7 @@ import { useTabBarRuntimeModel } from './use-tab-bar-runtime-model' import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller' import { useTabBarItemProjection } from './use-tab-bar-item-projection' import { renderTabBarSurface } from './tab-bar-surface' +import { useTabBarItemActions } from './use-tab-bar-item-actions' import { useActiveClientHostedBrowserRowId } from '@/lib/pane-manager/client-hosted-browser-row-state' function TabBarInner(props: TabBarProps): React.JSX.Element { @@ -64,6 +65,11 @@ function TabBarInner(props: TabBarProps): React.JSX.Element { } runtime.pinTab(item.unifiedTabId) } + const itemActions = useTabBarItemActions({ + props, + togglePinned, + toggleTabViewMode: runtime.toggleTabViewMode + }) // Read here, not just where the rows render: the real tabs have to know when a row took over. const activeClientHostedBrowserRowId = useActiveClientHostedBrowserRowId({ worktreeId, @@ -92,7 +98,7 @@ function TabBarInner(props: TabBarProps): React.JSX.Element { tabStripNavigation, tabStripDragScroll, activeClientHostedBrowserRowId, - togglePinned + itemActions }) } diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx new file mode 100644 index 00000000000..a095d4e2e6f --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx @@ -0,0 +1,379 @@ +// @vitest-environment happy-dom + +import { act, startTransition, Suspense, use } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + clearClientHostedBrowserRowSelection, + getClientHostedBrowserRowSelection, + selectClientHostedBrowserRow +} from '@/lib/pane-manager/client-hosted-browser-row-state' +import { i18n } from '@/i18n/i18n' +import type { TabBarItem } from './tab-bar-item-model' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' +import { + renderTabBarItems, + type TabBarItemSurfaceProps, + type TabBarItemSurfaceRuntime +} from './tab-bar-item-surface' +import { useTabBarItemActions } from './use-tab-bar-item-actions' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +type TabProps = { + tab?: { id: string; title: string } + isActive: boolean + isPinned: boolean + onActivate: (id: string) => void + onDuplicate?: () => void + gitStatus?: string | null +} + +// Every render of every tab, in order, keyed by the id the strip shows it under. +const tabRenders: { id: string; props: TabProps }[] = [] + +vi.mock('./SortableTab', () => ({ + default: (props: TabProps) => { + tabRenders.push({ id: props.tab!.id, props }) + return null + } +})) +vi.mock('./BrowserTab', () => ({ + default: (props: TabProps) => { + tabRenders.push({ id: props.tab!.id, props }) + return null + }, + getBrowserTabLabel: () => '' +})) +vi.mock('./EditorFileTab', () => ({ + default: (props: TabProps & { file: { id: string } }) => { + tabRenders.push({ id: props.file.id, props }) + return null + } +})) + +/** Rebuilt on every call, like the strip's projections: equal content, never the same objects. */ +function buildItems({ terminalPinned = false, browserTitle = 'Example' } = {}): TabBarItem[] { + return [ + { + type: 'terminal', + id: 'terminal-1', + unifiedTabId: 'unified-terminal-1', + isPinned: terminalPinned, + data: { + id: 'terminal-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'zsh', + generatedTitle: 'Fix the login bug', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + }, + { + type: 'browser', + id: 'browser-1', + unifiedTabId: 'unified-browser-1', + isPinned: false, + data: { + id: 'browser-1', + worktreeId: 'wt-1', + url: 'https://example.test', + title: browserTitle, + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + }, + { + type: 'editor', + id: 'file-1', + unifiedTabId: 'unified-file-1', + isPinned: false, + data: { + id: 'file-1', + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + isPreview: false, + isDirty: false, + mode: 'edit' + } + }, + { + type: 'agent-session', + id: 'session-1', + unifiedTabId: 'session-1', + isPinned: false, + data: { + id: 'session-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + label: 'Session', + customLabel: null, + color: null, + sortOrder: 3, + createdAt: 0 + } + } + ] +} + +type StripInputs = { + onActivate?: (id: string) => void + generatedTabTitlesEnabled?: boolean + managedBrowserCreationEnabled?: boolean + terminalPinned?: boolean + browserTitle?: string + activeTabType?: WorkspaceVisibleTabType + activeClientHostedBrowserRowId?: string | null + statusByRelativePath?: TabBarItemSurfaceRuntime['statusByRelativePath'] +} + +function Strip({ + onActivate = NOOP, + generatedTabTitlesEnabled = false, + managedBrowserCreationEnabled = false, + terminalPinned, + browserTitle, + activeTabType = 'terminal', + activeClientHostedBrowserRowId = null, + statusByRelativePath = STATUS_BY_RELATIVE_PATH +}: StripInputs): React.JSX.Element { + const props: TabBarItemSurfaceProps = { + worktreeId: 'wt-1', + activeTabId: 'terminal-1', + activeFileId: 'file-1', + activeBrowserTabId: 'browser-1', + activeSimulatorTabId: null, + activeTabType, + expandedPaneByTabId: {} + } + const runtime: TabBarItemSurfaceRuntime = { + resolvedGroupId: 'group-1', + generatedTabTitlesEnabled, + unifiedTabByVisibleId: new Map(), + nativeChatEnabled: false, + tabAgentTypesByTabId: {}, + nativeChatTabWideFallbackUnsafeTabsById: {}, + nativeChatTranscriptIsLocalReadable: false, + managedBrowserCreationEnabled, + statusByRelativePath + } + const actions = useTabBarItemActions({ + props: { + onActivate, + onActivateFile: onActivate, + onActivateBrowserTab: onActivate, + onActivateAgentSession: onActivate, + onClose: NOOP, + onCloseOthers: NOOP, + onCloseToRight: NOOP, + onCloseToLeft: NOOP, + onSetCustomTitle: NOOP, + onSetTabColor: NOOP, + onTogglePaneExpand: NOOP + }, + togglePinned: NOOP, + toggleTabViewMode: NOOP + }) + return ( + <> + {renderTabBarItems({ + items: buildItems({ terminalPinned, browserTitle }), + props, + runtime, + actions, + dropIndicatorByVisibleId: new Map(), + includeTopTabBorder: true, + activeClientHostedBrowserRowId + })} + + ) +} + +const NOOP = (): void => {} +const STATUS_BY_RELATIVE_PATH: TabBarItemSurfaceRuntime['statusByRelativePath'] = new Map() +const TAB_IDS = ['terminal-1', 'browser-1', 'file-1', 'session-1'] +let root: Root | null = null + +const NEVER_RESOLVES = new Promise(() => {}) + +function Suspended(): null { + use(NEVER_RESOLVES) + return null +} + +function stripTree(inputs: StripInputs, suspended = false): React.JSX.Element { + return ( + + + {suspended ? : null} + + ) +} + +function renderStrip(inputs: StripInputs = {}): void { + if (!root) { + root = createRoot(document.createElement('div')) + } + act(() => root!.render(stripTree(inputs))) +} + +function lastRender(tabId: string): TabProps { + return tabRenders.findLast((render) => render.id === tabId)!.props +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + tabRenders.length = 0 + clearClientHostedBrowserRowSelection() +}) + +describe('tab strip rows', () => { + it('skips every tab when the strip re-renders with equal tab content', () => { + renderStrip() + expect(tabRenders.map((render) => render.id)).toEqual(TAB_IDS) + + renderStrip({ onActivate: vi.fn() }) + + expect(tabRenders).toHaveLength(TAB_IDS.length) + }) + + it('calls the handler the strip has now, from a tab that skipped its render', () => { + const first = vi.fn() + const current = vi.fn() + renderStrip({ onActivate: first }) + renderStrip({ onActivate: current }) + + lastRender('terminal-1').onActivate('terminal-1') + + expect(current).toHaveBeenCalledWith('terminal-1') + expect(first).not.toHaveBeenCalled() + }) + + it('keeps the committed handler when React abandons a render', async () => { + const committed = vi.fn() + const abandoned = vi.fn() + renderStrip({ onActivate: committed }) + await act(async () => + startTransition(() => root!.render(stripTree({ onActivate: abandoned }, true))) + ) + + lastRender('terminal-1').onActivate('terminal-1') + + expect(committed).toHaveBeenCalledWith('terminal-1') + expect(abandoned).not.toHaveBeenCalled() + }) + + it('re-renders every tab when the language is re-applied, as a language-pack reload does', async () => { + renderStrip() + + await act(() => i18n.changeLanguage(i18n.language)) + + expect(tabRenders.map((render) => render.id)).toEqual([...TAB_IDS, ...TAB_IDS]) + }) + + it('re-renders a terminal tab when the generated-titles setting changes its title', () => { + renderStrip() + expect(lastRender('terminal-1').tab?.title).toBe('zsh') + + renderStrip({ generatedTabTitlesEnabled: true }) + + expect(lastRender('terminal-1').tab?.title).toBe('Fix the login bug') + }) + + it('re-renders a browser tab when duplicating becomes available', () => { + renderStrip() + expect(lastRender('browser-1').onDuplicate).toBeUndefined() + + renderStrip({ managedBrowserCreationEnabled: true }) + + expect(lastRender('browser-1').onDuplicate).toBeTypeOf('function') + }) + + it('re-renders a tab when it is pinned', () => { + renderStrip() + expect(lastRender('terminal-1').isPinned).toBe(false) + + renderStrip({ terminalPinned: true }) + + expect(lastRender('terminal-1').isPinned).toBe(true) + }) + + it('re-renders only the two tabs a switch moves the active state between', () => { + renderStrip({ activeTabType: 'terminal' }) + tabRenders.length = 0 + + renderStrip({ activeTabType: 'browser' }) + + expect(tabRenders.map((render) => render.id)).toEqual(['terminal-1', 'browser-1']) + }) + + it('re-renders a tab when its own data changes', () => { + renderStrip() + + renderStrip({ browserTitle: 'Renamed page' }) + + expect(lastRender('browser-1').tab?.title).toBe('Renamed page') + expect(tabRenders).toHaveLength(TAB_IDS.length + 1) + }) + + it('re-renders an editor tab only when a git status write changes its own status', () => { + renderStrip() + + renderStrip({ statusByRelativePath: new Map([['other.md', 'modified']]) }) + expect(tabRenders).toHaveLength(TAB_IDS.length) + + renderStrip({ statusByRelativePath: new Map([['notes.md', 'modified']]) }) + expect(lastRender('file-1').gitStatus).toBe('modified') + expect(tabRenders).toHaveLength(TAB_IDS.length + 1) + }) + + it.each(TAB_IDS)('retires a client-hosted row selection when %s is activated', (tabId) => { + renderStrip() + selectClientHostedBrowserRow({ + worktreeId: 'wt-1', + browserPageId: 'page-1', + groupId: 'group-1', + groupActiveTabIdAtSelection: 'unified-terminal-1' + }) + + lastRender(tabId).onActivate(tabId) + + expect(getClientHostedBrowserRowSelection()).toBeNull() + }) +}) + +/** + * A client-hosted row covers the pane without moving the group's `activeTabId`, so the strip's two + * halves would each keep painting an underline — the reported double highlight. + */ +describe('real tabs while a client-hosted row is selected', () => { + const activeFlags = (): boolean[] => TAB_IDS.map((tabId) => lastRender(tabId).isActive) + + it.each(['terminal', 'browser', 'editor'])( + 'underlines the %s tab the group is actually showing when no row is selected', + (activeTabType) => { + renderStrip({ activeTabType }) + expect(activeFlags().filter(Boolean)).toHaveLength(1) + } + ) + + it.each(['terminal', 'browser', 'editor'])( + 'renders the %s tab inactive while a row owns the pane', + (activeTabType) => { + renderStrip({ activeTabType, activeClientHostedBrowserRowId: 'page-1' }) + expect(activeFlags()).toEqual([false, false, false, false]) + } + ) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx new file mode 100644 index 00000000000..ead0645c7a5 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx @@ -0,0 +1,197 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { shallow } from 'zustand/shallow' +import type { GitFileStatus } from '../../../../shared/git-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import type { OpenFile } from '../../store/slices/editor' +import SortableTab from './SortableTab' +import EditorFileTab from './EditorFileTab' +import BrowserTab from './BrowserTab' +import type { DropIndicator } from './drop-indicator' +import type { TabDragItemData } from '../tab-group/useTabDragSplit' +import { getTabDragLabel, resolveTerminalItemTab, type TabBarItem } from './tab-bar-item-model' +import type { TabBarItemActions } from './use-tab-bar-item-actions' + +// Why only values and `actions`: anything a tab draws must be a compared prop, or a skipped render shows it stale. +type TabBarItemRowProps = { + item: TabBarItem + actions: TabBarItemActions + worktreeId: string + groupId: string + generatedTabTitlesEnabled: boolean + tabCount: number + hasTabsToLeft: boolean + hasTabsToRight: boolean + isActive: boolean + isExpanded: boolean + dropIndicator: DropIndicator + includeTopTabBorder: boolean + canToggleViewMode: boolean + isChatView: boolean + /** Unified tab whose view mode the terminal tab toggles; absent when it has none. */ + viewModeTabId: string | undefined + canDuplicate: boolean + /** This editor tab's own status, so a git status write re-renders only the tabs it changed. */ + gitStatus: GitFileStatus | null +} + +function TabBarItemRow({ + item, + actions, + worktreeId, + groupId, + generatedTabTitlesEnabled, + tabCount, + hasTabsToLeft, + hasTabsToRight, + isActive, + isExpanded, + dropIndicator, + includeTopTabBorder, + canToggleViewMode, + isChatView, + viewModeTabId, + canDuplicate, + gitStatus +}: TabBarItemRowProps): React.JSX.Element { + // Why: the tabs' labels come from `translate()`, which a skipped render would leave in the old language. + useTranslation() + const dragData: TabDragItemData = { + kind: 'tab', + worktreeId, + groupId, + unifiedTabId: item.unifiedTabId, + visibleTabId: item.id, + tabType: item.type, + label: getTabDragLabel(item, generatedTabTitlesEnabled), + iconPath: item.type === 'editor' ? item.data.filePath : undefined, + color: item.type === 'terminal' ? (item.data.color ?? null) : null + } + const shared = { + isActive, + isPinned: item.isPinned, + hasTabsToRight, + hasTabsToLeft, + tabCount, + onTogglePin: () => actions.togglePinned(item), + dragData, + dropIndicator, + includeTopTabBorder + } + const sortableTabProps = { + ...shared, + unifiedTabId: item.unifiedTabId, + groupId, + onClose: actions.close, + onCloseOthers: actions.closeOthers, + onCloseToRight: actions.closeToRight, + onCloseToLeft: actions.closeToLeft, + onSetCustomTitle: actions.setCustomTitle, + onSetTabColor: actions.setTabColor + } + if (item.type === 'terminal') { + return ( + actions.toggleViewMode(viewModeTabId) : undefined} + isExpanded={isExpanded} + onActivate={actions.activateTerminal} + onToggleExpand={actions.togglePaneExpand} + /> + ) + } + if (item.type === 'agent-session') { + const structuredTab: TerminalTab = { + id: item.id, + ptyId: null, + worktreeId, + title: item.data.label, + customTitle: item.data.customLabel, + color: item.data.color, + sortOrder: item.data.sortOrder, + createdAt: item.data.createdAt, + ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) + ? { launchAgent: item.data.agentSessionAgent as TuiAgent } + : {}) + } + return ( + {}} + canSplitTerminal={false} + /> + ) + } + const closeScope = { + onCloseOthers: () => actions.closeOthers(item.id), + onCloseToRight: () => actions.closeToRight(item.id), + onCloseToLeft: () => actions.closeToLeft(item.id) + } + if (item.type === 'browser') { + return ( + actions.activateBrowserTab(item.id)} + onClose={() => actions.closeBrowserTab(item.id)} + onDuplicate={ + canDuplicate ? () => actions.duplicateBrowserTab(item.id, item.unifiedTabId) : undefined + } + /> + ) + } + const fileTabProps = { + ...shared, + ...closeScope, + gitStatus, + onActivate: () => actions.activateFile(item.id), + onClose: () => actions.closeFile(item.id), + onCloseAll: actions.closeAllFiles + } + if (item.type === 'simulator') { + const simulatorLabel = item.data.label || 'Mobile Emulator' + const simulatorFile: OpenFile & { tabId: string } = { + id: item.id, + tabId: item.id, + filePath: simulatorLabel, + relativePath: simulatorLabel, + worktreeId, + language: 'simulator', + isPreview: false, + isDirty: false, + mode: 'edit' + } + return {}} /> + } + return ( + actions.makePreviewFilePermanent(item.data.id, item.data.tabId)} + /> + ) +} + +// Why field-by-field: the strip rebuilds every `item` and its `data` on any tab write, so identity never survives. +function sameTabBarItemRowProps(previous: TabBarItemRowProps, next: TabBarItemRowProps): boolean { + const { item: previousItem, ...previousRest } = previous + const { item: nextItem, ...nextRest } = next + const { data: previousData, ...previousIdentity } = previousItem + const { data: nextData, ...nextIdentity } = nextItem + return ( + shallow(previousRest, nextRest) && + shallow(previousIdentity, nextIdentity) && + shallow(previousData, nextData) + ) +} + +export default memo(TabBarItemRow, sameTabBarItemRowProps) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts index 3789d89b7de..6967197e7a9 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -1,4 +1,5 @@ import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' +import type { GitFileStatus } from '../../../../shared/git-status-types' import type { Tab, WorkspaceVisibleTabType } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' @@ -48,6 +49,24 @@ export type TabBarItem = data: Tab & { contentType: 'agent-session' } } +/** The terminal tab as the strip shows it: its title resolved against the generated-titles setting. */ +export function resolveTerminalItemTab( + tab: TerminalTab & { unifiedTabId?: string }, + generatedTitlesEnabled: boolean +): TerminalTab & { unifiedTabId?: string } { + return { ...tab, title: resolveTerminalTabTitle(tab, generatedTitlesEnabled, tab.title) } +} + +export function resolveEditorTabGitStatus( + relativePath: string, + statusByRelativePath: Map +): GitFileStatus | null { + if (relativePath === 'All Changes') { + return null + } + return statusByRelativePath.get(normalizeRelativePath(relativePath)) ?? null +} + export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolean): string { if (item.type === 'terminal') { return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx deleted file mode 100644 index a3f85044626..00000000000 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -// @vitest-environment happy-dom - -import type React from 'react' -import { afterEach, describe, expect, it } from 'vitest' -import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' -import { - clearClientHostedBrowserRowSelection, - getClientHostedBrowserRowSelection, - selectClientHostedBrowserRow -} from '@/lib/pane-manager/client-hosted-browser-row-state' -import type { TabBarItem } from './tab-bar-item-model' -import type { TabBarProps } from './tab-bar-props' -import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model' -import { renderTabBarItems } from './tab-bar-item-surface' - -const ITEMS: TabBarItem[] = [ - { - type: 'terminal', - id: 'terminal-1', - unifiedTabId: 'unified-terminal-1', - isPinned: false, - data: { - id: 'terminal-1', - ptyId: null, - worktreeId: 'wt-1', - title: 'Setup', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0 - } - }, - { - type: 'browser', - id: 'browser-1', - unifiedTabId: 'unified-browser-1', - isPinned: false, - data: { - id: 'browser-1', - worktreeId: 'wt-1', - url: 'https://example.test/', - title: 'Example Domain', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 0 - } - }, - { - type: 'editor', - id: 'file-1', - unifiedTabId: 'unified-file-1', - isPinned: false, - data: { - id: 'file-1', - filePath: '/repo/README.md', - relativePath: 'README.md', - worktreeId: 'wt-1', - language: 'markdown', - isPreview: false, - isDirty: false, - mode: 'edit' - } - } -] - -// Only the fields renderTabBarItems reads; nothing else reaches an isActive decision. -const RUNTIME = { - resolvedGroupId: 'group-1', - generatedTabTitlesEnabled: false, - unifiedTabByVisibleId: new Map(), - nativeChatEnabled: false, - tabAgentTypesByTabId: {}, - nativeChatTabWideFallbackUnsafeTabsById: {}, - nativeChatTranscriptIsLocalReadable: false, - managedBrowserCreationEnabled: false, - toggleTabViewMode: () => {}, - statusByRelativePath: new Map() -} as unknown as TabBarRuntimeModel - -function makeProps(activeTabType: WorkspaceVisibleTabType): TabBarProps { - return { - worktreeId: 'wt-1', - activeTabId: 'terminal-1', - activeFileId: 'file-1', - activeBrowserTabId: 'browser-1', - activeSimulatorTabId: null, - activeTabType, - groupActiveTabId: 'unified-terminal-1', - expandedPaneByTabId: {} - } as unknown as TabBarProps -} - -function activeFlags( - activeTabType: WorkspaceVisibleTabType, - activeClientHostedBrowserRowId: string | null -): boolean[] { - const rendered = renderTabBarItems({ - items: ITEMS, - props: makeProps(activeTabType), - runtime: RUNTIME, - dropIndicatorByVisibleId: new Map(), - includeTopTabBorder: true, - activeClientHostedBrowserRowId, - togglePinned: () => {} - }) - return rendered.map( - (node) => (node as React.ReactElement<{ isActive: boolean }>).props.isActive === true - ) -} - -afterEach(() => { - clearClientHostedBrowserRowSelection() -}) - -/** - * A client-hosted row covers the pane without moving the group's `activeTabId`, so the strip's two - * halves would each keep painting an underline — the reported double highlight. - */ -describe('real tabs while a client-hosted row is selected', () => { - it.each(['terminal', 'browser', 'editor'])( - 'underlines the %s tab the group is actually showing when no row is selected', - (activeTabType) => { - expect(activeFlags(activeTabType, null).filter(Boolean)).toHaveLength(1) - } - ) - - it.each(['terminal', 'browser', 'editor'])( - 'renders the %s tab inactive while a row owns the pane', - (activeTabType) => { - expect(activeFlags(activeTabType, 'page-1')).toEqual([false, false, false]) - } - ) -}) - -describe('client-hosted row while a real tab is activated', () => { - it.each(ITEMS.map((item, index) => [item.type, index] as const))( - 'retires the row selection when the %s tab is clicked', - (_type, index) => { - selectClientHostedBrowserRow({ - worktreeId: 'wt-1', - browserPageId: 'page-1', - groupId: 'group-1', - groupActiveTabIdAtSelection: 'unified-terminal-1' - }) - const rendered = renderTabBarItems({ - items: ITEMS, - props: makeProps('terminal'), - runtime: RUNTIME, - dropIndicatorByVisibleId: new Map(), - includeTopTabBorder: true, - activeClientHostedBrowserRowId: 'page-1', - togglePinned: () => {} - }) - - const clicked = rendered[index] as React.ReactElement<{ - onActivate: (id: string) => void - }> - clicked.props.onActivate('terminal-1') - - expect(getClientHostedBrowserRowSelection()).toBeNull() - } - ) -}) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index 449b9628ca8..7cd3c69cd35 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -1,37 +1,57 @@ import React from 'react' -import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' -import type { OpenFile } from '../../store/slices/editor' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { resolveNativeChatTabAgentEvidence } from './native-chat-tab-agent-evidence' -import SortableTab from './SortableTab' -import EditorFileTab from './EditorFileTab' -import BrowserTab from './BrowserTab' import type { DropIndicator } from './drop-indicator' -import type { TabDragItemData } from '../tab-group/useTabDragSplit' -import { getTabDragLabel, type TabBarItem } from './tab-bar-item-model' +import { + resolveEditorTabGitStatus, + resolveTerminalItemTab, + type TabBarItem +} from './tab-bar-item-model' import type { TabBarProps } from './tab-bar-props' import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model' -import { clearClientHostedBrowserRowSelection } from '@/lib/pane-manager/client-hosted-browser-row-state' +import type { TabBarItemActions } from './use-tab-bar-item-actions' +import TabBarItemRow from './TabBarItemRow' + +export type TabBarItemSurfaceProps = Pick< + TabBarProps, + | 'worktreeId' + | 'activeTabId' + | 'activeFileId' + | 'activeBrowserTabId' + | 'activeSimulatorTabId' + | 'activeTabType' + | 'expandedPaneByTabId' +> + +export type TabBarItemSurfaceRuntime = Pick< + TabBarRuntimeModel, + | 'resolvedGroupId' + | 'generatedTabTitlesEnabled' + | 'unifiedTabByVisibleId' + | 'nativeChatEnabled' + | 'tabAgentTypesByTabId' + | 'nativeChatTabWideFallbackUnsafeTabsById' + | 'nativeChatTranscriptIsLocalReadable' + | 'managedBrowserCreationEnabled' + | 'statusByRelativePath' +> export function renderTabBarItems({ items, props, runtime, + actions, dropIndicatorByVisibleId, includeTopTabBorder, - activeClientHostedBrowserRowId, - togglePinned + activeClientHostedBrowserRowId }: { items: TabBarItem[] - props: TabBarProps - runtime: TabBarRuntimeModel + props: TabBarItemSurfaceProps + runtime: TabBarItemSurfaceRuntime + actions: TabBarItemActions dropIndicatorByVisibleId: Map includeTopTabBorder: boolean activeClientHostedBrowserRowId: string | null - togglePinned: (item: TabBarItem) => void }): React.ReactNode[] { const { worktreeId, @@ -40,23 +60,7 @@ export function renderTabBarItems({ activeBrowserTabId, activeSimulatorTabId, activeTabType, - expandedPaneByTabId, - onActivate, - onClose, - onCloseOthers, - onCloseToRight, - onCloseToLeft, - onSetCustomTitle, - onSetTabColor, - onTogglePaneExpand, - onActivateFile, - onCloseFile, - onActivateBrowserTab, - onActivateAgentSession, - onCloseBrowserTab, - onDuplicateBrowserTab, - onCloseAllFiles, - onMakePreviewFilePermanent + expandedPaneByTabId } = props const { resolvedGroupId, @@ -66,7 +70,7 @@ export function renderTabBarItems({ tabAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById, nativeChatTranscriptIsLocalReadable, - toggleTabViewMode, + managedBrowserCreationEnabled, statusByRelativePath } = runtime @@ -74,40 +78,40 @@ export function renderTabBarItems({ // the group's own activeTabId never moves for it, and two underlines would show at once. const clientHostedRowOwnsActiveState = activeClientHostedBrowserRowId !== null - // Why: this is the strip's single activation fan-out, so retiring a client-hosted placeholder - // here covers every row kind — including re-clicking the tab that was already active, which the - // group's activeTabId never moves for. - function activateRealTab(activate: ((arg: TArg) => void) | undefined): (arg: TArg) => void { - return (arg) => { - clearClientHostedBrowserRowSelection() - activate?.(arg) + function isActiveItem(item: TabBarItem): boolean { + if (clientHostedRowOwnsActiveState) { + return false } + if (item.type === 'terminal') { + return ( + (activeTabType === 'terminal' || activeTabType === 'simulator') && item.id === activeTabId + ) + } + if (item.type === 'browser') { + return activeTabType === 'browser' && activeBrowserTabId === item.id + } + if (item.type === 'simulator') { + return activeTabType === 'simulator' && item.id === activeSimulatorTabId + } + if (item.type === 'agent-session') { + return activeTabType === 'agent-session' && item.id === activeTabId + } + return (activeTabType === 'editor' || activeTabType === 'simulator') && activeFileId === item.id } return items.map((item, index) => { - const dragData: TabDragItemData = { - kind: 'tab', - worktreeId, - groupId: resolvedGroupId, - unifiedTabId: item.unifiedTabId, - visibleTabId: item.id, - tabType: item.type, - label: getTabDragLabel(item, generatedTabTitlesEnabled), - iconPath: item.type === 'editor' ? item.data.filePath : undefined, - color: item.type === 'terminal' ? (item.data.color ?? null) : null - } + let canToggleViewMode = false + let isChatView = false + let viewModeTabId: string | undefined if (item.type === 'terminal') { - const terminalTab = { - ...item.data, - title: resolveTerminalTabTitle(item.data, generatedTabTitlesEnabled, item.data.title) - } + const terminalTab = resolveTerminalItemTab(item.data, generatedTabTitlesEnabled) const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. const resolvedAgent = resolveNativeChatTabAgentEvidence(terminalTab, unifiedTabForItem) // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true - const canToggleViewMode = + canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, @@ -118,185 +122,33 @@ export function renderTabBarItems({ nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) - return ( - toggleTabViewMode(unifiedTabForItem.id) : undefined - } - hasTabsToRight={index < items.length - 1} - hasTabsToLeft={index > 0} - isActive={ - !clientHostedRowOwnsActiveState && - (activeTabType === 'terminal' || activeTabType === 'simulator') && - item.id === activeTabId - } - isPinned={item.isPinned} - isExpanded={expandedPaneByTabId[item.id] === true} - onActivate={activateRealTab(onActivate)} - onClose={onClose} - onCloseOthers={onCloseOthers} - onCloseToRight={onCloseToRight} - onCloseToLeft={onCloseToLeft} - onSetCustomTitle={onSetCustomTitle} - onSetTabColor={onSetTabColor} - onTogglePin={() => togglePinned(item)} - onToggleExpand={onTogglePaneExpand} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - } - if (item.type === 'browser') { - return ( - 0} - tabCount={items.length} - onActivate={() => activateRealTab(onActivateBrowserTab)(item.id)} - onClose={() => onCloseBrowserTab?.(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onDuplicate={ - runtime.managedBrowserCreationEnabled - ? () => onDuplicateBrowserTab?.(item.id, item.unifiedTabId) - : undefined - } - onTogglePin={() => togglePinned(item)} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - } - if (item.type === 'simulator') { - const simulatorLabel = item.data.label || 'Mobile Emulator' - const simulatorFile: OpenFile & { tabId: string } = { - id: item.id, - tabId: item.id, - filePath: simulatorLabel, - relativePath: simulatorLabel, - worktreeId, - language: 'simulator', - isPreview: false, - isDirty: false, - mode: 'edit' - } - return ( - 0} - tabCount={items.length} - statusByRelativePath={statusByRelativePath} - onActivate={() => activateRealTab(onActivateFile)(item.id)} - onClose={() => onCloseFile?.(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onCloseAll={() => onCloseAllFiles?.()} - onMakePermanent={() => {}} - onTogglePin={() => togglePinned(item)} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - } - if (item.type === 'agent-session') { - const structuredTab: TerminalTab = { - id: item.id, - ptyId: null, - worktreeId, - title: item.data.label, - customTitle: item.data.customLabel, - color: item.data.color, - sortOrder: item.data.sortOrder, - createdAt: item.data.createdAt, - ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) - ? { launchAgent: item.data.agentSessionAgent as TuiAgent } - : {}) - } - return ( - 0} - isActive={ - !clientHostedRowOwnsActiveState && - activeTabType === 'agent-session' && - item.id === activeTabId - } - isPinned={item.isPinned} - isExpanded={false} - onActivate={() => activateRealTab(onActivateAgentSession)(item.id)} - onClose={() => onClose(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onSetCustomTitle={onSetCustomTitle} - onSetTabColor={onSetTabColor} - onTogglePin={() => togglePinned(item)} - onToggleExpand={() => {}} - canSplitTerminal={false} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) + isChatView = nativeChatEnabled && unifiedTabForItem?.viewMode === 'chat' + viewModeTabId = unifiedTabForItem?.id } return ( - 0} + item={item} + actions={actions} + worktreeId={worktreeId} + groupId={resolvedGroupId} + generatedTabTitlesEnabled={generatedTabTitlesEnabled} tabCount={items.length} - statusByRelativePath={statusByRelativePath} - onActivate={() => activateRealTab(onActivateFile)(item.id)} - onClose={() => onCloseFile?.(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onCloseAll={() => onCloseAllFiles?.()} - onMakePermanent={() => onMakePreviewFilePermanent?.(item.data.id, item.data.tabId)} - onTogglePin={() => togglePinned(item)} - dragData={dragData} + hasTabsToLeft={index > 0} + hasTabsToRight={index < items.length - 1} + isActive={isActiveItem(item)} + isExpanded={item.type === 'terminal' && expandedPaneByTabId[item.id] === true} dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} includeTopTabBorder={includeTopTabBorder} + canToggleViewMode={canToggleViewMode} + isChatView={isChatView} + viewModeTabId={viewModeTabId} + canDuplicate={item.type === 'browser' && managedBrowserCreationEnabled} + gitStatus={ + item.type === 'editor' + ? resolveEditorTabGitStatus(item.data.relativePath, statusByRelativePath) + : null + } /> ) }) diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index 1ef24b61831..8a6ad5be964 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -20,7 +20,7 @@ import type { TabBarProps } from './tab-bar-props' import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model' import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-controller' import type { TabBarItemProjection } from './use-tab-bar-item-projection' -import type { TabBarItem } from './tab-bar-item-model' +import type { TabBarItemActions } from './use-tab-bar-item-actions' import { renderTabBarItems } from './tab-bar-item-surface' import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' @@ -36,7 +36,7 @@ export function renderTabBarSurface({ tabStripNavigation, tabStripDragScroll, activeClientHostedBrowserRowId, - togglePinned + itemActions }: { props: TabBarProps runtime: TabBarRuntimeModel @@ -45,7 +45,7 @@ export function renderTabBarSurface({ tabStripNavigation: ReturnType tabStripDragScroll: ReturnType activeClientHostedBrowserRowId: string | null - togglePinned: (item: TabBarItem) => void + itemActions: TabBarItemActions }): React.JSX.Element { const { worktreeId, @@ -95,10 +95,10 @@ export function renderTabBarSurface({ items: orderedItems, props, runtime, + actions: itemActions, dropIndicatorByVisibleId, includeTopTabBorder, - activeClientHostedBrowserRowId, - togglePinned + activeClientHostedBrowserRowId }) return ( diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index c5e863af5ca..445076b590b 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -2,7 +2,6 @@ import { cloneElement, isValidElement, type ReactElement, type ReactNode } from import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' -import type { GitFileStatus } from '../../../../shared/git-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { OpenFile } from '../../store/slices/editor' @@ -361,7 +360,7 @@ describe('tab title tooltips', () => { hasTabsToRight={false} hasTabsToLeft={false} tabCount={1} - statusByRelativePath={new Map()} + gitStatus={null} onActivate={vi.fn()} onClose={vi.fn()} onCloseOthers={vi.fn()} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts new file mode 100644 index 00000000000..c404a075461 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts @@ -0,0 +1,92 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import { clearClientHostedBrowserRowSelection } from '@/lib/pane-manager/client-hosted-browser-row-state' +import type { TabBarItem } from './tab-bar-item-model' +import type { TabBarProps } from './tab-bar-props' + +type TabBarItemActionSource = { + props: Pick< + TabBarProps, + | 'onActivate' + | 'onActivateFile' + | 'onActivateBrowserTab' + | 'onActivateAgentSession' + | 'onClose' + | 'onCloseFile' + | 'onCloseBrowserTab' + | 'onCloseOthers' + | 'onCloseToRight' + | 'onCloseToLeft' + | 'onCloseAllFiles' + | 'onSetCustomTitle' + | 'onSetTabColor' + | 'onTogglePaneExpand' + | 'onDuplicateBrowserTab' + | 'onMakePreviewFilePermanent' + > + togglePinned: (item: TabBarItem) => void + toggleTabViewMode: (tabId: string) => void +} + +export type TabBarItemActions = { + activateTerminal: (tabId: string) => void + activateFile: (fileId: string) => void + activateBrowserTab: (tabId: string) => void + activateAgentSession: (tabId: string) => void + close: (tabId: string) => void + closeFile: (fileId: string) => void + closeBrowserTab: (tabId: string) => void + closeOthers: (tabId: string) => void + closeToRight: (tabId: string) => void + closeToLeft: (tabId: string) => void + closeAllFiles: () => void + setCustomTitle: (tabId: string, title: string | null) => void + setTabColor: (tabId: string, color: string | null) => void + togglePaneExpand: (tabId: string) => void + duplicateBrowserTab: (tabId: string, unifiedTabId: string) => void + makePreviewFilePermanent: (fileId: string, tabId?: string) => void + togglePinned: (item: TabBarItem) => void + toggleViewMode: (tabId: string) => void +} + +/** Actions that read the strip's handlers when called, so their own identity never has to change. */ +export function useTabBarItemActions(source: TabBarItemActionSource): TabBarItemActions { + const latest = useRef(source) + // Why an effect: a render React abandons must not hand its handlers to the tabs already on screen. + useLayoutEffect(() => { + latest.current = source + }) + return useMemo(() => { + // Why: this is the strip's single activation fan-out, so retiring a client-hosted placeholder + // here covers every row kind — including re-clicking the tab that was already active, which the + // group's activeTabId never moves for. + function activateRealTab(activate: () => void): void { + clearClientHostedBrowserRowSelection() + activate() + } + return { + activateTerminal: (tabId) => activateRealTab(() => latest.current.props.onActivate(tabId)), + activateFile: (fileId) => + activateRealTab(() => latest.current.props.onActivateFile?.(fileId)), + activateBrowserTab: (tabId) => + activateRealTab(() => latest.current.props.onActivateBrowserTab?.(tabId)), + activateAgentSession: (tabId) => + activateRealTab(() => latest.current.props.onActivateAgentSession?.(tabId)), + close: (tabId) => latest.current.props.onClose(tabId), + closeFile: (fileId) => latest.current.props.onCloseFile?.(fileId), + closeBrowserTab: (tabId) => latest.current.props.onCloseBrowserTab?.(tabId), + closeOthers: (tabId) => latest.current.props.onCloseOthers(tabId), + closeToRight: (tabId) => latest.current.props.onCloseToRight(tabId), + closeToLeft: (tabId) => latest.current.props.onCloseToLeft(tabId), + closeAllFiles: () => latest.current.props.onCloseAllFiles?.(), + setCustomTitle: (tabId, title) => latest.current.props.onSetCustomTitle(tabId, title), + setTabColor: (tabId, color) => latest.current.props.onSetTabColor(tabId, color), + togglePaneExpand: (tabId) => latest.current.props.onTogglePaneExpand(tabId), + duplicateBrowserTab: (tabId, unifiedTabId) => + latest.current.props.onDuplicateBrowserTab?.(tabId, unifiedTabId), + makePreviewFilePermanent: (fileId, tabId) => + latest.current.props.onMakePreviewFilePermanent?.(fileId, tabId), + togglePinned: (item) => latest.current.togglePinned(item), + toggleViewMode: (tabId) => latest.current.toggleTabViewMode(tabId) + } + }, []) +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.test.tsx b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.test.tsx new file mode 100644 index 00000000000..332b13473b9 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TabBarProps } from './tab-bar-props' +import { useTabBarItemProjection } from './use-tab-bar-item-projection' + +const NOOP = (): void => {} + +function terminalTab(id: string, sortOrder: number): TerminalTab & { unifiedTabId?: string } { + return { + id, + unifiedTabId: `unified-${id}`, + ptyId: null, + worktreeId: 'wt-1', + title: 'zsh', + customTitle: null, + color: null, + sortOrder, + createdAt: 0 + } +} + +/** Rebuilt per call, like the store's tab list: equal content, never the same objects. */ +function buildProps(ids: string[]): TabBarProps { + return { + tabs: ids.map((id, index) => terminalTab(id, index)), + tabBarOrder: [...ids], + activeTabId: ids[0] ?? null, + activeTabType: 'terminal', + worktreeId: 'wt-1', + expandedPaneByTabId: {}, + onActivate: NOOP, + onClose: NOOP, + onCloseOthers: NOOP, + onCloseToRight: NOOP, + onCloseToLeft: NOOP, + onNewTerminalTab: NOOP, + onNewBrowserTab: NOOP, + onSetCustomTitle: NOOP, + onSetTabColor: NOOP, + onTogglePaneExpand: NOOP + } +} + +function renderProjection(initialIds: string[]): { + sortableIds: () => string[] + rerenderWith: (ids: string[]) => void +} { + const { result, rerender } = renderHook( + (ids: string[]) => + useTabBarItemProjection({ + props: buildProps(ids), + resolvedGroupId: 'group-1', + unifiedTabs: [], + unifiedTabByVisibleId: new Map(), + generatedTabTitlesEnabled: false, + statusByRelativePath: new Map() + }), + { initialProps: initialIds } + ) + return { + sortableIds: () => result.current.sortableIds, + rerenderWith: (ids) => rerender(ids) + } +} + +describe('tab strip sortable ids', () => { + it('hands dnd-kit the same array while the ids are unchanged', () => { + const projection = renderProjection(['term-1', 'term-2']) + const first = projection.sortableIds() + + projection.rerenderWith(['term-1', 'term-2']) + + // A new array here re-renders every tab through dnd-kit's context, even with identical ids. + expect(projection.sortableIds()).toBe(first) + }) + + it('hands dnd-kit a new array when a tab is added', () => { + const projection = renderProjection(['term-1', 'term-2']) + + projection.rerenderWith(['term-1', 'term-2', 'term-3']) + + expect(projection.sortableIds()).toEqual(['term-1', 'term-2', 'term-3']) + }) + + it('hands dnd-kit a new array when the tabs are reordered', () => { + const projection = renderProjection(['term-1', 'term-2']) + + projection.rerenderWith(['term-2', 'term-1']) + + expect(projection.sortableIds()).toEqual(['term-2', 'term-1']) + }) +}) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts index f48c3ab14c3..8a2f5a58157 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { Tab } from '../../../../shared/tab-types' import type { TabBarProps } from './tab-bar-props' @@ -10,6 +10,7 @@ import { type TabBarItem } from './tab-bar-item-model' import type { DropIndicator } from './drop-indicator' +import { sameStringArray } from '@/runtime/web-session-tabs-sync/state-equality-core' export type TabBarItemProjection = { orderedItems: TabBarItem[] @@ -107,7 +108,12 @@ export function useTabBarItemProjection({ unifiedTabByVisibleId ] ) - const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems]) + const orderedIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems]) + // Why: dnd-kit re-renders every tab when this array's identity changes, and the items rebuild on any tab write. + const [sortableIds, setSortableIds] = useState(orderedIds) + if (!sameStringArray(sortableIds, orderedIds)) { + setSortableIds(orderedIds) + } const activeIndicator = hoveredTabInsertion?.groupId === resolvedGroupId ? hoveredTabInsertion : null const dropIndicatorByVisibleId = useMemo( diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index d9730fadbcc..a9f14d57bbc 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -271,6 +271,26 @@ describe('canDropTabIntoPaneBody', () => { }) describe('useTabDragSplit', () => { + it('keeps the drag sensors across re-renders until enablement changes', () => { + const sensorsByRender: ReturnType['sensors'][] = [] + function Probe({ enabled }: { enabled: boolean }): null { + sensorsByRender.push(useTabDragSplit({ worktreeId: WT, enabled }).sensors) + return null + } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mounted.push({ container, root }) + + act(() => root.render(createElement(Probe, { enabled: true }))) + act(() => root.render(createElement(Probe, { enabled: true }))) + // Why: new sensors rebuild every tab's drag listeners, which re-renders every tab. + expect(sensorsByRender.at(-1)).toBe(sensorsByRender.at(-2)) + + act(() => root.render(createElement(Probe, { enabled: false }))) + expect(sensorsByRender.at(-1)).not.toBe(sensorsByRender.at(-2)) + }) + it.each(['split', 'insertion'])( 'does not restore a %s preview after blur cleared the drag', async (preview) => { diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index ca1724e2bf4..f67712622ab 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type RefObject } from 'react' +import { useCallback, useMemo, useRef, useState, type RefObject } from 'react' import { closestCenter, pointerWithin, @@ -131,9 +131,13 @@ export function useTabDragSplit({ // useSensors(ptr) / useSensors(), because dnd-kit internally spreads // the sensors array into a useEffect dependency list — changing its // length between renders violates React's rules of hooks. - const pointerSensor = useSensor(TabDragPointerSensor, { - activationConstraint: { distance: getTabDragActivationDistance(enabled) } - }) + const activationDistance = getTabDragActivationDistance(enabled) + // Why memoized: fresh options rebuild every tab's drag listeners and wake every tab through dnd-kit's context. + const pointerSensorOptions = useMemo( + () => ({ activationConstraint: { distance: activationDistance } }), + [activationDistance] + ) + const pointerSensor = useSensor(TabDragPointerSensor, pointerSensorOptions) const sensors = useSensors(pointerSensor) const clearDragState = useCallback(() => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts index e8b8f8d7d43..c2210640c21 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createStore } from 'zustand/vanilla' +import { shallow } from 'zustand/shallow' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import type { @@ -304,6 +305,37 @@ describe('remote mirror resource identity', () => { expect(next.browserCertificateFailuresByPageId).toBe(state.browserCertificateFailuresByPageId) }) + it('keeps an unchanged browser tab record when a sibling browser tab changes', () => { + const browserTab = (index: number, title: string) => ({ + type: 'browser' as const, + id: `host-browser-tab-${index}`, + browserWorkspaceId: `host-browser-workspace-${index}`, + browserPageId: `host-browser-page-${index}`, + title, + url: `https://example.com/${index}`, + loading: false, + canGoBack: false, + canGoForward: false, + certificateFailure: null, + isActive: index === 1 + }) + const state = applySnapshot( + makeState(), + makeSnapshot(WORKTREE_A, [browserTab(1, 'One'), browserTab(2, 'Two')], 'browser') + ) + const next = applySnapshot( + state, + makeSnapshot(WORKTREE_A, [browserTab(1, 'One'), browserTab(2, 'Renamed')], 'browser'), + NOW + 1 + ) + + const [unchanged, renamed] = next.browserTabsByWorktree[WORKTREE_A]! + const [previous] = state.browserTabsByWorktree[WORKTREE_A]! + expect(renamed!.title).toBe('Renamed') + // Why: the tab strip compares each record field by field, so a rebuilt page list re-renders the tab. + expect(shallow(unchanged, previous)).toBe(true) + }) + it('clears a same-page certificate failure without replacing its page or handle', () => { const state = applySnapshot(makeState(), makeBrowserSnapshot()) const next = applySnapshot(state, makeBrowserSnapshot({ certificateFailure: null }), NOW + 1) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts index 8464ba504d7..c4a27812da9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts @@ -8,6 +8,7 @@ import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state' import { readBrowserClientHostId } from '../browser-client-host-identity' import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' import { browserPageEqual } from './state-equality-tabs' +import { sameStringArray } from './state-equality-core' import { collectLayoutGroupIds } from './tab-group-layout-tree' import { buildBrowserUnifiedTab } from './tab-builders' import { isReadyBrowserTab } from './terminal-surfaces' @@ -201,13 +202,18 @@ export function buildMirroredBrowserTabs( // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage + const existingPageIds = existing?.workspace.pageIds const workspace: BrowserWorkspace = { id: workspaceId, worktreeId: snapshot.worktree, label: existing?.workspace.label, sessionProfileId: existing?.workspace.sessionProfileId ?? null, activePageId: page.id, - pageIds: [page.id], + // Why: the tab strip skips a tab only while every field keeps its identity. + pageIds: + existingPageIds && sameStringArray(existingPageIds, [page.id]) + ? existingPageIds + : [page.id], url: page.url, title: page.title, loading: page.loading, diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 0d4b864ee64..207d4a4255b 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -13,6 +13,7 @@ import type { WorkspaceVisibleTabType } from '../../../src/shared/tab-types' import type { TerminalTab } from '../../../src/shared/terminal-tab-types' import type { Worktree } from '../../../src/shared/worktree/types' import type { DictationMeterState } from '../../../src/renderer/src/components/dictation/dictation-audio-meter' +import type { ReactCommitHook } from './tab-render-recorder' // Why: window.__store is the Zustand bound store itself, so specs get the whole StoreApi. export type AppStore = { @@ -68,6 +69,8 @@ declare global { __store?: AppStore __dictationMeterE2E?: { publish(meter: DictationMeterState): void } __paneManagers?: Map + __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactCommitHook + __tabsRenderedPerCommit?: number[] } } diff --git a/tests/e2e/helpers/tab-render-recorder.ts b/tests/e2e/helpers/tab-render-recorder.ts new file mode 100644 index 00000000000..83770b35aee --- /dev/null +++ b/tests/e2e/helpers/tab-render-recorder.ts @@ -0,0 +1,80 @@ +/** + * Counts how many tab-strip tabs each React commit re-renders, the way React DevTools highlights + * updates: through the commit hook the renderer always installs. + */ + +import type { Page } from '@stablyai/playwright-test' + +type Fiber = { + tag: number + flags: number + child: Fiber | null + sibling: Fiber | null + stateNode: unknown +} + +export type ReactCommitHook = { + onCommitFiberRoot?: (rendererId: unknown, root: { current: Fiber }, ...rest: unknown[]) => unknown +} + +/** Commits that render no tab (the sidebar, terminals starting up) are left out. */ +export async function startRecordingTabRenders(page: Page): Promise { + await page.evaluate(() => { + // Function, class, forwardRef and memo components; bit 1 is React's PerformedWork flag. + const componentTags = new Set([0, 1, 11, 14, 15]) + const performedWork = 1 + const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (!hook) { + throw new Error('React commit hook is not installed') + } + const original = hook.onCommitFiberRoot + // A subtree React skipped keeps last commit's fiber objects, whose flags are stale. Per root: the renderer mounts several. + const previousFibersByRoot = new WeakMap>() + const recorded: number[] = [] + window.__tabsRenderedPerCommit = recorded + hook.onCommitFiberRoot = function (rendererId, root, ...rest) { + const previousFibers = previousFibersByRoot.get(root) + const fibers = new Set() + const renderedTabIds = new Set() + const stack: Fiber[] = [root.current] + while (stack.length > 0) { + const fiber = stack.pop()! + fibers.add(fiber) + if ( + componentTags.has(fiber.tag) && + (fiber.flags & performedWork) === performedWork && + !previousFibers?.has(fiber) + ) { + let host: Fiber | null = fiber + while (host && host.tag !== 5) { + host = host.child + } + const element = host?.stateNode + const tabId = + element instanceof Element + ? element.closest('[data-tab-strip-slot]')?.getAttribute('data-tab-strip-slot') + : undefined + if (tabId) { + renderedTabIds.add(tabId) + } + } + if (fiber.sibling) { + stack.push(fiber.sibling) + } + if (fiber.child) { + stack.push(fiber.child) + } + } + previousFibersByRoot.set(root, fibers) + if (renderedTabIds.size > 0) { + recorded.push(renderedTabIds.size) + } + return original?.call(this, rendererId, root, ...rest) + } + }) +} + +/** Tabs re-rendered by each commit since the last call. */ +export async function takeTabRenders(page: Page): Promise { + return page.evaluate(() => window.__tabsRenderedPerCommit?.splice(0) ?? []) +} diff --git a/tests/e2e/tab-strip-tab-render-isolation.spec.ts b/tests/e2e/tab-strip-tab-render-isolation.spec.ts new file mode 100644 index 00000000000..c1c41726d80 --- /dev/null +++ b/tests/e2e/tab-strip-tab-render-isolation.spec.ts @@ -0,0 +1,97 @@ +/** + * E2E test for a long tab strip: a change to one tab must re-render that tab, not the strip. + * + * Why E2E: only the whole app shows every React commit a tab change causes — the store write, the + * strip's projections, and the drag-and-drop context every tab reads. + */ + +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { waitForActivePanePtyId } from './helpers/terminal' +import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal' +import { startRecordingTabRenders, takeTabRenders } from './helpers/tab-render-recorder' + +const BACKGROUND_TABS = 30 + +// New terminals retitle their tabs for a few seconds after opening; wait for the strip to go quiet. +async function waitForQuietStrip(page: Page): Promise { + await expect + .poll( + async () => { + await takeTabRenders(page) + await page.waitForTimeout(500) + return (await takeTabRenders(page)).length + }, + { timeout: 30_000 } + ) + .toBe(0) +} + +test.describe('Tab strip tab render isolation', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('a title change or a tab switch re-renders only the tabs involved', async ({ orcaPage }) => { + const worktreeId = await waitForActiveWorktree(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const tabIds = await orcaPage.evaluate( + ({ wId, count }) => { + const ids: string[] = [] + for (let i = 0; i < count; i++) { + ids.push( + window.__store!.getState().createTab(wId, undefined, undefined, { activate: false }).id + ) + } + return ids + }, + { wId: worktreeId, count: BACKGROUND_TABS } + ) + const tab = (tabId: string) => + orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`) + await expect(tab(tabIds[BACKGROUND_TABS - 1])).toBeAttached() + await startRecordingTabRenders(orcaPage) + + await waitForQuietStrip(orcaPage) + await orcaPage.evaluate( + (tabId) => window.__store!.getState().updateTabTitle(tabId, 'retitled in background'), + tabIds[5] + ) + await expect(tab(tabIds[5])).toHaveAttribute('data-tab-title', 'retitled in background') + const backgroundRetitle = await takeTabRenders(orcaPage) + // Control: the recorder sees the one tab that did change. + expect(backgroundRetitle.length).toBeGreaterThan(0) + expect(Math.max(...backgroundRetitle)).toBe(1) + + await waitForQuietStrip(orcaPage) + // A node script, so the title is emitted the same way under PowerShell, cmd and POSIX shells. + // It stays alive until the title is read; a shell prompt would retitle the tab straight back. + const retitle = await runNodeScriptInTerminal( + orcaPage, + ptyId, + `process.stdout.write('\\x1b]0;retitled by its terminal\\x07'); setTimeout(() => {}, 3000)` + ) + try { + await expect( + orcaPage.locator('[data-testid="sortable-tab"][data-active="true"]') + ).toHaveAttribute('data-tab-title', 'retitled by its terminal') + const terminalRetitle = await takeTabRenders(orcaPage) + expect(terminalRetitle.length).toBeGreaterThan(0) + expect(Math.max(...terminalRetitle)).toBe(1) + } finally { + retitle.cleanup() + } + + await waitForQuietStrip(orcaPage) + await tab(tabIds[1]).click() + await expect(tab(tabIds[1])).toHaveAttribute('data-active', 'true') + await orcaPage.waitForTimeout(500) + const tabSwitch = await takeTabRenders(orcaPage) + expect(tabSwitch.length).toBeGreaterThan(0) + // The tab that lost the active state and the one that gained it. + expect(Math.max(...tabSwitch)).toBeLessThanOrEqual(2) + }) +}) From a824fb74ab53327b3ceb8106db112c89fcc56d52 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:56:08 -0700 Subject: [PATCH 002/220] Reuse the headless detector compiler without installing full dependencies (#24895) * Reuse the headless detector compiler without full dependency setup * Keep optional compiler-cache saves from failing cache warming --- .../prepare-headless-compiler/action.yml | 45 ++++ .github/workflows/ci-cache-warmup.yml | 11 +- .github/workflows/node-server-tests.yml | 8 +- .../headless-detector-compiler-cache.mjs | 160 ++++++++++++++ .../headless-detector-compiler-cache.test.mjs | 204 ++++++++++++++++++ config/scripts/node-server-change-scope.mjs | 2 + .../scripts/node-server-change-scope.test.mjs | 2 + docs/reference/ci-runner-efficiency.md | 34 +++ 8 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 .github/actions/prepare-headless-compiler/action.yml create mode 100644 config/scripts/headless-detector-compiler-cache.mjs create mode 100644 config/scripts/headless-detector-compiler-cache.test.mjs diff --git a/.github/actions/prepare-headless-compiler/action.yml b/.github/actions/prepare-headless-compiler/action.yml new file mode 100644 index 00000000000..9e71e6df1b0 --- /dev/null +++ b/.github/actions/prepare-headless-compiler/action.yml @@ -0,0 +1,45 @@ +name: Prepare headless detector compiler +description: Reuse the policy-checked compiler from main; callers install normally on a miss. +inputs: + seed: + description: Pack an already installed compiler instead of activating a cached compiler. + default: 'false' +outputs: + available: + description: Whether the cached compiler was validated and activated. + value: ${{ steps.activate.outputs.available }} +runs: + using: composite + steps: + - id: identity + shell: bash + env: + COMPILER_POLICY_HASH: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', 'config/patches/**', '.github/actions/install-node-dependencies/**', '.github/actions/restore-pnpm-verification/**', 'config/scripts/headless-detector-compiler-cache.mjs', '.github/actions/prepare-headless-compiler/action.yml') }} + run: node config/scripts/headless-detector-compiler-cache.mjs identity + - id: cache + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: ${{ steps.identity.outputs.path }} + key: ${{ steps.identity.outputs.key }} + - id: activate + if: inputs.seed != 'true' && steps.cache.outputs.cache-hit == 'true' + shell: bash + env: + COMPILER_CACHE_KEY: ${{ steps.identity.outputs.key }} + COMPILER_CACHE_PATH: ${{ steps.identity.outputs.path }} + run: node config/scripts/headless-detector-compiler-cache.mjs activate + - name: Pack installed compiler + if: inputs.seed == 'true' && steps.cache.outputs.cache-hit != 'true' + shell: bash + env: + COMPILER_CACHE_KEY: ${{ steps.identity.outputs.key }} + COMPILER_CACHE_PATH: ${{ steps.identity.outputs.path }} + run: node config/scripts/headless-detector-compiler-cache.mjs pack + - name: Save compiler only from main + if: inputs.seed == 'true' && steps.cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ steps.identity.outputs.path }} + key: ${{ steps.identity.outputs.key }} diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 1c8c248da89..4522aa1e874 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -13,6 +13,8 @@ on: - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-headless-compiler/**' + - 'config/scripts/headless-detector-compiler-cache*' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' @@ -35,13 +37,14 @@ on: - 'src/shared/zip-extractor-command.ts' - 'config/scripts/shared-electron-dist-cache.mjs' - 'config/scripts/space-sharing-copy.mjs' - - 'config/patches/node-pty@1.1.0.patch' - - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + - 'config/patches/**' - 'native/windows-registry/**' pull_request: paths: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-headless-compiler/**' + - 'config/scripts/headless-detector-compiler-cache*' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' @@ -74,6 +77,10 @@ jobs: node-version: '24' cache-electron-package: 'true' + - uses: ./.github/actions/prepare-headless-compiler + with: + seed: 'true' + - name: Populate shared Electron archive run: node config/scripts/install-electron-package-binary.mjs diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index dad53d33f47..b3a5a02c33c 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -17,6 +17,7 @@ on: - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' + - '.github/actions/prepare-headless-compiler/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -37,6 +38,7 @@ on: - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' + - '.github/actions/prepare-headless-compiler/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -112,8 +114,12 @@ jobs: echo 'should_run=true' >> "$GITHUB_OUTPUT" fi - - uses: ./.github/actions/install-node-dependencies + - uses: ./.github/actions/prepare-headless-compiler + id: compiler if: steps.scope.outputs.graph_required == 'true' + continue-on-error: true + - uses: ./.github/actions/install-node-dependencies + if: steps.scope.outputs.graph_required == 'true' && steps.compiler.outputs.available != 'true' - name: Check the headless import graph id: graph if: steps.scope.outputs.graph_required == 'true' diff --git a/config/scripts/headless-detector-compiler-cache.mjs b/config/scripts/headless-detector-compiler-cache.mjs new file mode 100644 index 00000000000..9ef4d5cb6b5 --- /dev/null +++ b/config/scripts/headless-detector-compiler-cache.mjs @@ -0,0 +1,160 @@ +import { createHash } from 'node:crypto' +import { + appendFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const ROOT = resolve(import.meta.dirname, '../..') + +function inventory(directory, prefix = '') { + if (lstatSync(directory).isSymbolicLink()) { + throw new Error('Compiler cache directory is a symlink') + } + return readdirSync(directory) + .flatMap((name) => { + const path = join(directory, name) + const file = `${prefix}${name}` + const stat = lstatSync(path) + if (stat.isSymbolicLink()) { + throw new Error('Compiler cache contains a symlink') + } + if (stat.isDirectory()) { + return inventory(path, `${file}/`) + } + if (!stat.isFile()) { + throw new Error('Compiler cache contains a special file') + } + return [{ file, sha256: createHash('sha256').update(readFileSync(path)).digest('hex') }] + }) + .sort((a, b) => a.file.localeCompare(b.file)) +} + +export function compilerCacheIdentity({ + policyHash = process.env.COMPILER_POLICY_HASH, + cacheRoot = process.env.RUNNER_TEMP, + platform = process.platform, + arch = process.arch, + node = process.version +} = {}) { + if (!policyHash || !cacheRoot) { + throw new Error('Compiler cache requires policy hash and cache root') + } + return { + key: `headless-compiler-v1-${platform}-${arch}-${node}-${policyHash}`, + path: join(cacheRoot, 'headless-detector-compiler') + } +} + +export function packCompilerCache({ root = ROOT, identity = compilerCacheIdentity() } = {}) { + const require = createRequire(join(root, 'package.json')) + const esbuildDir = dirname(require.resolve('esbuild/package.json')) + const nativeName = `@esbuild/${process.platform}-${process.arch}` + const nativeDir = dirname(require.resolve(`${nativeName}/package.json`, { paths: [esbuildDir] })) + rmSync(identity.path, { recursive: true, force: true }) + mkdirSync(join(identity.path, 'node_modules', '@esbuild'), { recursive: true }) + cpSync(esbuildDir, join(identity.path, 'node_modules', 'esbuild'), { + recursive: true, + dereference: true + }) + cpSync(nativeDir, join(identity.path, 'node_modules', nativeName), { + recursive: true, + dereference: true + }) + writeFileSync( + join(identity.path, 'manifest.json'), + JSON.stringify({ + key: identity.key, + node: process.version, + version: require('esbuild').version, + files: inventory(identity.path) + }) + ) +} + +export async function activateCompilerCache({ + root = ROOT, + identity = compilerCacheIdentity() +} = {}) { + const dependencies = join(root, 'node_modules') + let created = false + try { + if (existsSync(dependencies)) { + throw new Error('Compiler activation requires an empty dependency tree') + } + const files = inventory(identity.path).filter((row) => row.file !== 'manifest.json') + const manifest = JSON.parse(readFileSync(join(identity.path, 'manifest.json'), 'utf8')) + if ( + manifest.key !== identity.key || + manifest.node !== process.version || + JSON.stringify(files) !== JSON.stringify(manifest.files) + ) { + throw new Error('Compiler cache identity or contents differ') + } + mkdirSync(join(dependencies, '@esbuild'), { recursive: true }) + created = true + for (const name of ['esbuild', `@esbuild/${process.platform}-${process.arch}`]) { + symlinkSync(join(identity.path, 'node_modules', name), join(dependencies, name), 'dir') + } + const require = createRequire(join(root, 'package.json')) + const esbuild = require('esbuild') + if (esbuild.version !== manifest.version) { + throw new Error('Compiler API version differs') + } + await esbuild.build({ + stdin: { contents: 'export const value = 1' }, + write: false, + logLevel: 'silent' + }) + return { available: true } + } catch (error) { + if (created) { + rmSync(dependencies, { recursive: true, force: true }) + } + return { available: false, reason: String(error) } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const phase = process.argv[2] + const identity = + phase === 'identity' + ? compilerCacheIdentity() + : { + key: process.env.COMPILER_CACHE_KEY, + path: process.env.COMPILER_CACHE_PATH + } + if (!identity.key || !identity.path) { + throw new Error('Compiler cache identity required') + } + const output = (values) => { + for (const [name, value] of Object.entries(values)) { + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`) + } + } + if (phase === 'identity') { + output(identity) + } else if (phase === 'pack') { + packCompilerCache({ identity }) + } else if (phase === 'activate') { + const result = await activateCompilerCache({ identity }) + output({ available: result.available }) + console.log( + result.available + ? 'Validated headless compiler cache' + : `Use normal dependency install: ${result.reason}` + ) + } else { + throw new Error('Expected identity, pack, or activate') + } +} diff --git a/config/scripts/headless-detector-compiler-cache.test.mjs b/config/scripts/headless-detector-compiler-cache.test.mjs new file mode 100644 index 00000000000..484807159a7 --- /dev/null +++ b/config/scripts/headless-detector-compiler-cache.test.mjs @@ -0,0 +1,204 @@ +import { + appendFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + activateCompilerCache, + compilerCacheIdentity, + packCompilerCache +} from './headless-detector-compiler-cache.mjs' +import { collectNodeServerInputs } from './node-server-change-scope.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const temporary = [] +afterEach(() => { + for (const dir of temporary.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'headless-compiler-cache-')) + temporary.push(directory) + const root = join(directory, 'checkout') + mkdirSync(root) + writeFileSync(join(root, 'package.json'), '{"type":"module"}') + const identity = compilerCacheIdentity({ policyHash: 'policy', cacheRoot: directory }) + packCompilerCache({ identity }) + return { root, identity } +} +function changeManifest(identity, update) { + const path = join(identity.path, 'manifest.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) + update(manifest) + writeFileSync(path, JSON.stringify(manifest)) +} + +it('separates policy, Node, platform and architecture identities with the same archive path', () => { + const options = { + policyHash: 'policy', + cacheRoot: '/cache', + platform: 'linux', + arch: 'x64', + node: 'v24.21.0' + } + const original = compilerCacheIdentity(options) + for (const override of [ + { policyHash: 'changed' }, + { node: 'v24.22.0' }, + { platform: 'darwin' }, + { arch: 'arm64' } + ]) { + const other = compilerCacheIdentity({ ...options, ...override }) + expect(other.key).not.toBe(original.key) + expect(other.path).toBe(original.path) + } +}) + +it('activates only the actual compiler packages and preserves import graph behavior', async () => { + const { root, identity } = fixture() + expect(await activateCompilerCache({ root, identity })).toEqual({ available: true }) + expect(readdirSync(join(root, 'node_modules')).sort()).toEqual(['@esbuild', 'esbuild']) + for (const name of [ + 'node-server-change-scope', + 'node-server-test-paths', + 'node-server-qualification', + 'orcad-entry-build' + ]) { + mkdirSync(join(root, 'config', 'scripts'), { recursive: true }) + cpSync( + new URL(`./${name}.mjs`, import.meta.url), + join(root, 'config', 'scripts', `${name}.mjs`) + ) + } + writeFileSync( + join(root, 'entry.ts'), + "import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'" + ) + for (const name of ['first', 'exports', 'dynamic', 'required']) { + writeFileSync(join(root, `${name}.ts`), 'export const value = 1') + } + writeFileSync( + join(root, 'probe.mjs'), + "import { collectNodeServerInputs } from './config/scripts/node-server-change-scope.mjs'; console.log(JSON.stringify([...(await collectNodeServerInputs({ root: process.cwd(), entryPoints: ['entry.ts'] }))].sort()))" + ) + const baseline = [...(await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }))].sort() + const candidate = runProcessSync({ + program: process.execPath, + args: ['probe.mjs'], + cwd: root, + timeoutMs: 10_000 + }) + expect(candidate.code, candidate.stderr).toBe(0) + expect(JSON.parse(candidate.stdout.trim())).toEqual(baseline) +}, 20_000) + +it.each(['key', 'node', 'version', 'files'])( + 'falls back on invalid manifest %s and cleans partial activation', + async (field) => { + const { root, identity } = fixture() + changeManifest(identity, (manifest) => { + manifest[field] = 'wrong' + }) + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + expect(existsSync(join(root, 'node_modules'))).toBe(false) + } +) + +it.each(['modified', 'missing', 'extra', 'symlink', 'malformed'])( + 'falls back on %s cache contents before loading code', + async (kind) => { + const { root, identity } = fixture() + const compiler = join(identity.path, 'node_modules', 'esbuild', 'lib', 'main.js') + if (kind === 'modified') { + appendFileSync(compiler, '\nthrow Error("must not load")') + } + if (kind === 'missing') { + rmSync(compiler) + } + if (kind === 'extra') { + writeFileSync(join(identity.path, 'unexpected'), 'extra') + } + if (kind === 'symlink') { + rmSync(compiler) + symlinkSync(join(root, 'package.json'), compiler) + } + if (kind === 'malformed') { + writeFileSync(join(identity.path, 'manifest.json'), '{') + } + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + expect(existsSync(join(root, 'node_modules'))).toBe(false) + } +) + +it('leaves existing dependencies alone and falls back on an absent archive', async () => { + const { root, identity } = fixture() + rmSync(identity.path, { recursive: true }) + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + mkdirSync(join(root, 'node_modules')) + writeFileSync(join(root, 'node_modules', 'retained'), 'retained') + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + expect(readFileSync(join(root, 'node_modules', 'retained'), 'utf8')).toBe('retained') +}) + +it('uses exact optional restores, seeds only main and retains full dependency fallback', () => { + const action = parse(readFileSync('.github/actions/prepare-headless-compiler/action.yml', 'utf8')) + const steps = action.runs.steps + const restore = steps.find((step) => step.id === 'cache') + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore['continue-on-error']).toBe(true) + expect(restore.with['restore-keys']).toBeUndefined() + const save = steps.find((step) => step.uses === 'actions/cache/save@v5') + expect(save.if).toContain("github.ref == 'refs/heads/main'") + expect(save.if).toContain("github.event_name != 'pull_request'") + expect(save['continue-on-error']).toBe(true) + expect(save.with).toEqual(restore.with) + const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + const detector = workflow.jobs.changes.steps + const cached = detector.find((step) => step.id === 'compiler') + expect(cached.if).toBe("steps.scope.outputs.graph_required == 'true'") + expect(cached['continue-on-error']).toBe(true) + expect( + detector.find((step) => step.uses === './.github/actions/install-node-dependencies').if + ).toBe( + "steps.scope.outputs.graph_required == 'true' && steps.compiler.outputs.available != 'true'" + ) + for (const event of ['push', 'pull_request']) { + expect(workflow.on[event].paths).toContain('.github/actions/prepare-headless-compiler/**') + } + const warmer = parse(readFileSync('.github/workflows/ci-cache-warmup.yml', 'utf8')) + const warmSteps = warmer.jobs.warm.steps + expect( + warmSteps.findIndex((step) => step.uses === './.github/actions/prepare-headless-compiler') + ).toBeGreaterThan( + warmSteps.findIndex((step) => step.uses === './.github/actions/install-node-dependencies') + ) + for (const event of ['push', 'pull_request']) { + expect(warmer.on[event].paths).toContain('.github/actions/prepare-headless-compiler/**') + } + const inputs = [ + ...steps.find((step) => step.id === 'identity').env.COMPILER_POLICY_HASH.matchAll(/'([^']+)'/g) + ].map((match) => match[1]) + for (const input of inputs) { + expect( + warmer.on.push.paths.some( + (pattern) => + pattern === input || + (pattern.endsWith('/**') && input.startsWith(pattern.slice(0, -2))) || + (pattern.endsWith('*') && input.startsWith(pattern.slice(0, -1))) + ), + input + ).toBe(true) + } +}) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 78c5e572677..71e788867e1 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -31,12 +31,14 @@ const ALWAYS_FILES = new Set([ '.github/workflows/node-server-tests.yml', 'config/scripts/node-server-change-scope.mjs', 'config/scripts/node-server-change-scope.test.mjs', + 'config/scripts/headless-detector-compiler-cache.mjs', 'config/scripts/node-server-qualification.mjs', 'config/scripts/node-server-qualification.test.mjs' ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', '.github/actions/restore-pnpm-verification/', + '.github/actions/prepare-headless-compiler/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/', // These areas also contain worker paths and fixtures opened without an import. diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 88ec2c446bb..80ce98aa11e 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -86,6 +86,8 @@ it.each([ 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', '.github/actions/restore-pnpm-verification/action.yml', + '.github/actions/prepare-headless-compiler/action.yml', + 'config/scripts/headless-detector-compiler-cache.mjs', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 570e35c71f8..86a28b0978f 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -46,6 +46,40 @@ used 42 aggregate runner-minutes across 11 test jobs. The estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## October 2 headless detector compiler cache + +The deferred detector already avoids dependency setup for known build inputs. +For changes that need import analysis, the collector marks package imports external; +only esbuild and its platform binary are needed. A small compiler archive can replace +root dependency setup for this analysis, while qualification jobs still install normally. + +The existing Linux x64 warmer packs these two packages after its frozen, +script-free, policy-checked install. Only main publishes. Readers use an exact key +covering Node/platform/architecture, manifests, install policy, patches and the cache +implementation. The producer and reader use the same archive path. File hashes, +identity and a compiler smoke are checked before availability is reported; missing, +invalid or failed restores use the original full installer. Graph analysis retains +its existing conservative full-qualification verdict on errors. + +A [three-pair hosted comparison](https://github.com/stablyai/orca/actions/runs/37071724200) +passed on Ubuntu x64 with Node 24.21.0 and esbuild 0.28.2. Every pair produced the +same 6,018 source inputs. Sample 2 ran the compiler-only treatment first; samples 1 +and 3 ran the existing installer first. Each used a fresh dependency tree, and the +compiler treatment required a real cache hit and validated its bytes and smoke. + +| Sample | Full installer + graph | Compiler restore + graph | Paired saving | +| ------ | ---------------------- | ------------------------ | ------------- | +| 1 | 11.730s | 2.805s | 8.925s | +| 2 | 14.702s | 4.706s | 9.996s | +| 3 | 14.666s | 3.750s | 10.916s | + +The median paired saving is 9.996 seconds. Inter-step overhead, archive transfer, +validation and the real graph are included. Checkout, initial Node setup, dependency +resets, seed work, post-job cache saves, tests and queues are excluded. These are +warm detector measurements, not whole-workflow or billing savings. The trial uses +the same package layout and validation as the production helper; production also +resolves its policy fingerprint. Cold or changed identities still install fully. + ## SSH Windows slot reuse The SSH Windows host workflow uses the same server-slot preparation action as From 533446dde6f2659714108cb8ce241073a697a73e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:56:43 -0700 Subject: [PATCH 003/220] Stop mocked renderer imports from qualifying headless CI (#24902) * Decouple headless running-work tests from the renderer * Keep the shared running-work probe contract documented --- .../scripts/node-server-change-scope.test.mjs | 12 ++ config/scripts/node-server-test-paths.mjs | 1 + docs/reference/ci-demand-rollout.md | 9 ++ .../terminal/pty-running-work-probe.ts | 105 ++--------------- src/shared/pty-running-work-probe.test.ts | 111 ++++++++++++++++++ src/shared/pty-running-work-probe.ts | 85 ++++++++++++++ .../daemon-running-work-probe.unit.test.ts | 30 ++--- 7 files changed, 243 insertions(+), 110 deletions(-) create mode 100644 src/shared/pty-running-work-probe.test.ts create mode 100644 src/shared/pty-running-work-probe.ts diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 80ce98aa11e..a9c9e76bba1 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -176,9 +176,19 @@ describe('the actual Bun build and profile-test dependency graph', () => { inputs = await collectNodeServerInputs() }, 60_000) + it('tracks the shared close probe without pulling in its mocked renderer adapter', () => { + expect(inputs.has('src/shared/pty-running-work-probe.ts')).toBe(true) + expect(inputs.has('src/shared/pty-running-work-probe.test.ts')).toBe(true) + expect(inputs.has('src/renderer/src/components/terminal/pty-running-work-probe.ts')).toBe(false) + expect(inputs.has('src/renderer/src/runtime/runtime-terminal-inspection.ts')).toBe(false) + expect([...inputs].some((file) => file.startsWith('src/renderer/'))).toBe(false) + }) + it.each([ 'config/scripts/ci-shard-timings.json', 'config/scripts/mobile-web-app-terminal-render.test.mjs', + 'src/renderer/src/components/terminal/pty-running-work-probe.ts', + 'src/renderer/src/runtime/runtime-terminal-inspection.ts', 'src/main/ssh/ssh-relay-upload-stage-commands.test.ts', 'src/main/menu/register-app-menu.ts' ])('skips unrelated work: %s', async (file) => { @@ -188,6 +198,8 @@ describe('the actual Bun build and profile-test dependency graph', () => { it.each([ ...Object.values(ORCAD_CHILD_ENTRY_POINTS), 'src/shared/keybindings/definitions-core-1.ts', + 'src/shared/pty-running-work-probe.ts', + 'src/shared/pty-running-work-probe.test.ts', 'src/main/runtime/orca-runtime.ts', 'src/main/windows/windows-process-table.ts', 'src/main/worker-thread-entry-path.ts', diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 6938919bc7c..a93c30869ca 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -21,6 +21,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = ...(artifact ? [ 'tests/e2e/daemon-running-work-probe.unit.test.ts', + 'src/shared/pty-running-work-probe.test.ts', 'src/main/orcad/orcad-packaged-node-pty.integration.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-node-launcher.integration.test.ts', diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md index 00dfdca4f5f..d8092ef412f 100644 --- a/docs/reference/ci-demand-rollout.md +++ b/docs/reference/ci-demand-rollout.md @@ -45,6 +45,15 @@ PRs pay the extra stage latency. Existing per-PR cancellation remains in place. Package assertions, native boundaries, SSH/folder coverage, cache warming and slow-test assertions are retained. +The daemon running-work test imports the shared probe directly, with the daemon's +process inspector supplied as its callback. The renderer keeps its existing +adapter and forwarding tests. This removes a mocked renderer dependency from the +headless graph without changing the probe algorithm or skipping backend tests. +At validation, the graph fell from 6,018 inputs (1,070 renderer inputs) to 4,879 +inputs (no renderer inputs), including nine added shared-probe cases. Renderer +adapter changes no longer qualify the headless matrix; shared probe and daemon +test changes still do. Future actual renderer imports remain discoverable. + ## Unit selection rollout PR planning runs alongside typechecking after their shared dependency setup; an diff --git a/src/renderer/src/components/terminal/pty-running-work-probe.ts b/src/renderer/src/components/terminal/pty-running-work-probe.ts index 1ee456ba389..9fe63c380f1 100644 --- a/src/renderer/src/components/terminal/pty-running-work-probe.ts +++ b/src/renderer/src/components/terminal/pty-running-work-probe.ts @@ -1,105 +1,24 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -import { isRemoteExecutionHostPtyId } from '../../../../shared/remote-execution-host-pty-id' -import { isClientOnlyUnverifiableInspection } from '../../../../shared/terminal-process-inspection' +import { + probePtyRunningWorkWithInspection, + type PtyRunningWorkProbe +} from '../../../../shared/pty-running-work-probe' -/** - * One probe answer in the fixed `live` / `unverifiable` / `exited` vocabulary of - * `docs/reference/ssh-execution-boundary.md`. `exited` is only ever produced by a host that - * answered; every failure to reach the owner — a rejection, a closed transport, or a deadline - * that expired first — stays `unverifiable`, because loss of contact is not evidence of death. - */ -export type PtyRunningWorkVerdict = 'live' | 'unverifiable' | 'exited' - -export type PtyRunningWorkProbe = { - ptyId: string - verdict: PtyRunningWorkVerdict - /** Why the owner could not be observed. Only set for `unverifiable`. */ - reason?: string - /** The deadline expired before this pty's probe answered at all. */ - timedOut: boolean - /** The pty is owned by a remote execution host (relay runtime or app SSH). */ - remote: boolean -} +export type { + PtyRunningWorkProbe, + PtyRunningWorkVerdict +} from '../../../../shared/pty-running-work-probe' type ProbeSettings = Pick | null | undefined -/** - * Probes every pty for running work and resolves at whichever comes first: every answer, or the - * deadline. Never rejects, and never reports a pty it did not hear back about as idle. - * - * Callers own the policy. This owns only the measurement, so the tab-close guard and the - * window-close guard cannot drift apart on what an unanswered remote host means. - */ -export async function probePtyRunningWork( +export function probePtyRunningWork( settings: ProbeSettings, ptyIds: readonly string[], options: { timeoutMs: number } ): Promise { - if (ptyIds.length === 0) { - return [] - } - const probes: PtyRunningWorkProbe[] = ptyIds.map((ptyId) => ({ - ptyId, - verdict: 'unverifiable', - reason: 'probe_deadline', - timedOut: true, - remote: isRemoteExecutionHostPtyId(ptyId) - })) - - const settle = Promise.all( - ptyIds.map(async (ptyId, index) => { - const probe = probes[index] - if (!probe) { - return - } - try { - // Why the flag: this probe backs decisions that act once and destructively, so it is worth - // a host process-table read on platforms where the child question costs one. The polled - // inspections deliberately do not ask for it. - const inspection = await inspectRuntimeTerminalProcess(settings, ptyId, { - scanChildProcesses: true - }) - probe.timedOut = false - if (isClientOnlyUnverifiableInspection(inspection)) { - probe.verdict = 'unverifiable' - probe.reason = inspection.reason - return - } - // `hasChildProcesses` cannot hold the third answer: a host that could not read its own - // process table spells that the same way as one that read it and found nothing. Windows - // relays spelled it `false` unconditionally, which read here as `exited`. - if (inspection.childProcessEvidence === 'unverifiable') { - probe.verdict = 'unverifiable' - probe.reason = 'host_child_processes_unobserved' - return - } - probe.verdict = - (inspection.childProcessEvidence ?? - (inspection.hasChildProcesses ? 'children' : 'no-children')) === 'children' - ? 'live' - : 'exited' - delete probe.reason - } catch { - // Why: `inspectRuntimeTerminalProcess` already maps every failure it can classify onto a - // reason; an unclassified throw is still a failure to observe, so it stays unverifiable. - probe.timedOut = false - probe.verdict = 'unverifiable' - probe.reason = 'probe_failed' - } - }) + return probePtyRunningWorkWithInspection(ptyIds, options, (ptyId) => + // Close guards require host child-process evidence before acting. + inspectRuntimeTerminalProcess(settings, ptyId, { scanChildProcesses: true }) ) - - let deadline: ReturnType | undefined - try { - await Promise.race([ - settle, - new Promise((resolve) => { - deadline = setTimeout(resolve, options.timeoutMs) - }) - ]) - } finally { - clearTimeout(deadline) - } - return probes } diff --git a/src/shared/pty-running-work-probe.test.ts b/src/shared/pty-running-work-probe.test.ts new file mode 100644 index 00000000000..a7710765f2e --- /dev/null +++ b/src/shared/pty-running-work-probe.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { probePtyRunningWorkWithInspection } from './pty-running-work-probe' +import { + clientOnlyUnverifiableInspection, + type TerminalProcessInspection +} from './terminal-process-inspection' + +afterEach(() => vi.useRealTimers()) + +it('returns immediately for no terminals without asking an execution host', async () => { + const inspect = vi.fn<(ptyId: string) => Promise>() + expect(await probePtyRunningWorkWithInspection([], { timeoutMs: 1000 }, inspect)).toEqual([]) + expect(inspect).not.toHaveBeenCalled() +}) + +it.each([ + [ + { foregroundProcess: 'zsh', hasChildProcesses: true, childProcessEvidence: 'children' }, + 'live', + undefined + ], + [ + { foregroundProcess: 'zsh', hasChildProcesses: false, childProcessEvidence: 'no-children' }, + 'exited', + undefined + ], + [ + { foregroundProcess: 'zsh', hasChildProcesses: false, childProcessEvidence: 'unverifiable' }, + 'unverifiable', + 'host_child_processes_unobserved' + ], + [clientOnlyUnverifiableInspection('transport_loss'), 'unverifiable', 'transport_loss'], + [{ foregroundProcess: 'zsh', hasChildProcesses: true }, 'live', undefined], + [{ foregroundProcess: 'zsh', hasChildProcesses: false }, 'exited', undefined] +] as const)( + 'keeps the existing owning-host verdict for %j', + async (inspection, verdict, reason) => { + const inspect = vi.fn(async () => inspection) + const [probe] = await probePtyRunningWorkWithInspection( + ['remote:owner:pty'], + { timeoutMs: 1000 }, + inspect + ) + expect(probe).toEqual({ + ptyId: 'remote:owner:pty', + verdict, + ...(reason ? { reason } : {}), + timedOut: false, + remote: true + }) + expect(inspect).toHaveBeenCalledWith('remote:owner:pty') + } +) + +it('keeps thrown inspections unverifiable and clears the deadline once all answer', async () => { + vi.useFakeTimers() + const inspect = vi.fn(async () => { + throw new Error('host unavailable') + }) + const [probe] = await probePtyRunningWorkWithInspection( + ['local-pty'], + { timeoutMs: 1000 }, + inspect + ) + expect(probe).toEqual({ + ptyId: 'local-pty', + verdict: 'unverifiable', + reason: 'probe_failed', + timedOut: false, + remote: false + }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('retains input order and unobserved verdicts when only some hosts answer before the deadline', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers() + const inspect = vi + .fn<(ptyId: string) => Promise>() + .mockResolvedValueOnce({ + foregroundProcess: 'zsh', + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + .mockReturnValueOnce(pending.promise) + const probing = probePtyRunningWorkWithInspection( + ['local-pty', 'remote:owner:pty'], + { timeoutMs: 10 }, + inspect + ) + await vi.advanceTimersByTimeAsync(10) + const probes = await probing + expect(probes).toEqual([ + { ptyId: 'local-pty', verdict: 'live', timedOut: false, remote: false }, + { + ptyId: 'remote:owner:pty', + verdict: 'unverifiable', + reason: 'probe_deadline', + timedOut: true, + remote: true + } + ]) + expect(inspect).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + pending.resolve({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + await pending.promise +}) diff --git a/src/shared/pty-running-work-probe.ts b/src/shared/pty-running-work-probe.ts new file mode 100644 index 00000000000..aad86e8d4d5 --- /dev/null +++ b/src/shared/pty-running-work-probe.ts @@ -0,0 +1,85 @@ +import { isRemoteExecutionHostPtyId } from './remote-execution-host-pty-id' +import { + isClientOnlyUnverifiableInspection, + type TerminalProcessInspection +} from './terminal-process-inspection' + +/** Owning-host verdicts: see docs/reference/ssh-execution-boundary.md. */ +export type PtyRunningWorkVerdict = 'live' | 'unverifiable' | 'exited' + +export type PtyRunningWorkProbe = { + ptyId: string + verdict: PtyRunningWorkVerdict + /** Why the owner could not be observed. Only set for `unverifiable`. */ + reason?: string + /** The deadline expired before this pty's probe answered at all. */ + timedOut: boolean + /** The pty is owned by a remote execution host (relay runtime or app SSH). */ + remote: boolean +} + +/** Never rejects; unanswered probes stay unverifiable until owning-host evidence arrives. */ +export async function probePtyRunningWorkWithInspection( + ptyIds: readonly string[], + options: { timeoutMs: number }, + inspect: (ptyId: string) => Promise +): Promise { + if (ptyIds.length === 0) { + return [] + } + const probes: PtyRunningWorkProbe[] = ptyIds.map((ptyId) => ({ + ptyId, + verdict: 'unverifiable', + reason: 'probe_deadline', + timedOut: true, + remote: isRemoteExecutionHostPtyId(ptyId) + })) + + const settle = Promise.all( + ptyIds.map(async (ptyId, index) => { + const probe = probes[index] + if (!probe) { + return + } + try { + const inspection = await inspect(ptyId) + probe.timedOut = false + if (isClientOnlyUnverifiableInspection(inspection)) { + probe.verdict = 'unverifiable' + probe.reason = inspection.reason + return + } + // The old boolean cannot distinguish an unreadable host process table. + if (inspection.childProcessEvidence === 'unverifiable') { + probe.verdict = 'unverifiable' + probe.reason = 'host_child_processes_unobserved' + return + } + probe.verdict = + (inspection.childProcessEvidence ?? + (inspection.hasChildProcesses ? 'children' : 'no-children')) === 'children' + ? 'live' + : 'exited' + delete probe.reason + } catch { + // An inspection failure cannot prove that execution stopped. + probe.timedOut = false + probe.verdict = 'unverifiable' + probe.reason = 'probe_failed' + } + }) + ) + + let deadline: ReturnType | undefined + try { + await Promise.race([ + settle, + new Promise((resolve) => { + deadline = setTimeout(resolve, options.timeoutMs) + }) + ]) + } finally { + clearTimeout(deadline) + } + return probes +} diff --git a/tests/e2e/daemon-running-work-probe.unit.test.ts b/tests/e2e/daemon-running-work-probe.unit.test.ts index 39bf29323f8..4a6ef996c44 100644 --- a/tests/e2e/daemon-running-work-probe.unit.test.ts +++ b/tests/e2e/daemon-running-work-probe.unit.test.ts @@ -3,19 +3,15 @@ import { Session } from '../../src/main/daemon/session' import { inspectTerminalHostProcess } from '../../src/main/daemon/terminal-host-process-inspection' import type * as SnapshotReader from '../../src/shared/process-table-snapshot-reader' import type { ProcessTableRow } from '../../src/shared/process-table-snapshot' -import { probePtyRunningWork } from '../../src/renderer/src/components/terminal/pty-running-work-probe' +import { probePtyRunningWorkWithInspection } from '../../src/shared/pty-running-work-probe' -const { readSnapshot, inspectRuntime } = vi.hoisted(() => ({ - readSnapshot: vi.fn(), - inspectRuntime: vi.fn() +const { readSnapshot } = vi.hoisted(() => ({ + readSnapshot: vi.fn() })) vi.mock('../../src/shared/process-table-snapshot-reader', async (importOriginal) => ({ ...(await importOriginal()), getStrictProcessTableSnapshotWithAge: readSnapshot })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - inspectRuntimeTerminalProcess: inspectRuntime -})) afterEach(() => vi.restoreAllMocks()) @@ -71,17 +67,17 @@ it.each(['stopped', 'background', 'idle', 'unreadable'] as const)( } }) try { - inspectRuntime.mockImplementation(() => - inspectTerminalHostProcess({ - sessionId: session.sessionId, - session, - authorityGeneration: 'owner', - nextObservationEpoch: () => 1 - }) + const [result] = await probePtyRunningWorkWithInspection( + ['remote:owner:close-guard'], + { timeoutMs: 1000 }, + () => + inspectTerminalHostProcess({ + sessionId: session.sessionId, + session, + authorityGeneration: 'owner', + nextObservationEpoch: () => 1 + }) ) - const [result] = await probePtyRunningWork(null, ['remote:owner:close-guard'], { - timeoutMs: 1000 - }) expect(result.verdict).toBe( state === 'idle' ? 'exited' : state === 'unreadable' ? 'unverifiable' : 'live' ) From b06f40f3bab70681c82eec9c1a87a6bda6d3b85e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:58:29 -0400 Subject: [PATCH 004/220] fix(opencode): read the binder's session store off the main thread; ship the reader worker in orcad (#24638) * fix(opencode): read the binder's session store on the foreign SQLite reader worker (STA-9122) Before: the OpenCode session binder listed new sessions from opencode.db with node:sqlite on the main thread every 60 s (and on SessionStart kicks), so a large or contended store could stall the app the same way Cursor's did. After: the read is a pure openCodeBinderSessions reader in foreign-sqlite-readers/readers/, run only on the worker. The binder's correlation, pane snapshot and process sweep stay where they were. - The binder round awaits listSessions and re-checks its generation right after, so a stop() during the read discards the round before it touches the unbound map or the watermark. - The client's in-flight dedupe key now includes the cursor, so a stale round from before a restart cannot hand its rows to the restarted round. - Idle teardown is per reader. The binder lane keeps its thread for 120 s, longer than its 60 s poll, so the thread is not respawned every round. - A timeout, crash, malformed reply or unstartable worker resolves to [] (no sessions), the value the old read already returned on failure. - An absent store still reads as [] without a log line, and a permission or corrupt-file failure still logs (kept from #24577, now in the reader: it stats the path and throws anything but ENOENT/ENOTDIR to the client's log). - The binder lane inherits #24572's limits from the shared lane: no respawn until a timed-out worker has exited, 2 consecutive deaths, a queue cap of 8. Its timeout stays 60 s, matching its poll. - dispatch switches on the destructured kind, so a new kind without a case still fails to compile. orcad: the hook server runs there too, so orcad now ships foreign-sqlite-reader-entry.js beside orcad.js (ORCAD_ARTIFACTS, built as an orcad child). build-orcad runs a smoke check that starts the built worker under the build's Node and under the pinned runtime, and does a real binder read on a fixture DB, a Cursor read of a missing file and an OpenCode history list. The OpenCode history scanner uses the same entry and was bundled into orcad without it, so on orcad it always failed closed; it can now run. Tests: reader (cursor, same-ms ids, OpenCode 2 rows, missing then created, corrupt, inaccessible directory), retirement gate for the binder lane, dispatch routing, client lane (rows, failure -> [], dedupe per cursor, own thread, idle teardown default and override), binder loop with an async listSessions (failure -> [], stop during the read), orcad path resolution through orcad's host adapters, artifact list, and the smoke check against good, missing and non-reading entries. * test(opencode): cover the binder read deadline with fake timers and name the failure test accurately (STA-9122) --- config/scripts/build-orcad.mjs | 17 ++ .../foreign-sqlite-reader-worker-smoke.mjs | 80 ++++++++ ...oreign-sqlite-reader-worker-smoke.test.mjs | 65 ++++++ config/scripts/orcad-entry-build.mjs | 3 +- .../server-opencode-binder.test.ts | 191 +++++++----------- .../server/server-opencode-binder.ts | 21 +- ...ite-reader-client-worker-lifecycle.test.ts | 81 +++++--- .../foreign-sqlite-reader-client.test.ts | 126 ++++++++++++ .../foreign-sqlite-reader-client.ts | 49 ++++- .../foreign-sqlite-reader-dispatch.test.ts | 25 +++ .../foreign-sqlite-reader-dispatch.ts | 14 +- .../foreign-sqlite-reader-entry-path.test.ts | 5 + .../foreign-sqlite-reader-protocol.ts | 11 +- .../foreign-sqlite-reader-spawn.ts | 14 ++ .../opencode-binder-sessions-result.ts | 35 ++++ .../readers/opencode-binder-sessions.test.ts | 105 ++++++++++ .../readers/opencode-binder-sessions.ts | 80 ++++++++ src/main/opencode/opencode-session-binder.ts | 98 +-------- .../orcad/orcad-forked-child-paths.test.ts | 13 ++ src/shared/orcad-artifacts.test.ts | 8 + src/shared/orcad-artifacts.ts | 5 + 21 files changed, 790 insertions(+), 256 deletions(-) create mode 100644 config/scripts/foreign-sqlite-reader-worker-smoke.mjs create mode 100644 config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs create mode 100644 src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts create mode 100644 src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts create mode 100644 src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 2bd9933d33a..66c8f535a5b 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -23,10 +23,12 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' +import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, + ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_NODE_PTY_DIR, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, @@ -56,6 +58,10 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') // orcad restart would SIGKILL every running terminal. const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') +// Why beside orcad.js: the hook server's OpenCode binder and the OpenCode history scanner +// start this worker from the module dir, since orcad has no Electron resources tree. +const FOREIGN_SQLITE_READER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader) +const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER_ENTRY) const OUT_FILE = join(OUT_DIR, 'orcad.js') const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET if (!BUILD_TARGET) { @@ -205,6 +211,7 @@ function buildForkedChild(entryPoint, outfile) { const childResults = await Promise.all([ buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), + buildForkedChild(FOREIGN_SQLITE_READER_ENTRY, FOREIGN_SQLITE_READER_OUT_FILE), ...['writer', 'backup'].map((role) => buildForkedChild( join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), @@ -340,6 +347,16 @@ try { process.exitCode = 1 } +try { + smokeForeignSqliteReaderWorker(OUT_DIR) + if (nodeRuntimePath) { + smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath }) + } +} catch (error) { + console.error('[build-orcad] foreign SQLite reader worker check failed:', error) + process.exitCode = 1 +} + // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on // this string, so two different builds carrying one version would share a directory — and an // already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs new file mode 100644 index 00000000000..b83c9c088af --- /dev/null +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../src/shared/orcad-artifacts.ts' + +// Why a child process: the read must run under the runtime orcad ships, which may not be +// the Node running the build. The verdict is the exit code, never matched output. +const PROBE = ` +const { Worker } = require('node:worker_threads') +const { DatabaseSync } = process.getBuiltinModule('node:sqlite') +const [entry, dbPath, missingPath] = process.argv.slice(2) +const db = new DatabaseSync(dbPath) +db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)') +db.prepare('INSERT INTO session VALUES (?, ?, ?, ?)').run('ses_smoke', '/smoke', 100, null) +db.close() +const steps = [ + { + request: { id: 1, kind: 'openCodeBinderSessions', dbPath, cursor: { ms: 0, id: '' } }, + expected: [{ id: 'ses_smoke', directory: '/smoke', createdAtMs: 100, parentId: null }] + }, + { request: { id: 2, kind: 'cursorProfile', dbPath: missingPath }, expected: { status: 'missing' } }, + // The OpenCode history scanner's kinds share this entry. + { request: { id: 3, kind: 'list', dbPaths: [], limit: null }, expected: { candidates: [], issues: [] } } +] +const worker = new Worker(entry, { execArgv: [] }) +const fail = (code, message) => { + console.error(message) + process.exit(code) +} +setTimeout(() => fail(3, 'foreign SQLite reader worker did not answer'), 20000).unref() +worker.on('error', (error) => fail(4, String(error && error.stack || error))) +worker.on('exit', (code) => fail(5, 'foreign SQLite reader worker exited with ' + code)) +let step = 0 +worker.on('message', (response) => { + const { request, expected } = steps[step] + if (!response || response.id !== request.id || response.ok !== true || + JSON.stringify(response.value) !== JSON.stringify(expected)) { + fail(6, request.kind + ' answered ' + JSON.stringify(response)) + } + step += 1 + if (step === steps.length) { + process.exit(0) + } + worker.postMessage(steps[step].request) +}) +worker.postMessage(steps[0].request) +` + +/** + * Load the built foreign SQLite reader entry and run real reads through it. + * @param outDir - orcad output directory holding the entry. + * @param options.runtimePath - Node to run under; the build's own Node when omitted. + */ +export function smokeForeignSqliteReaderWorker(outDir, { runtimePath, timeoutMs = 30_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-')) + try { + const probe = join(directory, 'probe.cjs') + writeFileSync(probe, PROBE) + const result = runProcessSync({ + program: runtimePath ?? process.execPath, + args: [ + probe, + resolve(outDir, ORCAD_FOREIGN_SQLITE_READER_ENTRY), + join(directory, 'opencode.db'), + join(directory, 'missing.vscdb') + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs, + maxOutputBytes: 64 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + `Foreign SQLite reader worker smoke failed: ${describeProcessFailure(result)}` + ) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs new file mode 100644 index 00000000000..36121ad88d4 --- /dev/null +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs @@ -0,0 +1,65 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' +import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' + +const ENTRY = 'foreign-sqlite-reader-entry.js' +const directories = [] +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await build({ + entryPoints: [resolve(ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader)], + outfile: join(builtDirectory, ENTRY), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) +}, 60_000) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('foreign SQLite reader build smoke', () => { + it('passes against the built entry', () => { + expect(() => smokeForeignSqliteReaderWorker(builtDirectory)).not.toThrow() + }) + + it('fails when the entry is missing', () => { + expect(() => smokeForeignSqliteReaderWorker(fixtureDirectory())).toThrow('smoke failed') + }) + + it('fails when the worker answers without reading', () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, ENTRY), + `const { parentPort } = require('node:worker_threads') + parentPort.on('message', ({ id }) => parentPort.postMessage({ id, ok: true, value: [] }))` + ) + expect(() => smokeForeignSqliteReaderWorker(directory)).toThrow('smoke failed') + }) + + it('runs in the orcad build under both runtimes', () => { + const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') + expect(source).toContain('smokeForeignSqliteReaderWorker(OUT_DIR)') + expect(source).toContain( + 'smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath })' + ) + }) +}) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index e168430fe0a..8ee6b243b3d 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -8,7 +8,8 @@ export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', - backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' + backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts', + foreignSqliteReader: 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts' } export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index ab6ed0ad0ba..354d542fb17 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -1,14 +1,13 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentHookSource } from '../../shared/agent-hook-relay' import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' import { makePaneKey } from '../../shared/stable-pane-id' -import SyncDatabase from '../sqlite/sync-database' +import type { + BinderSessionRow, + OpenCodeSessionCursor +} from '../foreign-sqlite-readers/opencode-binder-sessions-result' import { AgentHookServer } from './server' import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder' -import { listOpenCodeDbSessions } from '../opencode/opencode-session-binder' const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -38,33 +37,42 @@ class BinderTestServer extends AgentHookServer { } } -function writeDb(dbPath: string, table: 'session_v2' | 'session', id = 'ses_live'): void { - const db = new SyncDatabase(dbPath) - try { - db.exec( - `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` - ) - const insert = db.prepare( - `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` - ) - insert.run(id, DIR, Date.now() - 60_000, null) - } finally { - db.close() +const DB_PATH = '/tmp/binder-store/opencode.db' + +/** + * Stands in for the worker read: OpenCode 1 rows past the cursor, oldest first. + * The SQL itself is covered by readers/opencode-binder-sessions.test.ts. + */ +class FakeSessionStore { + rows: BinderSessionRow[] = [] + calls: { dbPath: string; cursor: OpenCodeSessionCursor }[] = [] + + add(id = 'ses_live'): void { + this.rows.push({ id, directory: DIR, createdAtMs: Date.now() - 60_000, parentId: null }) + } + + list = async (dbPath: string, cursor: OpenCodeSessionCursor): Promise => { + this.calls.push({ dbPath, cursor }) + return this.rows + .filter( + (row) => + row.createdAtMs > cursor.ms || (row.createdAtMs === cursor.ms && row.id > cursor.id) + ) + .sort((a, b) => a.createdAtMs - b.createdAtMs || (a.id < b.id ? -1 : 1)) } } describe('opencode binder loop', () => { - let dir = '' - let dbPath = '' + let store: FakeSessionStore let server: BinderTestServer beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-db-')) - dbPath = join(dir, 'opencode.db') + store = new FakeSessionStore() server = new BinderTestServer() server.bindDeps({ now: () => Date.now(), - dbPath: () => dbPath, + dbPath: () => DB_PATH, + listSessions: store.list, listPanes: () => [ { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 } ], @@ -82,24 +90,53 @@ describe('opencode binder loop', () => { afterEach(() => { server.stop() - rmSync(dir, { recursive: true, force: true }) }) it('binds a fresh OpenCode 1 session to its pane', async () => { - writeDb(dbPath, 'session') + store.add() const applied = await server.runBinderRound() expect(applied).toBe(1) expect(server.readRegistry('ses_live')).toBe(PANE_A) + expect(store.calls[0]).toEqual({ dbPath: DB_PATH, cursor: { ms: 0, id: '' } }) }) - it('never binds an OpenCode 2 session', async () => { - writeDb(dbPath, 'session_v2') + it('skips the round when the read answers its failure value', async () => { + // [] is what the worker client resolves to on a timeout, crash or unreadable store. + const sweep = vi.fn(async () => []) + server.bindDeps({ listSessions: async () => [], sweep }) expect(await server.runBinderRound()).toBe(0) + expect(sweep).not.toHaveBeenCalled() + }) + + it('discards a round whose session read was in flight across stop', async () => { + store.add() + let releaseRead!: () => void + const readGate = new Promise((resolve) => { + releaseRead = resolve + }) + const sweep = vi.fn(async () => []) + server.bindDeps({ + sweep, + listSessions: async (dbPath, cursor) => { + await readGate + return store.list(dbPath, cursor) + } + }) + const round = server.runBinderRound() + server.stop() + releaseRead() + expect(await round).toBe(0) + expect(sweep).not.toHaveBeenCalled() expect(server.readRegistry('ses_live')).toBeUndefined() + + // The stale round left the watermark alone: the next round lists from the start. + server.bindDeps({ listSessions: store.list }) + expect(await server.runBinderRound()).toBe(0) + expect(store.calls.at(-1)?.cursor).toEqual({ ms: 0, id: '' }) }) it('an opencode SessionStart kicks a round that binds before the poll', async () => { - writeDb(dbPath, 'session') + store.add() vi.useFakeTimers() try { // Birth arrives stamped with the wrong (server-starter) pane. @@ -117,7 +154,7 @@ describe('opencode binder loop', () => { }) it('an OpenCode 2 SessionStart kicks no round', async () => { - writeDb(dbPath, 'session') + store.add() const sweep = vi.fn(async () => []) server.bindDeps({ sweep }) vi.useFakeTimers() @@ -136,7 +173,7 @@ describe('opencode binder loop', () => { }) it('pane teardown unbinds its sessions', async () => { - writeDb(dbPath, 'session') + store.add() await server.runBinderRound() expect(server.readRegistry('ses_live')).toBe(PANE_A) server.clearPaneState(PANE_A) @@ -144,12 +181,12 @@ describe('opencode binder loop', () => { }) it('stops the loop without hanging the process', () => { - writeDb(dbPath, 'session') + store.add() expect(() => server.stop()).not.toThrow() }) it('runs a round immediately on loop start', async () => { - writeDb(dbPath, 'session') + store.add() server.startBinderLoop() try { await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) @@ -159,7 +196,7 @@ describe('opencode binder loop', () => { }) it('discards a round that was in flight across stop', async () => { - writeDb(dbPath, 'session') + store.add() let releaseSweep!: () => void const sweepGate = new Promise((resolve) => { releaseSweep = resolve @@ -186,7 +223,7 @@ describe('opencode binder loop', () => { }) it('an obsolete round does not clear the new round running flag', async () => { - writeDb(dbPath, 'session') + store.add() let releaseFirst!: () => void let releaseLater!: () => void const firstGate = new Promise((resolve) => { @@ -232,19 +269,17 @@ describe('opencode binder loop', () => { // OpenCode 1 `serve` in pane A stamps every post with pane A; `attach` in pane B drives the session. describe('OpenCode 1 serve + attach', () => { - let dir = '' - let dbPath = '' let server: BinderTestServer beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-attach-')) - dbPath = join(dir, 'opencode.db') - writeDb(dbPath, 'session') + const store = new FakeSessionStore() + store.add() const startedAtMs = Date.now() - 120_000 server = new BinderTestServer() server.bindDeps({ now: () => Date.now(), - dbPath: () => dbPath, + dbPath: () => DB_PATH, + listSessions: store.list, listPanes: () => [ { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }, { paneKey: PANE_B, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 211 } @@ -264,7 +299,6 @@ describe('OpenCode 1 serve + attach', () => { afterEach(() => { server.stop() - rmSync(dir, { recursive: true, force: true }) }) const busy = (extra: Record = {}): Record => ({ @@ -284,80 +318,3 @@ describe('OpenCode 1 serve + attach', () => { expect(server.ingest('opencode', busy({ opencodeMajor: 2 }))).toBe(PANE_A) }) }) - -describe('listOpenCodeDbSessions', () => { - let dir = '' - let dbPath = '' - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-reader-')) - dbPath = join(dir, 'opencode.db') - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - }) - - it('reads OpenCode 1 session rows newer than the watermark', () => { - writeDb(dbPath, 'session') - const rows = listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ id: 'ses_live', directory: DIR, parentId: null }) - expect(listOpenCodeDbSessions(dbPath, { ms: Date.now(), id: '' })).toEqual([]) - }) - - it('skips OpenCode 2 rows in a database both versions wrote', () => { - writeDb(dbPath, 'session', 'ses_v1') - writeDb(dbPath, 'session_v2', 'ses_v2') - expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }).map((row) => row.id)).toEqual([ - 'ses_v1' - ]) - }) - - it('returns [] for a missing database instead of throwing', () => { - expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) - }) - - it('stays quiet for a missing database and detects it once it appears', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const absent = join(dir, 'later.db') - expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([]) - expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([]) - expect(warn).not.toHaveBeenCalled() - writeDb(absent, 'session') - expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toHaveLength(1) - expect(warn).not.toHaveBeenCalled() - } finally { - warn.mockRestore() - } - }) - - it('still warns when the database exists but cannot be read', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - writeFileSync(dbPath, 'this is not a sqlite database'.repeat(100)) - expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([]) - expect(warn).toHaveBeenCalledTimes(1) - } finally { - warn.mockRestore() - } - }) - - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'warns when an existing database is behind an inaccessible directory', - () => { - writeDb(dbPath, 'session') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - chmodSync(dir, 0o000) - expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([]) - expect(warn).toHaveBeenCalledTimes(1) - expect(warn.mock.calls[0]?.[1]).toMatchObject({ code: 'EACCES' }) - } finally { - chmodSync(dir, 0o700) - warn.mockRestore() - } - } - ) -}) diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts index 48a75ba5483..a1d2d6d8d38 100644 --- a/src/main/agent-hooks/server/server-opencode-binder.ts +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -3,13 +3,15 @@ import { applyBinderOwnerships, defaultOpenCodeDbPath, listBinderPaneSnapshots, - listOpenCodeDbSessions, OPENCODE_SESSION_CURSOR_START, runOpenCodeBinderRound, - type BinderPaneSnapshot, - type BinderSessionRow, - type OpenCodeSessionCursor + type BinderPaneSnapshot } from '../../opencode/opencode-session-binder' +import type { + BinderSessionRow, + OpenCodeSessionCursor +} from '../../foreign-sqlite-readers/opencode-binder-sessions-result' +import { readOpenCodeBinderSessions } from '../../foreign-sqlite-readers/foreign-sqlite-reader-spawn' import { sweepProcessIdentities, type ProcessIdentityRow @@ -29,7 +31,8 @@ const OPENCODE_BINDER_UNBOUND_MAX = 500 export type OpenCodeBinderLoopDeps = { now: () => number dbPath: () => string - listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => BinderSessionRow[] + /** Resolves `[]` when the store or its worker cannot answer; never rejects. */ + listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => Promise listPanes: () => BinderPaneSnapshot[] sweep: () => Promise } @@ -54,7 +57,7 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi private openCodeBinderDeps: OpenCodeBinderLoopDeps = { now: () => Date.now(), dbPath: () => defaultOpenCodeDbPath(), - listSessions: (dbPath, sinceMs) => listOpenCodeDbSessions(dbPath, sinceMs), + listSessions: (dbPath, cursor) => readOpenCodeBinderSessions(dbPath, cursor), listPanes: () => listBinderPaneSnapshots(), sweep: () => sweepProcessIdentities() } @@ -131,7 +134,11 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi try { const deps = this.openCodeBinderDeps const nowMs = deps.now() - const fresh = deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) + const fresh = await deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) + // Why: stop() may land while the worker reads; its state and watermark were reset. + if (generation !== this.openCodeBinderGeneration) { + return 0 + } const sessions = [...fresh] for (const [id, entry] of this.openCodeBinderUnbound) { if (nowMs - entry.firstSeenMs > OPENCODE_BINDER_UNBOUND_RETRY_MS) { diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts index 87448246017..ec28e6e4c25 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts @@ -78,41 +78,60 @@ describe('ForeignSqliteReaderClient worker lifecycle', () => { expect(workers).toHaveLength(2) }) - it('refuses new native workers until the retired worker positively terminates', async () => { - let spawns = 0 - let finishRetirement: (code: number) => void = () => {} - const retirement = new Promise((resolve) => { - finishRetirement = resolve - }) - const events = new EventEmitter() - const client = makeClient(() => { - spawns++ - return { - on: (...args) => events.on(...args), - off: (...args) => events.off(...args), - removeAllListeners: () => events.removeAllListeners(), - unref: () => undefined, - terminate: () => retirement, - postMessage: (request: { id: number }) => { - if (spawns > 1) { - queueMicrotask(() => - events.emit('message', { id: request.id, ok: true, value: MISSING }) - ) + // Every reader shares the lane, so each must keep the retirement gate. + it.each([ + { + reader: 'cursorProfile', + read: (client: ForeignSqliteReaderClient) => + client.readCursorProfile('/synthetic/state.vscdb'), + failure: FAILURE, + answer: MISSING + }, + { + reader: 'openCodeBinderSessions', + read: (client: ForeignSqliteReaderClient) => + client.readOpenCodeBinderSessions('/synthetic/opencode.db', { ms: 0, id: '' }), + failure: [], + answer: [{ id: 'ses_a', directory: '/w', createdAtMs: 1, parentId: null }] + } + ])( + '$reader refuses new native workers until the retired worker positively terminates', + async ({ read, failure, answer }) => { + let spawns = 0 + let finishRetirement: (code: number) => void = () => {} + const retirement = new Promise((resolve) => { + finishRetirement = resolve + }) + const events = new EventEmitter() + const client = makeClient(() => { + spawns++ + return { + on: (...args) => events.on(...args), + off: (...args) => events.off(...args), + removeAllListeners: () => events.removeAllListeners(), + unref: () => undefined, + terminate: () => retirement, + postMessage: (request: { id: number }) => { + if (spawns > 1) { + queueMicrotask(() => + events.emit('message', { id: request.id, ok: true, value: answer }) + ) + } } } + }, 20) + expect(await read(client)).toEqual(failure) + for (let i = 0; i < 3; i++) { + expect(await read(client)).toEqual(failure) } - }, 20) - expect(await client.readCursorProfile('/synthetic/state.vscdb')).toEqual(FAILURE) - for (let i = 0; i < 3; i++) { - expect(await client.readCursorProfile('/synthetic/state.vscdb')).toEqual(FAILURE) + expect(spawns).toBe(1) + finishRetirement(1) + await retirement + await afterRetirement() + expect(await read(client)).toEqual(answer) + expect(spawns).toBe(2) } - expect(spawns).toBe(1) - finishRetirement(1) - await retirement - await afterRetirement() - expect(await client.readCursorProfile('/synthetic/state.vscdb')).toEqual(MISSING) - expect(spawns).toBe(2) - }) + ) it('settles pending reads when disposed', async () => { const client = makeClient(() => new Worker('setInterval(() => {}, 1000)', { eval: true })) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts index 5f1b614d511..91a25f7124a 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts @@ -252,3 +252,129 @@ describe('ForeignSqliteReaderLane', () => { lane.dispose() }) }) + +describe('ForeignSqliteReaderClient OpenCode binder sessions', () => { + const START = { ms: 0, id: '' } + const ROW = { id: 'ses_a', directory: '/w', createdAtMs: 100, parentId: null } + + it('returns the rows the worker posts', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const read = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 5, id: 'x' }) + expect(workers[0]?.posted[0]).toMatchObject({ + kind: 'openCodeBinderSessions', + dbPath: '/o/opencode.db', + cursor: { ms: 5, id: 'x' } + }) + workers[0]?.reply([ROW]) + await expect(read).resolves.toEqual([ROW]) + client.dispose() + }) + + it('returns no sessions once the 60 s read deadline elapses', async () => { + vi.useFakeTimers() + try { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {} + }) + let result: unknown + void client.readOpenCodeBinderSessions('/o/opencode.db', START).then((value) => { + result = value + }) + await vi.advanceTimersByTimeAsync(59_999) + expect(result).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result).toEqual([]) + expect(workers[0]?.terminated).toBe(true) + client.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('returns no sessions on a worker error, a malformed reply or a crash', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {} + }) + const errored = client.readOpenCodeBinderSessions('/o/opencode.db', START) + workers[0]?.emit('message', { id: workers[0].posted[0]?.id, ok: false, error: 'busy' }) + await expect(errored).resolves.toEqual([]) + const malformed = client.readOpenCodeBinderSessions('/o/opencode.db', START) + workers[0]?.reply([{ id: 'ses_a' }]) + await expect(malformed).resolves.toEqual([]) + const crashed = client.readOpenCodeBinderSessions('/o/opencode.db', START) + workers[0]?.emit('exit', 1) + await expect(crashed).resolves.toEqual([]) + client.dispose() + }) + + it('does not share an in-flight read across cursors', async () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + const stale = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 100, id: 'ses_a' }) + const restarted = client.readOpenCodeBinderSessions('/o/opencode.db', START) + expect(restarted).not.toBe(stale) + expect(client.readOpenCodeBinderSessions('/o/opencode.db', START)).toBe(restarted) + client.dispose() + }) + + it('runs on its own thread, apart from the Cursor reader', () => { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ workerFactory: fakeFactory(workers), log() {} }) + void client.readCursorProfile('/a/state.vscdb') + void client.readOpenCodeBinderSessions('/o/opencode.db', START) + expect(workers).toHaveLength(2) + expect(workers[1]?.posted[0]?.kind).toBe('openCodeBinderSessions') + client.dispose() + }) +}) + +describe('ForeignSqliteReaderClient idle teardown', () => { + it('keeps the binder thread alive across its 60 s poll and tears the Cursor one down', async () => { + vi.useFakeTimers() + try { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {} + }) + const cursor = client.readCursorProfile('/a/state.vscdb') + const binder = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 0, id: '' }) + workers[0]?.reply(OK) + workers[1]?.reply([]) + await Promise.all([cursor, binder]) + await vi.advanceTimersByTimeAsync(60_000) + expect(workers[0]?.terminated).toBe(true) + expect(workers[1]?.terminated).toBe(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(workers[1]?.terminated).toBe(true) + client.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('takes a per-reader override', async () => { + vi.useFakeTimers() + try { + const workers: FakeWorker[] = [] + const client = new ForeignSqliteReaderClient({ + workerFactory: fakeFactory(workers), + log() {}, + idleTeardownMs: { openCodeBinderSessions: 1_000 } + }) + const binder = client.readOpenCodeBinderSessions('/o/opencode.db', { ms: 0, id: '' }) + workers[0]?.reply([]) + await binder + await vi.advanceTimersByTimeAsync(1_000) + expect(workers[0]?.terminated).toBe(true) + client.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts index b04ed8895ef..854d3a06dcd 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts @@ -5,6 +5,12 @@ import { parseCursorProfileReadResult, type CursorDesktopProfileReadResult } from './cursor-profile-result' +import { + openCodeBinderSessionsFailure, + parseOpenCodeBinderSessions, + type BinderSessionRow, + type OpenCodeSessionCursor +} from './opencode-binder-sessions-result' import type { ForeignSqliteReaderKind, ForeignSqliteReaderRequest, @@ -18,9 +24,15 @@ import type { // Limits from the dedicated Cursor worker this replaces (#24572). const READ_TIMEOUT_MS: Record = { // Covers the 4-6 s WAL-index rebuild reported in #24360 with margin. - cursorProfile: 10_000 + cursorProfile: 10_000, + openCodeBinderSessions: 60_000 +} +// Why per reader: a thread torn down between a poller's rounds is respawned every round. +const DEFAULT_IDLE_TEARDOWN_MS: Record = { + cursorProfile: 30_000, + // The OpenCode binder polls every 60 s. + openCodeBinderSessions: 120_000 } -const IDLE_TEARDOWN_MS = 30_000 const MAX_CONSECUTIVE_DEATHS = 2 // Reads are deduped per key, so a backlog past this is pile-up, not demand. const MAX_QUEUED_READS = 8 @@ -113,18 +125,19 @@ export class ForeignSqliteReaderLane { export class ForeignSqliteReaderClient { private readonly cursorProfile: ForeignSqliteReaderLane + private readonly openCodeBinderSessions: ForeignSqliteReaderLane constructor(options: { workerFactory: WorkerThreadFactory log?: (message: string) => void timeoutMs?: number - idleTeardownMs?: number + idleTeardownMs?: Partial> }) { const settings = (kind: ForeignSqliteReaderKind): LaneSettings => ({ workerFactory: options.workerFactory, log: options.log ?? ((message: string) => console.warn(message)), timeoutMs: options.timeoutMs ?? READ_TIMEOUT_MS[kind], - idleTeardownMs: options.idleTeardownMs ?? IDLE_TEARDOWN_MS + idleTeardownMs: options.idleTeardownMs?.[kind] ?? DEFAULT_IDLE_TEARDOWN_MS[kind] }) this.cursorProfile = new ForeignSqliteReaderLane( 'cursorProfile', @@ -132,6 +145,12 @@ export class ForeignSqliteReaderClient { cursorProfileReadFailure, settings('cursorProfile') ) + this.openCodeBinderSessions = new ForeignSqliteReaderLane( + 'openCodeBinderSessions', + parseOpenCodeBinderSessions, + openCodeBinderSessionsFailure, + settings('openCodeBinderSessions') + ) } /** @@ -144,8 +163,30 @@ export class ForeignSqliteReaderClient { return this.cursorProfile.read(dbPath, (id) => ({ id, kind: 'cursorProfile', dbPath })) } + /** + * List OpenCode 1 sessions newer than `cursor` off the main thread. + * @param dbPath - The shared server's opencode.db. + * @param cursor - Store position the binder has handled up to. + * @returns Rows oldest first; `[]` when the store or the worker cannot answer. + */ + readOpenCodeBinderSessions( + dbPath: string, + cursor: OpenCodeSessionCursor + ): Promise { + // Why the cursor in the key: a round from before a stop can still be in flight + // with an older cursor, and its rows are not the answer for a restarted round. + const key = JSON.stringify([dbPath, cursor.ms, cursor.id]) + return this.openCodeBinderSessions.read(key, (id) => ({ + id, + kind: 'openCodeBinderSessions', + dbPath, + cursor: { ms: cursor.ms, id: cursor.id } + })) + } + dispose(): void { this.cursorProfile.dispose() + this.openCodeBinderSessions.dispose() } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts index 4aebc6408e4..0d25bca0a53 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { handleForeignSqliteReaderRequest } from './foreign-sqlite-reader-dispatch' @@ -12,6 +15,28 @@ describe('handleForeignSqliteReaderRequest', () => { ).toEqual({ id: 4, ok: true, value: { status: 'missing' } }) }) + it('routes openCodeBinderSessions to its reader and reports a failed open as an error', () => { + const request = { kind: 'openCodeBinderSessions' as const, cursor: { ms: 0, id: '' } } + expect( + handleForeignSqliteReaderRequest({ + ...request, + id: 5, + dbPath: '/definitely/missing/opencode.db' + }) + ).toEqual({ id: 5, ok: true, value: [] }) + const dir = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-dispatch-')) + try { + const dbPath = join(dir, 'opencode.db') + writeFileSync(dbPath, 'not a sqlite database'.repeat(100)) + expect(handleForeignSqliteReaderRequest({ ...request, id: 6, dbPath })).toMatchObject({ + id: 6, + ok: false + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('rejects a kind no reader owns instead of running one', () => { // Parsed, as a structured clone arrives: untyped, with a kind outside the union. const request = JSON.parse('{"id":9,"kind":"list","dbPaths":[]}') diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts index 69214e5edcd..c1e5b420d6d 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts @@ -3,6 +3,7 @@ import type { ForeignSqliteReaderResponse } from './foreign-sqlite-reader-protocol' import { readCursorProfile } from './readers/cursor-profile' +import { readOpenCodeBinderSessions } from './readers/opencode-binder-sessions' /** * Run one foreign-app SQLite read on the worker thread. @@ -12,14 +13,19 @@ import { readCursorProfile } from './readers/cursor-profile' export function handleForeignSqliteReaderRequest( request: ForeignSqliteReaderRequest ): ForeignSqliteReaderResponse { + // Destructured from the parameter so `kind` narrows `request` and ends as `never`. + const { id, kind } = request try { - if (request.kind === 'cursorProfile') { - return { id: request.id, ok: true, value: readCursorProfile(request.dbPath) } + switch (kind) { + case 'cursorProfile': + return { id, ok: true, value: readCursorProfile(request.dbPath) } + case 'openCodeBinderSessions': + return { id, ok: true, value: readOpenCodeBinderSessions(request.dbPath, request.cursor) } } // A structured clone can carry any kind; one without a reader is refused, not guessed at. - return unknownKind(request.id, request.kind) + return unknownKind(id, kind) } catch (err) { - return { id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) } + return { id, ok: false, error: err instanceof Error ? err.message : String(err) } } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts index 23280d8b069..f0cb7715bef 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts @@ -4,6 +4,7 @@ import { FOREIGN_SQLITE_READER_ENTRY_FILENAME as ENTRY, resolveForeignSqliteReaderEntryPath } from './foreign-sqlite-reader-entry-path' +import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../shared/orcad-artifacts' const unpackaged = (moduleDir: string) => ({ isPackaged: false, @@ -12,6 +13,10 @@ const unpackaged = (moduleDir: string) => ({ }) describe('resolveForeignSqliteReaderEntryPath', () => { + it('looks for the file name the orcad build emits', () => { + expect(ENTRY).toBe(ORCAD_FOREIGN_SQLITE_READER_ENTRY) + }) + it('resolves the entry beside a dev bundle', () => { const expected = join('out', 'main', ENTRY) expect( diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts index 99293a5b761..2dc35084356 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts @@ -1,12 +1,21 @@ // Type-only and electron-free: the worker entry and the main-process client both import it. +import type { OpenCodeSessionCursor } from './opencode-binder-sessions-result' + type CursorProfileRequest = { id: number kind: 'cursorProfile' dbPath: string } -export type ForeignSqliteReaderRequest = CursorProfileRequest +type OpenCodeBinderSessionsRequest = { + id: number + kind: 'openCodeBinderSessions' + dbPath: string + cursor: OpenCodeSessionCursor +} + +export type ForeignSqliteReaderRequest = CursorProfileRequest | OpenCodeBinderSessionsRequest export type ForeignSqliteReaderKind = ForeignSqliteReaderRequest['kind'] diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts index 706c32377cb..de4654d1160 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts @@ -4,6 +4,7 @@ import { currentWorkerEntryLayout } from '../worker-thread-entry-path' import type { CursorDesktopProfileReadResult } from './cursor-profile-result' import { ForeignSqliteReaderClient } from './foreign-sqlite-reader-client' import { resolveForeignSqliteReaderEntryPath } from './foreign-sqlite-reader-entry-path' +import type { BinderSessionRow, OpenCodeSessionCursor } from './opencode-binder-sessions-result' // Why: owns the process-wide client and the real worker factory, so the client // class stays testable with a fake factory and callers see only plain functions. @@ -32,3 +33,16 @@ function getSharedClient(): ForeignSqliteReaderClient { export function readCursorDesktopProfile(dbPath: string): Promise { return getSharedClient().readCursorProfile(dbPath) } + +/** + * List OpenCode 1 sessions newer than `cursor` on the foreign SQLite reader worker. + * @param dbPath - The shared server's opencode.db. + * @param cursor - Store position the binder has handled up to. + * @returns Rows oldest first; `[]` when the store or the worker cannot answer. + */ +export function readOpenCodeBinderSessions( + dbPath: string, + cursor: OpenCodeSessionCursor +): Promise { + return getSharedClient().readOpenCodeBinderSessions(dbPath, cursor) +} diff --git a/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts b/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts new file mode 100644 index 00000000000..9a27f89f2ea --- /dev/null +++ b/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +/** One session store row feeding a binder round. */ +export type BinderSessionRow = { + id: string + directory: string + createdAtMs: number + parentId: string | null +} + +/** Position in the session store; composite so same-millisecond rows are never skipped. */ +export type OpenCodeSessionCursor = { + ms: number + id: string +} + +/** No sessions: what the binder read has always answered when the store can't be read. */ +export function openCodeBinderSessionsFailure(): BinderSessionRow[] { + return [] +} + +const rowsSchema = z.array( + z.object({ + id: z.string(), + directory: z.string(), + createdAtMs: z.number(), + parentId: z.string().nullable() + }) +) + +/** A worker reply arrives as a structured clone; null when it is not a list of session rows. */ +export function parseOpenCodeBinderSessions(value: unknown): BinderSessionRow[] | null { + const parsed = rowsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts new file mode 100644 index 00000000000..83396ff02d9 --- /dev/null +++ b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts @@ -0,0 +1,105 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import SyncDatabase from '../../sqlite/sync-database' +import { readOpenCodeBinderSessions } from './opencode-binder-sessions' + +const DIR = '/tmp/binder-worktree-a' +const START = { ms: 0, id: '' } + +let dir = '' +let dbPath = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-binder-reader-')) + dbPath = join(dir, 'opencode.db') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeSessions( + table: 'session' | 'session_v2', + rows: { id: string; createdAtMs: number; parentId?: string }[] +): void { + const db = new SyncDatabase(dbPath) + try { + db.exec( + `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` + ) + const insert = db.prepare( + `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` + ) + for (const row of rows) { + insert.run(row.id, DIR, row.createdAtMs, row.parentId ?? null) + } + } finally { + db.close() + } +} + +describe('readOpenCodeBinderSessions', () => { + it('reads OpenCode 1 rows past the cursor, oldest first', () => { + writeSessions('session', [ + { id: 'ses_b', createdAtMs: 200, parentId: 'ses_a' }, + { id: 'ses_a', createdAtMs: 100 } + ]) + expect(readOpenCodeBinderSessions(dbPath, START)).toEqual([ + { id: 'ses_a', directory: DIR, createdAtMs: 100, parentId: null }, + { id: 'ses_b', directory: DIR, createdAtMs: 200, parentId: 'ses_a' } + ]) + expect(readOpenCodeBinderSessions(dbPath, { ms: 200, id: 'ses_b' })).toEqual([]) + }) + + it('re-lists rows that share the cursor millisecond with a later id', () => { + writeSessions('session', [ + { id: 'ses_a', createdAtMs: 100 }, + { id: 'ses_b', createdAtMs: 100 } + ]) + expect( + readOpenCodeBinderSessions(dbPath, { ms: 100, id: 'ses_a' }).map((row) => row.id) + ).toEqual(['ses_b']) + }) + + it('skips OpenCode 2 rows in a database both versions wrote', () => { + writeSessions('session', [{ id: 'ses_v1', createdAtMs: 100 }]) + writeSessions('session_v2', [{ id: 'ses_v2', createdAtMs: 100 }]) + expect(readOpenCodeBinderSessions(dbPath, START).map((row) => row.id)).toEqual(['ses_v1']) + }) + + it('reads a store without the session table as empty', () => { + writeSessions('session_v2', [{ id: 'ses_v2', createdAtMs: 100 }]) + expect(readOpenCodeBinderSessions(dbPath, START)).toEqual([]) + }) + + // #24577: an absent store is normal, so it answers [] and the client logs nothing. + it('reads a missing database as empty and detects it once it appears', () => { + expect(readOpenCodeBinderSessions(join(dir, 'later.db'), START)).toEqual([]) + expect(readOpenCodeBinderSessions(join(dir, 'missing', 'opencode.db'), START)).toEqual([]) + dbPath = join(dir, 'later.db') + writeSessions('session', [{ id: 'ses_a', createdAtMs: 100 }]) + expect(readOpenCodeBinderSessions(dbPath, START)).toHaveLength(1) + }) + + it('throws for a corrupt database so the client logs it and answers its failure value', () => { + writeFileSync(dbPath, 'not a sqlite database'.repeat(100)) + expect(() => readOpenCodeBinderSessions(dbPath, START)).toThrow() + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'throws when an existing database is behind an inaccessible directory', + () => { + writeSessions('session', [{ id: 'ses_a', createdAtMs: 100 }]) + try { + chmodSync(dir, 0o000) + expect(() => readOpenCodeBinderSessions(dbPath, START)).toThrow( + expect.objectContaining({ code: 'EACCES' }) + ) + } finally { + chmodSync(dir, 0o700) + } + } + ) +}) diff --git a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts new file mode 100644 index 00000000000..817e9469f8e --- /dev/null +++ b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts @@ -0,0 +1,80 @@ +import { statSync } from 'node:fs' +import { readOpenCodeDatabase } from '../../ai-vault/session-scanner-opencode-sqlite-open' +import { columnExists, tableExists } from '../../opencode-usage/schema-helpers' +import type { BinderSessionRow, OpenCodeSessionCursor } from '../opencode-binder-sessions-result' + +/** + * Sessions newer than `cursor`, oldest first. The composite `(time_created, id)` + * position re-lists rows sharing a millisecond with the cursor, including rows + * the LIMIT cut off last round. Unknown shapes read as empty so an OpenCode + * schema move degrades to unbound sessions. An absent store is empty (#24577); + * any other failed open throws, so the client logs it. + */ +export function readOpenCodeBinderSessions( + dbPath: string, + cursor: OpenCodeSessionCursor +): BinderSessionRow[] { + if (!storeExists(dbPath)) { + return [] + } + return readOpenCodeDatabase({ + dbPath, + read: (db) => { + // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its + // posts name their own pane, so its sessions must never bind. + const table = 'session' + if ( + !tableExists(db, table) || + !columnExists(db, table, 'directory') || + !columnExists(db, table, 'time_created') + ) { + return [] + } + const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL' + const rows: unknown[] = db + .prepare( + `SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500` + ) + .all(cursor.ms, cursor.ms, cursor.id) + const sessions: BinderSessionRow[] = [] + for (const row of rows) { + if (typeof row !== 'object' || row === null) { + continue + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else. + const record = row as Record + if ( + typeof record.id !== 'string' || + typeof record.directory !== 'string' || + typeof record.time_created !== 'number' + ) { + continue + } + sessions.push({ + id: record.id, + directory: record.directory, + createdAtMs: record.time_created, + parentId: typeof record.parent_id === 'string' ? record.parent_id : null + }) + } + return sessions + } + }) +} + +// Why stat, not existsSync: a permission failure must surface, not read as an unused store. +function storeExists(dbPath: string): boolean { + try { + statSync(dbPath) + return true + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + (err.code === 'ENOENT' || err.code === 'ENOTDIR') + ) { + return false + } + throw err + } +} diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 8b62a05fa82..7f7d95876c8 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -1,4 +1,3 @@ -import { statSync } from 'node:fs' import { resolveOpenCodeDataDirectory } from './opencode-data-directory' import { bindOpenCodeSession, @@ -11,8 +10,10 @@ import { type CorrelatedSession, type SessionOwnership } from '../../shared/agent-hook-listener/opencode-session-correlation' -import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open' -import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import type { + BinderSessionRow, + OpenCodeSessionCursor +} from '../foreign-sqlite-readers/opencode-binder-sessions-result' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { listRegisteredPtys } from '../memory/pty-registry' import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep' @@ -24,9 +25,9 @@ import type { HookListenerState } from '../../shared/agent-hook-listener/listene * * Each round: read new sessions from the shared server's SQLite store, * snapshot panes, sweep for live clients, correlate, bind. Everything the - * round needs is injected so the decision core stays unit-testable; only - * the SQLite reader below touches disk, reusing ai-vault's guarded open - * (read-only + query_only + busy timeout). + * round needs is injected so the decision core stays unit-testable; the + * store read runs on the foreign SQLite reader worker + * (foreign-sqlite-readers/readers/opencode-binder-sessions.ts). */ /** One pane snapshot feeding a binder round. */ @@ -38,14 +39,6 @@ export type BinderPaneSnapshot = { shellPid: number | null } -/** One session store row feeding a binder round. */ -export type BinderSessionRow = { - id: string - directory: string - createdAtMs: number - parentId: string | null -} - /** Everything one binder round needs, injected for tests. */ export type BinderRoundDeps = { nowMs: number @@ -61,12 +54,6 @@ export type BinderRoundResult = { ownerships: SessionOwnership[] } -/** Position in the session store; composite so same-millisecond rows are never skipped. */ -export type OpenCodeSessionCursor = { - ms: number - id: string -} - /** Cursor before anything was ever read. */ export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' } @@ -213,77 +200,6 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult return { ownerships } } -/** - * Sessions newer than `cursor`, oldest first. The composite - * `(time_created, id)` position means rows sharing a millisecond with the - * cursor — including rows the LIMIT cut off last round — are re-listed - * instead of permanently skipped. Unknown shapes read as empty so an opencode - * schema move degrades to unbound sessions, never a crash. Fail-open [] on - * any read error for the same reason. - */ -export function listOpenCodeDbSessions( - dbPath: string, - cursor: OpenCodeSessionCursor -): BinderSessionRow[] { - try { - // Check absence without hiding permission failures as an unused store. - statSync(dbPath) - return readOpenCodeDatabase({ - dbPath, - read: (db) => { - // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its - // posts name their own pane, so its sessions must never bind. - const table = 'session' - if ( - !tableExists(db, table) || - !columnExists(db, table, 'directory') || - !columnExists(db, table, 'time_created') - ) { - return [] - } - const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL' - const rows: unknown[] = db - .prepare( - `SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500` - ) - .all(cursor.ms, cursor.ms, cursor.id) - const sessions: BinderSessionRow[] = [] - for (const row of rows) { - if (typeof row !== 'object' || row === null) { - continue - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else. - const record = row as Record - if ( - typeof record.id !== 'string' || - typeof record.directory !== 'string' || - typeof record.time_created !== 'number' - ) { - continue - } - sessions.push({ - id: record.id, - directory: record.directory, - createdAtMs: record.time_created, - parentId: typeof record.parent_id === 'string' ? record.parent_id : null - }) - } - return sessions - } - }) - } catch (err) { - if ( - err instanceof Error && - 'code' in err && - (err.code === 'ENOENT' || err.code === 'ENOTDIR') - ) { - return [] - } - console.warn('[opencode-binder] session store read failed; skipping round', err) - return [] - } -} - /** Default database path for the local shared server. */ export function defaultOpenCodeDbPath(): string { return `${resolveOpenCodeDataDirectory()}/opencode.db` diff --git a/src/main/orcad/orcad-forked-child-paths.test.ts b/src/main/orcad/orcad-forked-child-paths.test.ts index e66b3c44702..9f6f63fdc82 100644 --- a/src/main/orcad/orcad-forked-child-paths.test.ts +++ b/src/main/orcad/orcad-forked-child-paths.test.ts @@ -4,6 +4,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getAppEnvironment } from '../../shared/app-environment' import { getWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { + FOREIGN_SQLITE_READER_ENTRY_FILENAME, + resolveForeignSqliteReaderEntryPath +} from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path' +import { currentWorkerEntryLayout } from '../worker-thread-entry-path' import { installOrcadHostAdapters } from './orcad-entry' /** @@ -20,6 +25,7 @@ describe('orcad forked-child paths', () => { // The layout build-orcad.mjs emits: the bundle and its children side by side. writeFileSync(join(deployRoot, 'orcad.js'), '') writeFileSync(join(deployRoot, 'parcel-watcher-process-entry.js'), '') + writeFileSync(join(deployRoot, FOREIGN_SQLITE_READER_ENTRY_FILENAME), '') originalArgv = process.argv process.argv = [process.execPath, join(deployRoot, 'orcad.js')] installOrcadHostAdapters() @@ -34,6 +40,13 @@ describe('orcad forked-child paths', () => { expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) }) + it('starts the foreign SQLite reader worker shipped beside orcad.js', () => { + // orcad.js is one bundle, so its __dirname is the deploy root. + expect(resolveForeignSqliteReaderEntryPath(currentWorkerEntryLayout(deployRoot))).toBe( + join(deployRoot, FOREIGN_SQLITE_READER_ENTRY_FILENAME) + ) + }) + it('ignores a decoy child under the working directory', () => { // A cwd-derived app root finds the real child only when the operator happens to // launch from the install directory, which a supervisor never does — and here it diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index a84ce87be19..33462dcb95a 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -5,6 +5,7 @@ import { BUNDLED_RIPGREP_PLATFORMS, bundledRipgrepBinaryName } from './bundled-r import { SERVER_TARGETS } from './node-runtime-pin' import { ORCAD_BUILD_TARGET_FILENAME, + ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_RIPGREP_ARTIFACTS, @@ -16,6 +17,13 @@ import { } from './orcad-artifacts' describe('standalone runtime artifacts', () => { + it.each(SERVER_TARGETS)( + 'ships the foreign SQLite reader worker the %s runtime starts', + (target) => { + expect(orcadArtifactFilenames(target)).toContain(ORCAD_FOREIGN_SQLITE_READER_ENTRY) + } + ) + it('names a search binary for every SSH host platform', () => { const expected = BUNDLED_RIPGREP_PLATFORMS.map( (platform) => `ripgrep/${platform}/${bundledRipgrepBinaryName(platform)}` diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index c0cbf30bd22..ba9fa05545e 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -101,6 +101,9 @@ export const ORCAD_EMOJI_SHORTCODE_DATASET = export const ORCAD_VERSION = '0.1.0' +// Equals FOREIGN_SQLITE_READER_ENTRY_FILENAME; that module is not loadable under type stripping. +export const ORCAD_FOREIGN_SQLITE_READER_ENTRY = 'foreign-sqlite-reader-entry.js' + // Kept here because build-orcad.mjs imports this manifest directly under Node type stripping. export const ORCAD_RIPGREP_ARTIFACTS = [ 'ripgrep/linux-x64/rg', @@ -153,6 +156,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: 'daemon-entry.js' }, { filename: 'profile-state-writer-worker-entry.js' }, { filename: 'profile-state-backup-worker-entry.js' }, + // Worker thread that reads other apps' SQLite (the OpenCode binder and history) off the event loop. + { filename: ORCAD_FOREIGN_SQLITE_READER_ENTRY }, // Target-specific even when the JavaScript bundle is shared across packaged slots. { filename: ORCAD_SERVER_TARGET_FILENAME }, // orcad never depends on a host runtime or host-installed native module. From 40e60b7385caa22170de54bebb16517abe8aaef1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:58:33 -0400 Subject: [PATCH 005/220] refactor(ai-vault): delete the unused session-scanner worker thread (#24607) * refactor(ai-vault): delete the unused session-scanner worker thread Production always scans through the forked session-scanner service process; the worker thread was reachable only under NODE_ENV=test or the undocumented ORCA_AI_VAULT_SERVICE_PROCESS=0 switch, and nothing fell back to it on service failure. Remove the thread (spawn, client, protocol, entry, tests), its build entry, knip and plain-node-guard listings, and the backend switch, so session-scanner-background always routes to the service. - Move the scan options type to the service protocol as AiVaultServiceScanOptions. - Tests now mock session-scanner-service-spawn, the seam production calls. - Repoint the hot-path listing reliability gate from the worker-client test to the service-client test, which covers the same bounded-queue, cancellation, and fault-restart properties for the real executor. STA-9122 * test(ai-vault): cover the service's Claude-vs-OMP subagent lister choice Runs the real service entry and subagent reader, replacing only the two per-agent listers, so a swapped lister choice fails. STA-9122 --- .../build-plugins/plain-node-entry-guard.ts | 1 - config/knip.json | 1 - config/reliability-gates.jsonc | 28 +-- config/scripts/ci-shard-timings.json | 2 - electron.vite.config.ts | 3 - .../cached-session-list-wsl-probe.test.ts | 16 +- src/main/ai-vault/cached-session-list.test.ts | 22 +- .../session-scanner-background.test.ts | 36 --- .../ai-vault/session-scanner-background.ts | 55 ++-- .../session-scanner-fs-import-guard.test.ts | 1 - ...on-scanner-service-entry-subagents.test.ts | 71 ++++++ .../session-scanner-service-protocol.ts | 6 +- .../ai-vault/session-scanner-service-spawn.ts | 4 +- .../session-scanner-worker-client.test.ts | 196 --------------- .../ai-vault/session-scanner-worker-client.ts | 238 ------------------ .../ai-vault/session-scanner-worker-entry.ts | 115 --------- .../session-scanner-worker-protocol.ts | 29 --- .../ai-vault/session-scanner-worker-spawn.ts | 57 ----- src/main/ipc/ai-vault-scan-coalescing.test.ts | 26 +- src/main/ipc/ai-vault.test.ts | 75 +++--- src/main/runtime/rpc/methods/ai-vault.test.ts | 80 +++--- ...ructured-agent-session-held-signal.test.ts | 8 +- 22 files changed, 215 insertions(+), 855 deletions(-) delete mode 100644 src/main/ai-vault/session-scanner-background.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-entry-subagents.test.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-client.test.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-client.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-entry.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-protocol.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-spawn.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 12e06db7be1..716bc1e4c63 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -56,7 +56,6 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'stt-worker', 'warp-theme-parser-worker', 'foreign-sqlite-reader-entry', - 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', 'usage-scan-worker-entry', diff --git a/config/knip.json b/config/knip.json index 99516709af5..98891b9e687 100644 --- a/config/knip.json +++ b/config/knip.json @@ -10,7 +10,6 @@ "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", "src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts", - "src/main/ai-vault/session-scanner-worker-entry.ts", "src/main/ports/port-scan-command-worker-entry.ts", "src/main/ipc/parcel-watcher-process-entry.ts", "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 5b4c135c1da..a157e9dc79c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -12107,10 +12107,10 @@ "https://github.com/stablyai/orca/pull/12778" ], "invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.", - "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, serializes worker work, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", + "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, bounds scanner-service calls at sixteen, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts" ], "testFiles": [ "src/main/ipc/pty-startup-barrier-and-listing.test.ts", @@ -12118,7 +12118,7 @@ "src/renderer/src/components/status-bar/resource-session-inventory.test.ts", "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts", "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts", - "src/main/ai-vault/session-scanner-worker-client.test.ts", + "src/main/ai-vault/session-scanner-service-client.test.ts", "src/main/ai-vault/session-title-file-reader.test.ts", "src/main/ai-vault/session-parse-cache-persistence.test.ts", "src/main/ipc/ai-vault.test.ts", @@ -12168,12 +12168,12 @@ ] }, { - "file": "src/main/ai-vault/session-scanner-worker-client.test.ts", + "file": "src/main/ai-vault/session-scanner-service-client.test.ts", "assertions": [ - "full scans and exact-title reads share one serial FIFO worker", - "active cancellation stays serialized and queued work remains bounded", - "worker faults restart queued work and idle time preserves incremental parse state", - "worker disposal rejects retained work and terminates the worker" + "the service waits for ready and runs the cache and interactive lanes independently", + "active and queued calls are bounded together at sixteen", + "cancellation reaches active work and kills a service that ignores it", + "service faults restart queued work under a restart circuit that a forced refresh reopens" ] }, { @@ -12202,13 +12202,13 @@ "summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing." }, { - "date": "2026-08-09", + "date": "2026-10-02", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", "result": "passed", - "durationSeconds": 3.1, - "summary": "The focused run passed 112 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, persistent serial worker lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." + "durationSeconds": 5.3, + "summary": "The focused run passed 138 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, per-lane scanner-service lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." } ], "runtimeBudget": { @@ -12221,11 +12221,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to one serial worker or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion." + "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to the local scanner service or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion." }, "performanceBudget": { "required": true, - "evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active worker operation, 16 queued operations, four concurrent transcript parses inside the worker, a 4,096-title index, and no worktree-path-triggered refresh. The worker emits the aiVault.scan.worker span with duration and session count for full scans." + "evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active scanner-service call per lane (cache, interactive), 16 active plus queued calls, four concurrent transcript parses inside the service, a 4,096-title index, and no worktree-path-triggered refresh. The service emits the aiVault.scan.service span with duration and session count for full scans." }, "promotionCriteria": [ "Add deterministic call-count instrumentation.", diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 7a3a3710d4e..73d0226943b 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -626,7 +626,6 @@ "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 1121, "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 72, "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 330, - "src/main/ai-vault/session-scanner-background.test.ts": 354, "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 245, "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 170, "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 137, @@ -706,7 +705,6 @@ "src/main/ai-vault/session-scanner-timeline.test.ts": 75, "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3562, "src/main/ai-vault/session-scanner-values.test.ts": 92, - "src/main/ai-vault/session-scanner-worker-client.test.ts": 48, "src/main/ai-vault/session-scanner.test.ts": 343, "src/main/ai-vault/session-sidecar-stat.test.ts": 25, "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 173, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 729d1b19bc7..4f4cedb456c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -239,9 +239,6 @@ export const electronViteConfig: UserConfig = { 'foreign-sqlite-reader-entry': resolve( 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts' ), - 'session-scanner-worker-entry': resolve( - 'src/main/ai-vault/session-scanner-worker-entry.ts' - ), 'session-scanner-service-entry': resolve( 'src/main/ai-vault/session-scanner-service-entry.ts' ), diff --git a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts index 0c2ef27316a..14256c9a83a 100644 --- a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts +++ b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts @@ -2,18 +2,18 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as childProcess from 'node:child_process' -const { execFileMock, scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ +const { execFileMock, scanAiVaultSessionsInService } = vi.hoisted(() => ({ execFileMock: vi.fn(), - scanAiVaultSessionsInWorker: vi.fn() + scanAiVaultSessionsInService: vi.fn() })) vi.mock('child_process', async (importOriginal) => ({ ...(await importOriginal()), execFile: execFileMock })) -vi.mock('./session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService })) vi.mock('./opencode-wsl-runtime-preparation', () => ({ prepareOpenCodeWslReaders: vi.fn(async () => []) @@ -44,7 +44,7 @@ describe('AI Vault listing wsl.exe probes', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') resetAiVaultSessionListCacheForTests() configureAiVaultSessionSources({ getAdditionalCodexHomePaths: () => [NATIVE_CODEX_HOME] }) - scanAiVaultSessionsInWorker.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) + scanAiVaultSessionsInService.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) }) afterEach(() => { execFileMock.mockReset() @@ -59,7 +59,7 @@ describe('AI Vault listing wsl.exe probes', () => { await listAiVaultSessions() expect(wslSpawns()).toEqual([]) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(scanAiVaultSessionsInService).toHaveBeenCalledWith( expect.objectContaining({ additionalCodexSessionsDirs: [join(NATIVE_CODEX_HOME, 'sessions')], wslHomeDirs: [] @@ -80,7 +80,7 @@ describe('AI Vault listing wsl.exe probes', () => { ['--list', '--running', '--quiet'], ['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME'] ]) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(scanAiVaultSessionsInService).toHaveBeenCalledWith( expect.objectContaining({ wslHomeDirs: [WSL_HOME] }), expect.anything() ) diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index c0d3668f1f3..55801c6df83 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -6,18 +6,18 @@ const { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync, - scanAiVaultSessionsInWorker + scanAiVaultSessionsInService } = vi.hoisted(() => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), getCachedWslDistros: vi.fn((): string[] | null => null), hasCachedWslDistros: vi.fn(() => false), listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), - scanAiVaultSessionsInWorker: vi.fn() + scanAiVaultSessionsInService: vi.fn() })) -vi.mock('./session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService })) vi.mock('../wsl', () => ({ getCachedWslDistros, @@ -43,7 +43,7 @@ function scanResult(scannedAt: string): AiVaultListResult { // mid-flight. function deferredScan(): { resolve: (value: AiVaultListResult) => void } { let resolveFn: (value: AiVaultListResult) => void = () => {} - scanAiVaultSessionsInWorker.mockReturnValueOnce( + scanAiVaultSessionsInService.mockReturnValueOnce( new Promise((resolve) => { resolveFn = resolve }) @@ -60,7 +60,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { getCachedWslDistros.mockReset().mockReturnValue(null) hasCachedWslDistros.mockReset().mockReturnValue(false) listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) - scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInService.mockReset() }) afterEach(() => { resetAiVaultSessionListCacheForTests() @@ -81,22 +81,22 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { // A non-force list must re-scan (cache empty) rather than serve A's stale // result — proof A's late .then() did not repopulate the cache. - scanAiVaultSessionsInWorker.mockResolvedValueOnce(scanResult('scan-B')) + scanAiVaultSessionsInService.mockResolvedValueOnce(scanResult('scan-B')) const next = await listAiVaultSessions() expect(next.scannedAt).toBe('scan-B') - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) }) it('caches normally when no invalidation interrupts the scan', async () => { - scanAiVaultSessionsInWorker.mockResolvedValueOnce(scanResult('scan-A')) + scanAiVaultSessionsInService.mockResolvedValueOnce(scanResult('scan-A')) await listAiVaultSessions() // Second non-force call is a cache hit — no second scan. const cached = await listAiVaultSessions() expect(cached.scannedAt).toBe('scan-A') - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ai-vault/session-scanner-background.test.ts b/src/main/ai-vault/session-scanner-background.test.ts deleted file mode 100644 index 0b0ca493db4..00000000000 --- a/src/main/ai-vault/session-scanner-background.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { shouldUseAiVaultServiceProcess } from './session-scanner-background' - -const originalBackend = process.env.ORCA_AI_VAULT_SERVICE_PROCESS -const originalNodeEnv = process.env.NODE_ENV - -afterEach(() => { - if (originalBackend === undefined) { - delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS - } else { - process.env.ORCA_AI_VAULT_SERVICE_PROCESS = originalBackend - } - process.env.NODE_ENV = originalNodeEnv -}) - -describe('shouldUseAiVaultServiceProcess', () => { - it('keeps unit tests on the worker fallback by default', () => { - delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS - process.env.NODE_ENV = 'test' - expect(shouldUseAiVaultServiceProcess()).toBe(false) - }) - - it('defaults non-test hosts to the service process', () => { - delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS - process.env.NODE_ENV = 'production' - expect(shouldUseAiVaultServiceProcess()).toBe(true) - }) - - it.each([ - ['1', true], - ['0', false] - ] as const)('honors the explicit %s kill switch', (value, expected) => { - process.env.ORCA_AI_VAULT_SERVICE_PROCESS = value - expect(shouldUseAiVaultServiceProcess()).toBe(expected) - }) -}) diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts index 70eca2434ef..3a13ca04c51 100644 --- a/src/main/ai-vault/session-scanner-background.ts +++ b/src/main/ai-vault/session-scanner-background.ts @@ -3,10 +3,9 @@ import type { AiVaultSessionTitleRequest, AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' -import { - readAiVaultFirstUserPrompt, - type ReadAiVaultFirstUserPromptArgs, - type ReadAiVaultFirstUserPromptResult +import type { + ReadAiVaultFirstUserPromptArgs, + ReadAiVaultFirstUserPromptResult } from './session-first-user-prompt-read' import { clearAiVaultServiceRestartCircuit, @@ -17,58 +16,35 @@ import { resolveAiVaultSessionTitlesInService, scanAiVaultSessionsInService } from './session-scanner-service-spawn' -import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' -import { - resetAiVaultScannerWorkerForTests, - resolveAiVaultSessionTitlesInWorker, - scanAiVaultSessionsInWorker -} from './session-scanner-worker-spawn' -import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' -import { listLocalAiVaultSubagentSessions } from './session-subagent-reader' +import type { + AiVaultServiceScanOptions, + AiVaultServiceSubagentRequest +} from './session-scanner-service-protocol' import { isWslUncPath } from '../../shared/wsl-paths' -export function shouldUseAiVaultServiceProcess(): boolean { - const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS - if (configured === '1') { - return true - } - if (configured === '0') { - return false - } - return process.env.NODE_ENV !== 'test' -} - // Let forced refreshes retry after a local service circuit opens. export function clearAiVaultBackgroundRestartCircuit(): void { - if (shouldUseAiVaultServiceProcess()) { - clearAiVaultServiceRestartCircuit() - } + clearAiVaultServiceRestartCircuit() } export function scanAiVaultSessionsInBackground( - options: AiVaultWorkerScanOptions, + options: AiVaultServiceScanOptions, signal?: AbortSignal ): Promise { - return shouldUseAiVaultServiceProcess() - ? scanAiVaultSessionsInService(options, signal) - : scanAiVaultSessionsInWorker(options, signal) + return scanAiVaultSessionsInService(options, signal) } export function resolveAiVaultSessionTitlesInBackground( requests: AiVaultSessionTitleRequest[], signal?: AbortSignal ): Promise { - return shouldUseAiVaultServiceProcess() - ? resolveAiVaultSessionTitlesInService(requests, signal) - : resolveAiVaultSessionTitlesInWorker(requests, signal) + return resolveAiVaultSessionTitlesInService(requests, signal) } export function listAiVaultSubagentSessionsInBackground( request: AiVaultServiceSubagentRequest ): Promise { - return shouldUseAiVaultServiceProcess() - ? listAiVaultSubagentSessionsInService(request) - : listLocalAiVaultSubagentSessions(request) + return listAiVaultSubagentSessionsInService(request) } export async function readAiVaultFirstUserPromptInBackground( @@ -79,16 +55,13 @@ export async function readAiVaultFirstUserPromptInBackground( const roots = await localAiVaultScanRoots() request = { ...request, wslOpenCodeReaders: roots.wslOpenCodeReaders ?? [] } } - return shouldUseAiVaultServiceProcess() - ? readAiVaultFirstUserPromptInService(request) - : readAiVaultFirstUserPrompt(request) + return readAiVaultFirstUserPromptInService(request) } export function invalidateAiVaultBackgroundCache(paths: string[]): Promise { - return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve() + return invalidateAiVaultServiceCache(paths) } export function resetAiVaultScannerBackgroundForTests(): void { resetAiVaultScannerServiceForTests() - resetAiVaultScannerWorkerForTests() } diff --git a/src/main/ai-vault/session-scanner-fs-import-guard.test.ts b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts index 3d431cb93da..74c0f441f07 100644 --- a/src/main/ai-vault/session-scanner-fs-import-guard.test.ts +++ b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts @@ -22,7 +22,6 @@ const ALLOWLIST = new Set([ // `existsSync` on bundled module paths inside the app dir. 'session-scanner-service-entry-path.ts', 'session-scanner-service-spawn.ts', - 'session-scanner-worker-spawn.ts', 'session-scanner-opencode-sqlite-worker-spawn.ts', // On-demand IPC readers, gated in the STA-4049 follow-up. 'session-scanner-claude-subagents.ts', diff --git a/src/main/ai-vault/session-scanner-service-entry-subagents.test.ts b/src/main/ai-vault/session-scanner-service-entry-subagents.test.ts new file mode 100644 index 00000000000..36ffe6d7013 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry-subagents.test.ts @@ -0,0 +1,71 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol' + +const listClaudeSubagentSessions = vi.hoisted(() => vi.fn()) +const listOmpSubagentSessions = vi.hoisted(() => vi.fn()) + +// Only the per-agent listers are replaced; the entry and its reader run for real. +vi.mock('./session-scanner-claude-subagents', () => ({ listClaudeSubagentSessions })) +vi.mock('./session-scanner-omp-subagent-listing', () => ({ listOmpSubagentSessions })) +vi.mock('./session-parse-cache-persistence', () => ({ + flushSessionParseCachePersist: vi.fn(() => Promise.resolve()), + initSessionParseCachePersistence: vi.fn() +})) + +type SentMessage = { type: string; id?: number; operation?: string; value?: unknown } +const sent: SentMessage[] = [] + +function emit(message: unknown): void { + process.emit('message', message, undefined) +} + +async function requestSubagents( + id: number, + agent: 'claude' | 'omp', + parentFilePath: string +): Promise { + emit({ type: 'request', id, operation: 'subagents', request: { agent, parentFilePath } }) + await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true)) + return sent.find((message) => message.id === id) +} + +describe('AI Vault service entry subagent listing', () => { + beforeAll(async () => { + process.send = (message: SentMessage) => { + sent.push(message) + return true + } + await import('./session-scanner-service-entry') + emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION }) + }) + + beforeEach(() => { + sent.length = 0 + listClaudeSubagentSessions.mockReset() + listOmpSubagentSessions.mockReset() + }) + + it('lists Claude subagents with the Claude lister only', async () => { + const claude = { sessions: [], issues: [{ message: 'claude-lister' }] } + listClaudeSubagentSessions.mockResolvedValue(claude) + + const reply = await requestSubagents(1, 'claude', '/claude/proj/sess.jsonl') + + expect(reply).toMatchObject({ type: 'result', operation: 'subagents', value: claude }) + expect(listClaudeSubagentSessions).toHaveBeenCalledWith({ + parentFilePath: '/claude/proj/sess.jsonl' + }) + expect(listOmpSubagentSessions).not.toHaveBeenCalled() + }) + + it('lists OMP subagents with the OMP lister only', async () => { + const omp = { sessions: [], issues: [{ message: 'omp-lister' }] } + listOmpSubagentSessions.mockResolvedValue(omp) + + const reply = await requestSubagents(2, 'omp', '/omp/slug/sess.jsonl') + + expect(reply).toMatchObject({ type: 'result', operation: 'subagents', value: omp }) + expect(listOmpSubagentSessions).toHaveBeenCalledWith({ parentFilePath: '/omp/slug/sess.jsonl' }) + expect(listClaudeSubagentSessions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts index faff7a29896..c2164075e37 100644 --- a/src/main/ai-vault/session-scanner-service-protocol.ts +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -14,10 +14,12 @@ import type { SessionSearchHostScope } from '../ai-vault-search/session-search-s import type { SessionSearchScanRoots } from '../ai-vault-search/session-search-scan-roots' import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read' import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence' -import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' +import type { AiVaultScanOptions } from './session-scanner-types' export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1 +export type AiVaultServiceScanOptions = Omit + export type AiVaultServiceLane = 'cache' | 'interactive' export type AiVaultServiceOperation = | 'scan' @@ -67,7 +69,7 @@ export type AiVaultServiceInit = { } export type AiVaultServiceRequestBody = - | { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions } + | { type: 'request'; operation: 'scan'; options: AiVaultServiceScanOptions } | { type: 'request' operation: 'titles' diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index a923e904023..eeb575b7b52 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -24,10 +24,10 @@ import { AiVaultScannerServiceClient } from './session-scanner-service-client' import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path' import { lowerAiVaultServicePriority } from './session-scanner-service-priority' import type { + AiVaultServiceScanOptions, AiVaultServiceSubagentRequest, AiVaultSessionSearchInit } from './session-scanner-service-protocol' -import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' export function spawnAiVaultServiceProcess(): ChildProcess { const entryPath = getAiVaultServiceEntryPath() @@ -61,7 +61,7 @@ function getSharedClient(): AiVaultScannerServiceClient { } export function scanAiVaultSessionsInService( - options: AiVaultWorkerScanOptions, + options: AiVaultServiceScanOptions, signal?: AbortSignal ): Promise { return withSpan('aiVault.scan.service', async (span) => { diff --git a/src/main/ai-vault/session-scanner-worker-client.test.ts b/src/main/ai-vault/session-scanner-worker-client.test.ts deleted file mode 100644 index 862f9c75e06..00000000000 --- a/src/main/ai-vault/session-scanner-worker-client.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { Worker } from 'node:worker_threads' -import { describe, expect, it, vi } from 'vitest' -import type { - AiVaultWorkerControl, - AiVaultWorkerRequest, - AiVaultWorkerResponse -} from './session-scanner-worker-protocol' -import { AiVaultScannerWorkerClient } from './session-scanner-worker-client' - -class FakeWorker { - readonly posted: (AiVaultWorkerRequest | AiVaultWorkerControl)[] = [] - terminated = false - unrefed = false - private readonly listeners = new Map void>>() - - on(event: string, listener: (value: unknown) => void): this { - const listeners = this.listeners.get(event) ?? new Set() - listeners.add(listener) - this.listeners.set(event, listeners) - return this - } - - removeAllListeners(): void { - this.listeners.clear() - } - - postMessage(message: AiVaultWorkerRequest | AiVaultWorkerControl): void { - this.posted.push(message) - } - - unref(): void { - this.unrefed = true - } - - async terminate(): Promise { - this.terminated = true - return 1 - } - - emit(event: string, value: unknown): void { - for (const listener of this.listeners.get(event) ?? []) { - listener(value) - } - } -} - -function setup(): { client: AiVaultScannerWorkerClient; worker: FakeWorker } { - const worker = new FakeWorker() - return { - client: new AiVaultScannerWorkerClient({ - workerFactory: () => worker as unknown as Worker - }), - worker - } -} - -function setupWorkerFactory(): { - client: AiVaultScannerWorkerClient - workers: FakeWorker[] -} { - const workers: FakeWorker[] = [] - return { - client: new AiVaultScannerWorkerClient({ - workerFactory: () => { - const worker = new FakeWorker() - workers.push(worker) - return worker as unknown as Worker - } - }), - workers - } -} - -function titleResponse( - id: number, - title: string -): Extract { - return { - id, - ok: true, - kind: 'titles', - value: { titles: [{ agent: 'codex', sessionId: 'session', title }] } - } -} - -describe('AiVaultScannerWorkerClient', () => { - it('serializes requests in FIFO order and ignores stale responses', async () => { - const { client, worker } = setup() - const first = client.resolveTitles([{ agent: 'codex', sessionId: 'session' }]) - const second = client.resolveTitles([{ agent: 'claude', sessionId: 'other' }]) - - expect(worker.posted).toHaveLength(1) - const firstId = worker.posted[0]!.id - worker.emit('message', titleResponse(999, 'stale')) - expect(worker.posted).toHaveLength(1) - - worker.emit('message', titleResponse(firstId, 'first')) - await expect(first).resolves.toEqual(titleResponse(firstId, 'first').value) - expect(worker.posted).toHaveLength(2) - - const secondId = worker.posted[1]!.id - worker.emit('message', titleResponse(secondId, 'second')) - await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value) - expect(worker.unrefed).toBe(true) - client.dispose() - }) - - it('cancels active work without dispatching the next call concurrently', async () => { - const { client, worker } = setup() - const controller = new AbortController() - const first = client.resolveTitles( - [{ agent: 'codex', sessionId: 'session' }], - controller.signal - ) - const second = client.resolveTitles([{ agent: 'claude', sessionId: 'other' }]) - const firstId = worker.posted[0]!.id - - controller.abort() - await expect(first).rejects.toMatchObject({ name: 'AbortError' }) - expect(worker.posted).toEqual([ - expect.objectContaining({ id: firstId, kind: 'titles' }), - { id: firstId, kind: 'cancel' } - ]) - - worker.emit('message', titleResponse(firstId, 'ignored')) - expect(worker.posted).toHaveLength(3) - const secondId = worker.posted[2]!.id - worker.emit('message', titleResponse(secondId, 'second')) - await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value) - client.dispose() - }) - - it.each([ - ['error', new Error('worker crashed')], - ['exit', 1] - ] as const)('restarts queued work after a worker %s', async (event, value) => { - const { client, workers } = setupWorkerFactory() - const first = client.resolveTitles([{ agent: 'codex', sessionId: 'first' }]) - const second = client.resolveTitles([{ agent: 'claude', sessionId: 'second' }]) - - workers[0]!.emit(event, value) - - await expect(first).rejects.toThrow() - expect(workers).toHaveLength(2) - expect(workers[0]!.terminated).toBe(true) - const secondId = workers[1]!.posted[0]!.id - workers[1]!.emit('message', titleResponse(secondId, 'second')) - await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value) - client.dispose() - }) - - it('keeps the unrefed worker resident so incremental parse state survives idle time', async () => { - vi.useFakeTimers() - try { - const { client, worker } = setup() - const result = client.resolveTitles([{ agent: 'codex', sessionId: 'session' }]) - const requestId = worker.posted[0]!.id - worker.emit('message', titleResponse(requestId, 'title')) - await result - - await vi.advanceTimersByTimeAsync(10 * 60_000) - - expect(worker.terminated).toBe(false) - client.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('bounds queued calls while one request is active', async () => { - const { client } = setup() - const active = client.resolveTitles([{ agent: 'codex', sessionId: 'active' }]) - const queued = Array.from({ length: 16 }, (_, index) => - client.resolveTitles([{ agent: 'codex', sessionId: `queued-${index}` }]) - ) - - await expect(client.resolveTitles([{ agent: 'codex', sessionId: 'overflow' }])).rejects.toThrow( - 'queue is full' - ) - client.dispose() - await expect(active).rejects.toThrow('disposed') - await Promise.all(queued.map((promise) => expect(promise).rejects.toThrow('disposed'))) - }) - - it('terminates and rejects active and queued calls on disposal', async () => { - const { client, worker } = setup() - const active = client.resolveTitles([{ agent: 'codex', sessionId: 'active' }]) - const queued = client.resolveTitles([{ agent: 'claude', sessionId: 'queued' }]) - - client.dispose() - - expect(worker.terminated).toBe(true) - await expect(active).rejects.toThrow('disposed') - await expect(queued).rejects.toThrow('disposed') - }) -}) diff --git a/src/main/ai-vault/session-scanner-worker-client.ts b/src/main/ai-vault/session-scanner-worker-client.ts deleted file mode 100644 index 70e75c4f982..00000000000 --- a/src/main/ai-vault/session-scanner-worker-client.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { Worker } from 'node:worker_threads' -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' -import type { - AiVaultWorkerRequest, - AiVaultWorkerResponse, - AiVaultWorkerScanOptions -} from './session-scanner-worker-protocol' - -const SCAN_TIMEOUT_MS = 130_000 -const TITLE_TIMEOUT_MS = 15_000 -const MAX_QUEUED_CALLS = 16 - -export type AiVaultWorkerFactory = () => Worker - -type RequestBody = - | Omit, 'id'> - | Omit, 'id'> - -type PendingCall = { - request: AiVaultWorkerRequest - timeoutMs: number - signal?: AbortSignal - resolve: (value: unknown) => void - reject: (error: Error) => void - timer: NodeJS.Timeout | null - onAbort: (() => void) | null - cancelled: boolean -} - -export class AiVaultScannerWorkerClient { - private worker: Worker | null = null - private active: PendingCall | null = null - private queue: PendingCall[] = [] - private nextId = 1 - private readonly workerFactory: AiVaultWorkerFactory - - constructor(options: { workerFactory: AiVaultWorkerFactory }) { - this.workerFactory = options.workerFactory - } - - scan( - options: AiVaultWorkerScanOptions, - signal?: AbortSignal - ): Promise<{ result: AiVaultListResult; durationMs: number }> { - return this.dispatch({ kind: 'scan', options }, SCAN_TIMEOUT_MS, signal) as Promise<{ - result: AiVaultListResult - durationMs: number - }> - } - - resolveTitles( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal - ): Promise { - return this.dispatch( - { kind: 'titles', requests }, - TITLE_TIMEOUT_MS, - signal - ) as Promise - } - - dispose(): void { - this.destroyWorker() - const pending = this.queue - this.queue = [] - for (const call of pending) { - this.rejectCall(call, new Error('AI Vault scanner worker was disposed.')) - } - if (this.active) { - this.rejectCall(this.active, new Error('AI Vault scanner worker was disposed.')) - this.active = null - } - } - - private dispatch(body: RequestBody, timeoutMs: number, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return Promise.reject(createAiVaultScanCancelledError()) - } - if (this.queue.length >= MAX_QUEUED_CALLS) { - return Promise.reject(new Error('AI Vault scanner worker queue is full.')) - } - return new Promise((resolve, reject) => { - const call: PendingCall = { - request: { ...body, id: this.nextId++ } as AiVaultWorkerRequest, - timeoutMs, - signal, - resolve, - reject, - timer: null, - onAbort: null, - cancelled: false - } - if (signal) { - call.onAbort = () => this.cancel(call) - signal.addEventListener('abort', call.onAbort, { once: true }) - } - this.queue.push(call) - this.pump() - }) - } - - private pump(): void { - if (this.active || this.queue.length === 0) { - return - } - const worker = this.ensureWorker() - if (!worker) { - this.failQueue(new Error('AI Vault background scanner could not start.')) - return - } - const call = this.queue.shift() - if (!call) { - return - } - this.active = call - call.timer = setTimeout(() => { - this.onWorkerFault(new Error(`AI Vault scanner worker timed out after ${call.timeoutMs}ms.`)) - }, call.timeoutMs) - call.timer.unref?.() - worker.postMessage(call.request) - } - - private ensureWorker(): Worker | null { - if (this.worker) { - return this.worker - } - try { - const worker = this.workerFactory() - worker.on('message', (response: AiVaultWorkerResponse) => this.onMessage(response)) - worker.on('error', (error: Error) => this.onWorkerFault(error)) - worker.on('exit', (code: number) => { - if (code !== 0 || this.active || this.queue.length > 0) { - this.onWorkerFault(new Error(`AI Vault scanner worker exited with code ${code}.`)) - } else { - this.destroyWorker() - } - }) - worker.unref?.() - this.worker = worker - return worker - } catch { - return null - } - } - - private onMessage(response: AiVaultWorkerResponse): void { - const call = this.active - if (!call || call.request.id !== response.id) { - return - } - this.active = null - this.clearCall(call) - if (!call.cancelled) { - if (response.ok) { - call.resolve(response.value) - } else { - call.reject(new Error(response.error)) - } - } - this.afterSettle() - } - - private cancel(call: PendingCall): void { - if (call.cancelled) { - return - } - call.cancelled = true - call.reject(createAiVaultScanCancelledError()) - if (this.active === call) { - this.worker?.postMessage({ id: call.request.id, kind: 'cancel' }) - return - } - const index = this.queue.indexOf(call) - if (index !== -1) { - this.queue.splice(index, 1) - this.clearCall(call) - } - } - - private onWorkerFault(error: Error): void { - const active = this.active - this.active = null - this.destroyWorker() - if (active) { - this.rejectCall(active, error) - } - if (this.queue.length > 0) { - this.pump() - } - } - - private rejectCall(call: PendingCall, error: Error): void { - this.clearCall(call) - if (!call.cancelled) { - call.reject(error) - } - } - - private failQueue(error: Error): void { - const pending = this.queue - this.queue = [] - for (const call of pending) { - this.rejectCall(call, error) - } - } - - private clearCall(call: PendingCall): void { - if (call.timer) { - clearTimeout(call.timer) - call.timer = null - } - if (call.signal && call.onAbort) { - call.signal.removeEventListener('abort', call.onAbort) - call.onAbort = null - } - } - - private afterSettle(): void { - if (this.queue.length > 0) { - this.pump() - } - } - - private destroyWorker(): void { - const worker = this.worker - this.worker = null - if (!worker) { - return - } - worker.removeAllListeners() - void worker.terminate().catch(() => undefined) - } -} diff --git a/src/main/ai-vault/session-scanner-worker-entry.ts b/src/main/ai-vault/session-scanner-worker-entry.ts deleted file mode 100644 index 33c06d30401..00000000000 --- a/src/main/ai-vault/session-scanner-worker-entry.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { parentPort, workerData } from 'node:worker_threads' -import type { - AiVaultSessionTitle, - AiVaultSessionTitleRequest -} from '../../shared/ai-vault-session-title' -import { scanAiVaultSessions } from './session-scanner' -import { initSessionParseCachePersistence } from './session-parse-cache-persistence' -import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' -import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths' -import type { - AiVaultWorkerControl, - AiVaultWorkerData, - AiVaultWorkerRequest, - AiVaultWorkerResponse -} from './session-scanner-worker-protocol' - -const TITLE_INDEX_MAX_ENTRIES = 4_096 - -if (!parentPort) { - throw new Error('AI Vault scanner worker must run with a parent port.') -} -const port = parentPort -const data = workerData as AiVaultWorkerData | undefined -if (data?.sessionParseCache) { - initSessionParseCachePersistence(data.sessionParseCache) -} -const controllers = new Map() -const titleIndex = new Map() - -function titleKey(request: Pick): string { - return `${request.agent}\0${request.sessionId}` -} - -function storeTitle(title: AiVaultSessionTitle): void { - const key = titleKey(title) - titleIndex.delete(key) - titleIndex.set(key, title) - while (titleIndex.size > TITLE_INDEX_MAX_ENTRIES) { - const oldest = titleIndex.keys().next().value - if (oldest === undefined) { - break - } - titleIndex.delete(oldest) - } -} - -async function handleRequest(request: AiVaultWorkerRequest): Promise { - const controller = new AbortController() - controllers.set(request.id, controller) - try { - if (request.kind === 'titles') { - const requests = await resolveHostReadableAiVaultTitleRequests( - request.requests, - controller.signal - ) - return { - id: request.id, - ok: true, - kind: 'titles', - value: await readAiVaultSessionTitlesFromFiles(requests, { - signal: controller.signal, - cache: { - get: (titleRequest) => titleIndex.get(titleKey(titleRequest)) ?? null, - set: storeTitle - } - }) - } - } - const startedAt = performance.now() - const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal }) - for (const session of result.sessions) { - if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) { - storeTitle({ - agent: session.agent, - sessionId: session.sessionId, - title: session.title.trim() - }) - } - } - return { - id: request.id, - ok: true, - kind: 'scan', - value: { result, durationMs: performance.now() - startedAt } - } - } catch (error) { - return { - id: request.id, - ok: false, - error: error instanceof Error ? error.message : String(error) - } - } finally { - controllers.delete(request.id) - } -} - -let pending = Promise.resolve() -port.on('message', (message: AiVaultWorkerRequest | AiVaultWorkerControl) => { - if (message.kind === 'cancel') { - controllers.get(message.id)?.abort() - return - } - pending = pending.then(async () => { - const response = await handleRequest(message) - try { - port.postMessage(response) - } catch { - port.postMessage({ - id: message.id, - ok: false, - error: 'AI Vault worker result could not be serialized.' - } satisfies AiVaultWorkerResponse) - } - }) -}) diff --git a/src/main/ai-vault/session-scanner-worker-protocol.ts b/src/main/ai-vault/session-scanner-worker-protocol.ts deleted file mode 100644 index a49b8ab7120..00000000000 --- a/src/main/ai-vault/session-scanner-worker-protocol.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import type { AiVaultScanOptions } from './session-scanner-types' -import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence' - -export type AiVaultWorkerScanOptions = Omit - -export type AiVaultWorkerData = { - sessionParseCache: SessionParseCachePersistenceOptions | null -} - -export type AiVaultWorkerRequest = - | { id: number; kind: 'scan'; options: AiVaultWorkerScanOptions } - | { id: number; kind: 'titles'; requests: AiVaultSessionTitleRequest[] } - -export type AiVaultWorkerControl = { id: number; kind: 'cancel' } - -export type AiVaultWorkerResponse = - | { - id: number - ok: true - kind: 'scan' - value: { result: AiVaultListResult; durationMs: number } - } - | { id: number; ok: true; kind: 'titles'; value: AiVaultSessionTitlesResult } - | { id: number; ok: false; error: string } diff --git a/src/main/ai-vault/session-scanner-worker-spawn.ts b/src/main/ai-vault/session-scanner-worker-spawn.ts deleted file mode 100644 index 3c5abbd3551..00000000000 --- a/src/main/ai-vault/session-scanner-worker-spawn.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { Worker } from 'node:worker_threads' -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import { withSpan } from '../observability/tracer' -import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence' -import { AiVaultScannerWorkerClient } from './session-scanner-worker-client' -import type { AiVaultWorkerData, AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' - -const WORKER_ENTRY_FILENAME = 'session-scanner-worker-entry.js' - -function defaultWorkerFactory(): Worker { - const workerPath = join(__dirname, WORKER_ENTRY_FILENAME) - if (!existsSync(workerPath)) { - throw new Error(`AI Vault scanner worker entry not found: ${workerPath}`) - } - return new Worker(workerPath, { - workerData: { - sessionParseCache: getSessionParseCachePersistenceOptions() - } satisfies AiVaultWorkerData - }) -} - -let sharedClient: AiVaultScannerWorkerClient | null = null - -function getSharedClient(): AiVaultScannerWorkerClient { - sharedClient ??= new AiVaultScannerWorkerClient({ workerFactory: defaultWorkerFactory }) - return sharedClient -} - -export async function scanAiVaultSessionsInWorker( - options: AiVaultWorkerScanOptions, - signal?: AbortSignal -): Promise { - return withSpan('aiVault.scan.worker', async (span) => { - const { result, durationMs } = await getSharedClient().scan(options, signal) - span.setAttribute('workerDurationMs', durationMs) - span.setAttribute('sessions', result.sessions.length) - return result - }) -} - -export function resolveAiVaultSessionTitlesInWorker( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal -): Promise { - return getSharedClient().resolveTitles(requests, signal) -} - -export function resetAiVaultScannerWorkerForTests(): void { - sharedClient?.dispose() - sharedClient = null -} diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 0efbea4d1ee..157161f7852 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -5,8 +5,8 @@ import type { IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' const mocks = vi.hoisted(() => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn(), + scanAiVaultSessionsInService: vi.fn(), + resolveAiVaultSessionTitlesInService: vi.fn(), scanRemoteAiVaultSessions: vi.fn(), scanRuntimeAiVaultSessions: vi.fn(), getSshFilesystemProvider: vi.fn(), @@ -18,10 +18,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: mocks.ipcHandle } })) -vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker, - resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: mocks.scanAiVaultSessionsInService, + resolveAiVaultSessionTitlesInService: mocks.resolveAiVaultSessionTitlesInService })) vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions @@ -54,8 +54,8 @@ const EMPTY_RESULT: AiVaultListResult = { beforeEach(() => { vi.clearAllMocks() _internals.resetAiVaultCacheForTests() - mocks.scanAiVaultSessionsInWorker.mockResolvedValue(EMPTY_RESULT) - mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + mocks.scanAiVaultSessionsInService.mockResolvedValue(EMPTY_RESULT) + mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) mocks.scanRemoteAiVaultSessions.mockResolvedValue(EMPTY_RESULT) mocks.scanRuntimeAiVaultSessions.mockResolvedValue(EMPTY_RESULT) mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider) @@ -67,7 +67,7 @@ beforeEach(() => { describe('Agent Session History scan coalescing', () => { it.each([ - ['local', mocks.scanAiVaultSessionsInWorker], + ['local', mocks.scanAiVaultSessionsInService], ['runtime:remote-server', mocks.scanRuntimeAiVaultSessions] ] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => { let resolveScan: ((result: AiVaultListResult) => void) | undefined @@ -113,7 +113,7 @@ describe('Agent Session History scan coalescing', () => { const second = _internals.listAiVaultSessions({ executionHostScope: 'all' }) await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1) controller.abort() @@ -181,7 +181,7 @@ describe('Agent Session History scan coalescing', () => { }) it('reports a failed local scan as a host issue rather than rejecting', async () => { - mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('transcript root is unreadable')) + mocks.scanAiVaultSessionsInService.mockRejectedValue(new Error('transcript root is unreadable')) registerAiVaultHandlers() const list = ipcHandler('aiVault:listSessions') @@ -206,7 +206,7 @@ describe('Agent Session History scan coalescing', () => { signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) }) } - mocks.scanAiVaultSessionsInWorker.mockImplementation((_args, signal: AbortSignal) => + mocks.scanAiVaultSessionsInService.mockImplementation((_args, signal: AbortSignal) => waitForAbort(signal) ) mocks.requestActiveSshAiVaultSessionList.mockImplementation( @@ -256,7 +256,7 @@ describe('Agent Session History scan coalescing', () => { finishOwnership?.() await expect(pending).resolves.toMatchObject({ cancelled: true }) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.scanAiVaultSessionsInService).not.toHaveBeenCalled() expect(event.sender.eventNames()).toEqual([]) }) diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index b9f5661e19c..826b7986d62 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -10,11 +10,10 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn(), + scanLocalSessions: vi.fn(), + resolveAiVaultSessionTitlesInService: vi.fn(), scanRemoteAiVaultSessions: vi.fn(), - listClaudeSubagentSessions: vi.fn(), - listOmpSubagentSessions: vi.fn(), + listSubagentsInService: vi.fn(), scanRuntimeAiVaultSessions: vi.fn(), getAiVaultWslHomeDirs: vi.fn(), getSshFilesystemProvider: vi.fn(), @@ -33,24 +32,17 @@ vi.mock('electron', () => ({ ipcMain: { handle: mocks.ipcHandle } })) -vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker, - resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: mocks.scanLocalSessions, + resolveAiVaultSessionTitlesInService: mocks.resolveAiVaultSessionTitlesInService, + listAiVaultSubagentSessionsInService: mocks.listSubagentsInService })) vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions })) -vi.mock('../ai-vault/session-scanner-claude-subagents', () => ({ - listClaudeSubagentSessions: mocks.listClaudeSubagentSessions -})) - -vi.mock('../ai-vault/session-scanner-omp-subagent-listing', () => ({ - listOmpSubagentSessions: mocks.listOmpSubagentSessions -})) - vi.mock('../ai-vault/session-delete', () => ({ deleteAiVaultSessionFile: mocks.deleteAiVaultSessionFile })) @@ -102,13 +94,12 @@ const provider = {} as IFilesystemProvider beforeEach(() => { vi.clearAllMocks() _internals.resetAiVaultCacheForTests() - mocks.scanAiVaultSessionsInWorker.mockResolvedValue(result([session('local', 'local-session')])) - mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + mocks.scanLocalSessions.mockResolvedValue(result([session('local', 'local-session')])) + mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) mocks.scanRemoteAiVaultSessions.mockResolvedValue( result([session('ssh:dev-box', 'remote-session')]) ) - mocks.listClaudeSubagentSessions.mockResolvedValue({ sessions: [], issues: [] }) - mocks.listOmpSubagentSessions.mockResolvedValue({ sessions: [], issues: [] }) + mocks.listSubagentsInService.mockResolvedValue({ sessions: [], issues: [] }) mocks.scanRuntimeAiVaultSessions.mockResolvedValue( result([session('runtime:remote-server', 'runtime-session')]) ) @@ -123,7 +114,7 @@ describe('listAiVaultSessions host routing', () => { it('routes local scope to the local scanner', async () => { await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(mocks.scanLocalSessions).toHaveBeenCalledWith( expect.objectContaining({ scopePaths: ['/repo'], executionHostId: 'local' @@ -139,7 +130,7 @@ describe('listAiVaultSessions host routing', () => { scopePaths: ['/home/ada/repo'] }) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.scanLocalSessions).not.toHaveBeenCalled() expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( expect.objectContaining({ @@ -298,7 +289,7 @@ describe('listAiVaultSessions host routing', () => { it('merges local plus connected SSH targets for all hosts', async () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( 'dev-box', @@ -345,7 +336,7 @@ describe('listAiVaultSessions host routing', () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) @@ -361,7 +352,7 @@ describe('listAiVaultSessions host routing', () => { it('keeps SSH results when the local scan itself throws', async () => { // Why: `all` awaits every leg together, so an unguarded local throw (parse // cache load, WSL home resolution) would discard every host's sessions. - mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('session parse cache is corrupt')) + mocks.scanLocalSessions.mockRejectedValue(new Error('session parse cache is corrupt')) registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [], scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions @@ -391,7 +382,7 @@ describe('listAiVaultSessions host routing', () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local']) expect(result.issues).toEqual([ expect.objectContaining({ @@ -445,7 +436,7 @@ describe('listAiVaultSessions host routing', () => { await _internals.listAiVaultSessions({ executionHostScope: 'local' }) await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) }) @@ -512,15 +503,15 @@ describe('resolveAiVaultSessionTitles host routing', () => { titles: [{ agent: 'codex' as const, sessionId: 'session-1', title: 'Exact title' }] } - it('routes local identities to the worker without a broad scan', async () => { - mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue(titles) + it('routes local identities to the scanner service without a broad scan', async () => { + mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue(titles) await expect( _internals.resolveAiVaultSessionTitles({ executionHostScope: 'local', requests }) ).resolves.toEqual(titles) - expect(mocks.resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, undefined) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, undefined) + expect(mocks.scanLocalSessions).not.toHaveBeenCalled() }) it('routes SSH identities to the transcript-owning relay', async () => { @@ -569,7 +560,7 @@ describe('resolveAiVaultSessionTitles host routing', () => { }) ).resolves.toEqual({ titles: [] }) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.scanLocalSessions).not.toHaveBeenCalled() expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() }) }) @@ -667,7 +658,7 @@ describe('listAiVaultSubagentSessions gating', () => { executionHostId: 'local' }) - expect(mocks.listClaudeSubagentSessions).toHaveBeenCalledWith({ parentFilePath }) + expect(mocks.listSubagentsInService).toHaveBeenCalledWith({ agent: 'claude', parentFilePath }) }) it('returns empty for a remote Claude session without reading the filesystem', async () => { @@ -678,7 +669,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('rejects a path outside the Claude projects root', async () => { @@ -689,7 +680,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('rejects a dot-segment traversal out of the Claude projects root', async () => { @@ -703,7 +694,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('resolves empty for malformed IPC payloads instead of throwing', async () => { @@ -716,7 +707,7 @@ describe('listAiVaultSubagentSessions gating', () => { expect(missing).toEqual({ sessions: [], issues: [] }) expect(badPath).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('returns empty for an agent with no sibling subagent layout', async () => { @@ -727,8 +718,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() - expect(mocks.listOmpSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('lists subagents for a local OMP session inside the sessions root', async () => { @@ -744,8 +734,7 @@ describe('listAiVaultSubagentSessions gating', () => { executionHostId: 'local' }) - expect(mocks.listOmpSubagentSessions).toHaveBeenCalledWith({ parentFilePath }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).toHaveBeenCalledWith({ agent: 'omp', parentFilePath }) }) it('returns empty for a remote OMP session without reading the filesystem', async () => { @@ -756,7 +745,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listOmpSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('rejects an OMP path that only sits inside another agent root', async () => { @@ -776,7 +765,7 @@ describe('listAiVaultSubagentSessions gating', () => { expect(crossAgent).toEqual({ sessions: [], issues: [] }) expect(traversal).toEqual({ sessions: [], issues: [] }) - expect(mocks.listOmpSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index a240c0c3919..6853b5f572f 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -3,21 +3,24 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import { OrcaRuntimeService } from '../../orca-runtime' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' -import type { AiVaultScanOptions } from '../../../ai-vault/session-scanner-types' +import type { AiVaultServiceScanOptions } from '../../../ai-vault/session-scanner-service-protocol' import { AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' -const { scanAiVaultSessionsInWorker, resolveAiVaultSessionTitlesInWorker } = vi.hoisted(() => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn() +const { scanAiVaultSessionsInService, resolveAiVaultSessionTitlesInService } = vi.hoisted(() => ({ + scanAiVaultSessionsInService: + vi.fn< + (options: AiVaultServiceScanOptions, signal?: AbortSignal) => Promise + >(), + resolveAiVaultSessionTitlesInService: vi.fn() })) -vi.mock('../../../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker, - resolveAiVaultSessionTitlesInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../../../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService, + resolveAiVaultSessionTitlesInService })) import { @@ -71,13 +74,14 @@ function makeSession(): AiVaultSession { function makeDispatcher(): RpcDispatcher { // Why: the handler only needs getRuntimeId (envelope) + listAiVaultSessions, // which delegates to the shared cache module the IPC handler also uses. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these three runtime members. const runtime = { getRuntimeId: () => 'test-runtime', ensureStructuredAgentSessionHost: vi.fn(async () => undefined), listAiVaultSessions: (args?: Parameters[0]) => listAiVaultSessions(args), resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) => - resolveAiVaultSessionTitlesInWorker(requests, signal) + resolveAiVaultSessionTitlesInService(requests, signal) } as unknown as OrcaRuntimeService return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) } @@ -93,11 +97,11 @@ function makeFailingDispatcher(error: Error): RpcDispatcher { describe('aiVault.resolveSessionTitles handler', () => { beforeEach(() => { - resolveAiVaultSessionTitlesInWorker.mockReset() + resolveAiVaultSessionTitlesInService.mockReset() }) it('advertises and routes the bounded exact-title capability', async () => { - resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ + resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title' }] }) const dispatcher = makeDispatcher() @@ -111,12 +115,12 @@ describe('aiVault.resolveSessionTitles handler', () => { ok: true, result: { titles: [{ sessionId: 'session-1', title: 'Exact title' }] } }) - expect(resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, undefined) + expect(resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, undefined) expect(RUNTIME_CAPABILITIES).toContain(AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY) }) it('forwards transport cancellation to the background scanner', async () => { - resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) const dispatcher = makeDispatcher() const controller = new AbortController() const requests = [{ agent: 'codex', sessionId: 'session-1' }] @@ -125,7 +129,7 @@ describe('aiVault.resolveSessionTitles handler', () => { signal: controller.signal }) - expect(resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, controller.signal) + expect(resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, controller.signal) }) it('rejects more than 64 title identities before reaching the host', async () => { @@ -138,7 +142,7 @@ describe('aiVault.resolveSessionTitles handler', () => { await expect( dispatcher.dispatch(makeRequest('aiVault.resolveSessionTitles', { requests })) ).resolves.toMatchObject({ ok: false }) - expect(resolveAiVaultSessionTitlesInWorker).not.toHaveBeenCalled() + expect(resolveAiVaultSessionTitlesInService).not.toHaveBeenCalled() }) }) @@ -234,8 +238,8 @@ describe('aiVault methods without a structured host', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() - scanAiVaultSessionsInWorker.mockReset() - scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) + scanAiVaultSessionsInService.mockReset() + scanAiVaultSessionsInService.mockResolvedValue(makeResult()) recordStructuredAgentSessionHostInstallRefusal(refusal) }) @@ -285,10 +289,10 @@ describe('aiVault methods without a structured host', () => { describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() - scanAiVaultSessionsInWorker.mockReset() - scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) - resolveAiVaultSessionTitlesInWorker.mockReset() - resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + scanAiVaultSessionsInService.mockReset() + scanAiVaultSessionsInService.mockResolvedValue(makeResult()) + resolveAiVaultSessionTitlesInService.mockReset() + resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) }) afterEach(() => { @@ -336,7 +340,7 @@ describe('aiVault.listSessions handler + shared cache', () => { const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { scopePaths })) expect(response).toMatchObject({ ok: true }) - expect(scanAiVaultSessionsInWorker.mock.calls[0]?.[0]).toMatchObject({ + expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({ scopePaths: scopePaths.slice(0, 64) }) }) @@ -347,7 +351,7 @@ describe('aiVault.listSessions handler + shared cache', () => { await listAiVaultSessions({ limit: 500 }) // Second call via the RPC method with the same cache key. await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) }) it('keeps completed scans cached for one minute', async () => { @@ -356,11 +360,11 @@ describe('aiVault.listSessions handler + shared cache', () => { await listAiVaultSessions({ limit: 500 }) await vi.advanceTimersByTimeAsync(59_999) await listAiVaultSessions({ limit: 500 }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) await listAiVaultSessions({ limit: 500 }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } @@ -371,14 +375,14 @@ describe('aiVault.listSessions handler + shared cache', () => { await listAiVaultSessions({ limit: 250 }) await listAiVaultSessions({ limit: 500 }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) }) it('shares a cache entry across equivalent scope path ordering', async () => { await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/a', '/repo/b'] }) await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/b', '/repo/a'] }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) }) it('forwards Unlimited without a numeric limit', async () => { @@ -388,7 +392,7 @@ describe('aiVault.listSessions handler + shared cache', () => { ) expect(response).toMatchObject({ ok: true }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(scanAiVaultSessionsInService).toHaveBeenCalledWith( expect.objectContaining({ limit: undefined, unlimited: true }), expect.any(AbortSignal) ) @@ -398,7 +402,7 @@ describe('aiVault.listSessions handler + shared cache', () => { // Why: the resolving scan's cleanup must not clear tracking a concurrent // different-key scan replaced, or re-requests start a duplicate rescan. const deferreds: ((result: AiVaultListResult) => void)[] = [] - scanAiVaultSessionsInWorker.mockImplementation( + scanAiVaultSessionsInService.mockImplementation( () => new Promise((resolve) => deferreds.push(resolve)) ) // The scanner is invoked a microtask after the call (WSL-home await), so @@ -416,13 +420,13 @@ describe('aiVault.listSessions handler + shared cache', () => { // reverted guard reads 3, so this assertion — not a Promise.all hang — pins // the fix. await new Promise((resolve) => setTimeout(resolve)) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) deferreds[1]?.(makeResult()) await Promise.all([scanB, scanBAgain]) }) it('restamps the shared cached result as the addressed runtime host', async () => { - scanAiVaultSessionsInWorker.mockResolvedValue({ + scanAiVaultSessionsInService.mockResolvedValue({ sessions: [makeSession()], issues: [{ executionHostId: 'local', agent: 'claude', path: '/tmp', message: 'boom' }], scannedAt: SCANNED_AT @@ -442,8 +446,8 @@ describe('aiVault.listSessions handler + shared cache', () => { // Why: the host id must never change what is scanned — one host-local scan // (and one cache entry) serves every caller; only the stamps differ. - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) - expect(scanAiVaultSessionsInWorker.mock.calls[0]?.[0]).toMatchObject({ + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({ executionHostId: 'local' }) @@ -461,11 +465,11 @@ describe('aiVault.listSessions handler + shared cache', () => { }) const dispatcher = makeDispatcher() await dispatcher.dispatch(makeRequest('aiVault.listSessions', {})) - const options = scanAiVaultSessionsInWorker.mock.calls[0]?.[0] as AiVaultScanOptions + const options = scanAiVaultSessionsInService.mock.calls[0]?.[0] // Why: the codex-home is sourced from the runtime, not the window-only // registerCoreHandlers path, so it survives in serve mode. - expect(options.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions') - expect(options.wslHomeDirs).toEqual([]) + expect(options?.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions') + expect(options?.wslHomeDirs).toEqual([]) }) it('forwards codex-home through the real OrcaRuntimeService construction path', async () => { @@ -476,7 +480,7 @@ describe('aiVault.listSessions handler + shared cache', () => { getAdditionalAiVaultCodexHomePaths: () => ['/ctor/codex/home'] }) await runtime.listAiVaultSessions({}) - const options = scanAiVaultSessionsInWorker.mock.calls[0]?.[0] as AiVaultScanOptions - expect(options.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') + const options = scanAiVaultSessionsInService.mock.calls[0]?.[0] + expect(options?.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') }) }) diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index a467e45521f..2bf1a423fe1 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -22,10 +22,10 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn(), - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: vi.fn(), + resolveAiVaultSessionTitlesInService: vi.fn() })) const { AI_VAULT_METHODS } = await import('./rpc/methods/ai-vault') From 1241ce1b49ac1dade61c371d96b235610f40ff65 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:16:49 -0700 Subject: [PATCH 006/220] Skip slower root package-store restores in macOS PR jobs (#24908) * Skip slower root package-store restores in macOS PR jobs * Update the companion cache-policy contract --- .../install-node-dependencies/action.yml | 6 +-- .../ci-dependency-download-cache.test.mjs | 16 +++++--- .../scripts/pr-workflow-parallelism.test.mjs | 2 +- docs/reference/ci-runner-efficiency.md | 38 ++++++++++++++++++- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 2a0583917aa..6774942113a 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -92,7 +92,7 @@ runs: id: pnpm-store if: >- github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && - !(runner.os == 'Linux' && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && + !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) @@ -107,11 +107,11 @@ runs: printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT" # Match setup-node's key and path so existing default-branch stores remain reusable. - # Direct downloads beat store restoration for the measured Linux and Windows installs. + # Direct downloads beat store restoration for the measured Linux, macOS and Windows installs. - name: Restore pnpm download store without saving if: >- github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && - !(runner.os == 'Linux' && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && + !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 62f22673a33..bd8236a3662 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -32,13 +32,13 @@ describe('CI dependency download caches', () => { ]) }) - it('restores PR stores except measured Windows and Linux installs, without a post-job save', () => { + it('restores PR stores except measured Windows, Linux and macOS installs, without a post-job save', () => { const resolve = action.runs.steps.find((step) => step.id === 'pnpm-store') const restore = action.runs.steps.find( (step) => step.name === 'Restore pnpm download store without saving' ) expect(resolve.if).toBe( - "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !(runner.os == 'Linux' && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" + "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" ) expect(restore.if).toBe(resolve.if) expect(restore.uses).toBe('actions/cache/restore@v5') @@ -124,11 +124,17 @@ describe('CI dependency download caches', () => { ['Linux x64 custom PR', 'pull_request', 'Linux', 'X64', 'cloud/pnpm-lock.yaml', true, ''], ['Linux x64 root-only push', 'push', 'Linux', 'X64', false, false, 'pnpm'], ['Linux ARM64 root-only manual', 'workflow_dispatch', 'Linux', 'ARM64', false, false, 'pnpm'], - ['macOS x64 root-only PR', 'pull_request', 'macOS', 'X64', false, true, ''], - ['macOS ARM64 root-only PR', 'pull_request', 'macOS', 'ARM64', false, true, ''], + ['macOS x64 root-only PR', 'pull_request', 'macOS', 'X64', false, false, ''], + ['macOS ARM64 root-only PR', 'pull_request', 'macOS', 'ARM64', false, false, ''], + ['macOS x86 root-only PR', 'pull_request', 'macOS', 'X86', false, true, ''], + ['macOS x64 mixed PR', 'pull_request', 'macOS', 'X64', true, true, ''], + ['macOS ARM64 mixed PR', 'pull_request', 'macOS', 'ARM64', true, true, ''], + ['macOS ARM64 custom PR', 'pull_request', 'macOS', 'ARM64', 'cloud/pnpm-lock.yaml', true, ''], + ['macOS ARM64 opted-out PR', 'pull_request', 'macOS', 'ARM64', true, false, '', 'false'], + ['macOS x64 root-only push', 'push', 'macOS', 'X64', false, false, 'pnpm'], + ['macOS ARM64 root-only manual', 'workflow_dispatch', 'macOS', 'ARM64', false, false, 'pnpm'], ['Linux x64 mixed PR', 'pull_request', 'Linux', 'X64', true, true, ''], ['Linux ARM64 mixed PR', 'pull_request', 'Linux', 'ARM64', true, true, ''], - ['macOS ARM64 mixed PR', 'pull_request', 'macOS', 'ARM64', true, true, ''], ['Windows x64 mixed push', 'push', 'Windows', 'X64', true, false, 'pnpm'], ['Windows x64 mixed manual run', 'workflow_dispatch', 'Windows', 'X64', true, false, 'pnpm'] ])( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 10822ae701d..dca4a40ee01 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -305,7 +305,7 @@ describe('PR workflow parallelism', () => { ) expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5') expect(steps[restoreIndex].if).toBe( - "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !(runner.os == 'Linux' && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" + "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" ) }) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 86a28b0978f..6bf02e62035 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -174,9 +174,43 @@ setup. These are warm-policy setup measurements, not workflow or billing savings The shared installer consequently skips root-only Linux x64/ARM64 store restores on PRs. It still installs and checks every package through pnpm. Mixed mobile and -custom lockfile sets, other architectures, Mac behavior, verification/native caches, +custom lockfile sets, other architectures, verification/native caches, main store writers and release installation policies keep their existing behavior. -The measured Windows exceptions remain. No new periodic job or cache is added. +The measured Windows exceptions remain. Mac restores were retained at this stage; +the following comparison supersedes that policy. No periodic job or cache is added. + +## October 2 macOS root store comparison + +A [six-job hosted comparison](https://github.com/stablyai/orca/actions/runs/37078232553) +used the same paired method on macOS 15 Intel and Apple Silicon. All six jobs +passed with actual main store cache hits. The middle sample reversed treatment +order. Each treatment started with a reset dependency tree, store and pnpm +metadata, followed by the same verification-record restore. Policy files and +installed lockfile digests matched within every pair. + +| Architecture/sample | Store restore + install | Direct registry install | Paired saving | +| ------------------- | ----------------------- | ----------------------- | ------------- | +| x64 / 1 | 87.270s | 60.584s | 26.686s | +| x64 / 2 | 103.280s | 52.640s | 50.640s | +| x64 / 3 | 61.564s | 40.768s | 20.796s | +| ARM64 / 1 | 26.024s | 14.375s | 11.649s | +| ARM64 / 2 | 37.000s | 19.726s | 17.274s | +| ARM64 / 3 | 34.616s | 14.888s | 19.728s | + +Median paired savings are 26.686 seconds on x64 and 17.274 seconds on ARM64; +means are 32.707 and 16.217 seconds. Node matched within each pair: 24.19.0 on +Intel and 24.20.0 on Apple Silicon, as resolved by the existing installer. Both +used pnpm 12.8.1. Timing includes actual cache lookup/transfer/restore, inter-step +overhead and installation. Checkout, initial setup, process-wrapper preparation, +resets, verification restores, native work, tests, cache saves and queues are +excluded. Initial setup has already used package services. These measurements +do not establish whole-workflow or billing savings. + +The existing root-only PR exception now also covers macOS x64/ARM64. Frozen, +script-free installs and pnpm policy checks still run. Mixed/custom lockfile sets, +other architectures, verification/native caches, main/manual store writers and +release installation policies retain their existing behavior. No periodic job +or cache is added. ## October 1 Windows and dependency cache follow-up From 75f8f34ce369c378d3be8982ae767ac61b38d09a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:18:44 -0700 Subject: [PATCH 007/220] Overlap independent Linux headless runtime builds (#24910) --- .github/workflows/node-server-tests.yml | 44 ++++--- .../ci-background-step-barriers.test.mjs | 33 ++++- .../scripts/node-server-change-scope.test.mjs | 27 ++++- docs/reference/ci-runner-efficiency.md | 113 +++++++++++++++++- 4 files changed, 190 insertions(+), 27 deletions(-) diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index b3a5a02c33c..d9005930245 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -161,6 +161,27 @@ jobs: with: native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} cache-pnpm-store: ${{ runner.os != 'Windows' }} + # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped + # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION + # moves past the Bun daemon's. Its build uses this checkout's installed dependencies. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + if: runner.os == 'Linux' + with: + bun-version: 1.4.2 + - name: Build the last Bun orcad for the cross-runtime tests + id: bun-orcad + background: true + shell: bash + env: + BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc + run: | + if [ "$RUNNER_OS" != Linux ]; then exit 0; fi + git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" + git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" + ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" + node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" + echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT" + echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT" # Linux release slots come from the floor and Alpine lanes; this slot serves local tests. - uses: ./.github/actions/prepare-orcad-prebuilds id: orcad-prebuild @@ -171,26 +192,11 @@ jobs: (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }} restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} - run: pnpm build:orcad - # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped - # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION - # moves past the Bun daemon's. Same lockfile, so its build reuses this checkout's node_modules. - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - if: runner.os == 'Linux' - with: - bun-version: 1.4.2 - - name: Build the last Bun orcad for the cross-runtime tests - if: runner.os == 'Linux' - shell: bash - env: - BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc - run: | - git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" - git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" - ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" - node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" - echo "ORCA_BUN_ORCAD_SLOT=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_ENV" - echo "BUN_EXECUTABLE=$(command -v bun)" >> "$GITHUB_ENV" + - wait: bun-orcad - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} + env: + ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }} + BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }} # Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay. # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. - name: Build the Windows process-table addons for the desktop template diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index d9dc6332a9f..ba4de163f2d 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' const pr = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const mobile = parse(readFileSync('.github/workflows/mobile.yml', 'utf8')) const cloud = parse(readFileSync('.github/workflows/cloud-verify.yml', 'utf8')) +const headless = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) function assertJoinedBefore(steps, id, consumer) { const start = steps.findIndex((step) => step.id === id) @@ -25,7 +26,8 @@ describe('CI background step barriers', () => { pr.jobs.package, pr.jobs.shell_contracts, mobile.jobs.verify, - cloud.jobs.security + cloud.jobs.security, + headless.jobs.persistence ]) { const pending = new Set() for (const step of job.steps) { @@ -56,6 +58,35 @@ describe('CI background step barriers', () => { ) }) + it('joins the Linux Bun build before requiring both headless runtime artifacts', () => { + const steps = headless.jobs.persistence.steps + const consumer = (step) => step.run?.startsWith('pnpm test:node-server --artifact ') + assertJoinedBefore(steps, 'bun-orcad', consumer) + const start = steps.findIndex((step) => step.id === 'bun-orcad') + const join = steps.findIndex((step) => step.wait === 'bun-orcad') + const install = steps.findIndex((step) => step.uses?.endsWith('/install-node-dependencies')) + const setup = steps.findIndex((step) => step.uses?.startsWith('oven-sh/setup-bun@')) + expect(install).toBeGreaterThanOrEqual(0) + expect(setup).toBeGreaterThanOrEqual(0) + expect(install).toBeLessThan(setup) + expect(setup).toBeLessThan(start) + expect(steps[setup].if).toBe("runner.os == 'Linux'") + expect(steps[start].if).toBeUndefined() + expect(steps[start].run).toContain('if [ "$RUNNER_OS" != Linux ]; then exit 0; fi') + for (const build of [ + steps.findIndex((step) => step.uses?.endsWith('/prepare-orcad-prebuilds')), + steps.findIndex((step) => step.run === 'pnpm build:orcad') + ]) { + expect(build).toBeGreaterThan(start) + expect(build).toBeLessThan(join) + expect(steps[build].background).toBeUndefined() + } + const test = steps.find(consumer) + expect(test.run).toContain("${{ runner.os == 'Linux' && '--cross-runtime' || '' }}") + expect(test.env.ORCA_BUN_ORCAD_SLOT).toBe('${{ steps.bun-orcad.outputs.slot }}') + expect(test.env.BUN_EXECUTABLE).toBe('${{ steps.bun-orcad.outputs.executable }}') + }) + it('finishes native import-cycle analysis before mobile installation changes resolution', () => { const steps = pr.jobs.static_analysis.steps assertJoinedBefore(steps, 'native-code-quality', (step) => diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index a9c9e76bba1..4216beb2556 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -304,14 +304,29 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', ( expect(setupBun.with['bun-version']).toBe('1.4.2') const build = steps.find((step) => String(step.run).includes('build-orcad-bun.mjs')) expect(build.env.BUN_ORCAD_COMMIT).toMatch(/^[0-9a-f]{40}$/) - expect(build.run).toContain('ORCA_BUN_ORCAD_SLOT=') - expect(build.run).toContain('BUN_EXECUTABLE=') - for (const step of [setupBun, build]) { - expect(step.if).toBe("runner.os == 'Linux'") - } - expect(steps.map((step) => step.run).join('\n')).toContain( + expect(setupBun.if).toBe("runner.os == 'Linux'") + expect(build.id).toBe('bun-orcad') + expect(build.background).toBe(true) + expect(build.if).toBeUndefined() + expect(build['continue-on-error']).toBeUndefined() + expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/) + expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"') + expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"') + expect(build.run).not.toContain('GITHUB_ENV') + const join = steps.findIndex((step) => step.wait === build.id) + expect(join).toBeGreaterThan(steps.indexOf(build)) + expect(steps[join].if).toBeUndefined() + expect(steps[join]['continue-on-error']).toBeUndefined() + const consumer = steps.find((step) => step.run?.startsWith('pnpm test:node-server --artifact ')) + expect(steps.indexOf(consumer)).toBeGreaterThan(join) + expect(consumer.run).toBe( "pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }}" ) + expect(consumer.if).toBeUndefined() + expect(consumer.env).toEqual({ + ORCA_BUN_ORCAD_SLOT: '${{ steps.bun-orcad.outputs.slot }}', + BUN_EXECUTABLE: '${{ steps.bun-orcad.outputs.executable }}' + }) const alpine = workflow.jobs.linux_musl.steps.find((step) => String(step.run).includes('docker run') ) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 6bf02e62035..0cd1cd4666c 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1632,7 +1632,14 @@ hashes. Selected-case totals fell from 13.674 to 3.318 seconds and from 13.276 t 6.323 seconds. Whole-file test totals fell from 24.845 to 10.829 seconds and from 21.500 to 19.410 seconds. Process wall times were 41.488/37.810 seconds and 42.140/78.450 seconds; the reverse candidate spent 56.31 seconds importing under -unrelated local load. Overall wall-time savings remain inconclusive. +unrelated local load. Local process-wall savings were inconclusive. + +The later [hosted x64 and ARM comparison](https://github.com/stablyai/orca/actions/runs/37001891871) +passed the same 23 cases in `structured-chat-coordinator-mail.test.ts` in both +orders on each architecture, with frozen case and policy hashes. Median full-file +wall time was 33.506 → 23.149 seconds on x64 and 33.438 → 22.504 seconds on ARM. +Median test-body totals were 19.329 → 9.343 and 19.695 → 9.103 seconds, respectively. +These measurements qualify this file; they do not measure whole-PR time. Injected extra deliveries at 1,499 ms and 99 ms still fail the original assertions in both clock modes. The latter candidate fails the unchanged journal-read gate @@ -1640,3 +1647,107 @@ with the same extra provider start. A separate control confirms the orphan repai actually executes against the closed database and leaves no fake timers. The change retains all 121 original expectation sites and adds one teardown check; it does not shorten the runtime's observation interval or claim a whole-PR gain. + +## Stub child shutdown clocks: Codex and Claude + +[Merged Codex change #24893](https://github.com/stablyai/orca/pull/24893) scopes timeout +clocks to two synthetic-child cases in `codex-app-server-connection.test.ts`. +The full platform graceful deadline and 1,000 ms forced wait remain; the test +waits for the actual stub SIGKILL before advancing the forced window. Streams, +process-table reads, Date and immediate callbacks remain real. Fault controls +still detect late exit, missing EPIPE, missing exit proof and unwanted notification. + +The [hosted ARM comparison](https://github.com/stablyai/orca/actions/runs/37074124526) +passed the same 32 full-file cases in baseline/candidate and candidate/baseline +order. File wall times were 13.671 / 13.674 seconds originally and 1.658 / 1.649 +seconds with scoped clocks. Installer time is excluded; generated caches remain +across the disclosed order. Real-child coverage and production shutdown code remain. + +[Merged Claude change #24897](https://github.com/stablyai/orca/pull/24897) changes only +two synthetic-child cases in `claude-agent-sdk-exit-proof.test.ts`. Both full +33-case runs passed, including the unchanged five real-child cases. In one local +macOS pair, the two bodies took 2,503 / 1,502 ms originally and 1.37 / 0.39 ms with +scoped clocks. They cross a real immediate callback before advancing the complete +1,500 ms graceful and 1,000 ms forced windows, restore timers in `finally`, and +retain the original false exit verdicts. Fault controls detect either deadline +shortened by one millisecond, an unproved true verdict and a leftover timer. +[Normal PR CI](https://github.com/stablyai/orca/actions/runs/37075819218) passed; +these local body measurements do not establish hosted or whole-PR time savings. + +## Sequential static analysis and typecheck: retain separate jobs + +A four-trial hosted screen kept the slim router unchanged and compared the two +independent ARM jobs with one ARM job running their unchanged checks sequentially. +The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) +matched all compiler inputs and the full 10,477-file unit inventory in separate, +shared root-only and shared mixed-install states. The [safety qualification](https://github.com/stablyai/orca/actions/runs/37075043747) +verified native joins after compiler failure and a real late action-post failure; +all four guarded downstream sentinels skipped and the audit passed. + +| Trial | Mode | Active ARM seconds | Router finish to heavy finish, seconds | +| -------------------------------------------------------------- | -------- | -----------------: | -------------------------------------: | +| [1](https://github.com/stablyai/orca/actions/runs/37075574191) | Separate | 157 | 124 | +| [2](https://github.com/stablyai/orca/actions/runs/37075887937) | Combined | 134 | 150 | +| [3](https://github.com/stablyai/orca/actions/runs/37076222202) | Combined | 129 | 134 | +| [4](https://github.com/stablyai/orca/actions/runs/37076786281) | Separate | 159 | 194 | + +Both pairs saved active ARM time: 23 and 30 seconds, or 14.6% and 18.9%, with one +heavy admission instead of two. The active critical path was 15 and 8 seconds +longer. Downstream eligibility changed by +26 and −60 seconds; observed ready-to-start +delay differences of +11 and −68 seconds explain that reversal. Created-to-start +delay is recorded separately and does not establish a quota or queue cause. + +Retain separate jobs for now. This screen shows a capacity saving, with a longer +active critical path and no repeatable latency gain. All trials used frozen +`cc73c8e1a72b0e9ee9c29e57458ce307f5f019c2` source, manual workflow dispatches, +Node 24.21.0 and the same four exact primary cache hits. Main's later +[Linux PR root-store policy change #24896](https://github.com/stablyai/orca/pull/24896) +is outside this screen. The trial ran actual heavy checks and proved unit and both +package eligibility, without launching those downstream matrices or measuring a +whole-PR speedup. + +## Linux headless runtime build overlap + +The historical pinned Bun artifact now builds in a native background step while +current native preparation and Node bundling run in the foreground. An +unconditional join precedes the unchanged artifact and cross-runtime tests. Bun +setup stays Linux-only; other platforms register and join a successful no-op. +The producer publishes step outputs consumed only by those tests. The existing +selector, native floors, template builders and cache policies remain. + +A [hosted alternating comparison](https://github.com/stablyai/orca/actions/runs/37072923774) +ran four serial/overlap arms on each of two Linux VMs: + +| Architecture | Serial preparation, seconds | Overlapped preparation, seconds | +| ------------ | --------------------------: | ------------------------------: | +| x64 | 20.831 / 19.576 | 11.149 / 10.914 | +| ARM64 | 15.155 / 14.396 | 8.566 / 8.553 | + +Every arm passed the same 961 cases across 92 files: 930 passed and 31 skipped. +Both cross-runtime persistence cases passed. The two live daemon-handover cases +kept their existing protocol-version skips. All four x64 arms passed actual Node +18 loading and pinned-runtime handoff. Installed/source inputs and artifact +inventories matched; each normal owned-process ledger was clean before cleanup. +Common native compiler warmup preceded timing and retained its generated Python +caches in the strict installed ledger. These are preparation savings of 5.8–9.7 +seconds, excluding setup, cold installs, runner start delays and whole-PR time. + +Actual [Bun failure](https://github.com/stablyai/orca/actions/runs/37078015921) and +[Node failure](https://github.com/stablyai/orca/actions/runs/37078021568) controls +qualified genuine compiler errors with fresh live opposite builders, native joins, +skipped consumers, restored inputs and verified exits. A [normal cancellation +control](https://github.com/stablyai/orca/actions/runs/37079655167) received SIGINT +while the actual Bun builder was freshly live; both builders and the detached +owned child had simultaneous earlier readiness. All three native joins had terminal dispositions of cancelled, success and +cancelled, and every consumer skipped. The temporary observer retired its owned processes; +the collector independently verified their absence and unchanged inputs. This +proves signal delivery and observer-owned retirement, without establishing +runner-only descendant cleanup at the join. The unchanged historical builder +starts finite build/smoke work, and its children retain GitHub's normal orphan +tracking marker. + +Earlier cancellation trials remain excluded from live-build qualification: one +collector stopped its observer before signal routing, and the corrected trial +received the signal after both builders finished. The qualifying trial requested +normal cancellation earlier in the same preparation sequence to account for +observed delivery delay; no workload, wait or proof predicate was shortened. From 65deb4bdbe2469e578646e9d737943f8add07b93 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:23:07 -0700 Subject: [PATCH 008/220] test(persistence): wait for real worker readiness before timeout (#24793) --- .../profile-state-backup-worker.test.ts | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 4f331ed711e..9e1d09b761f 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -1,5 +1,13 @@ import { build } from 'esbuild' -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + watch, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' @@ -69,6 +77,32 @@ function script(directory: string, source: string): string { return path } +function waitForBackupWorkerReady(directory: string, ready: string): Promise { + const deadline = AbortSignal.timeout(5_000) + return new Promise((resolve, reject) => { + const watcher = watch(directory, () => { + if (existsSync(ready)) { + finish() + } + }) + const abort = () => finish(new Error('Backup worker did not become ready')) + const finish = (error?: Error) => { + watcher.close() + deadline.removeEventListener('abort', abort) + if (error) { + reject(error) + } else { + resolve() + } + } + watcher.once('error', finish) + deadline.addEventListener('abort', abort, { once: true }) + if (existsSync(ready)) { + finish() + } + }) +} + describe('profile state backup worker', () => { it('runs the built entry and releases every handle before recovery can move its files', async () => { const { directory, job } = fixture() @@ -179,21 +213,35 @@ describe('profile state backup worker', () => { ` ) const cancellation = new AbortController() + const workerReady = waitForBackupWorkerReady(directory, ready) + // Native startup must finish before the parent deadline advances. + if (mode === 'timeout') { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + } const pending = runProfileStateBackupWorker(job, { workerPath: worker, - // Worker startup must not race the cancellation assertion. timeoutMs: mode === 'cancel' ? 10_000 : 500, signal: cancellation.signal }) const failed = expect(pending).rejects.toThrow(mode === 'cancel' ? 'cancelled' : 'timed out') - await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5_000 }) - expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength(4) - if (mode === 'cancel') { + try { + await workerReady + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength( + 4 + ) + if (mode === 'cancel') { + cancellation.abort() + } else { + await vi.advanceTimersByTimeAsync(500) + } + await failed + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toEqual([]) + expect(existsSync(job.databasePath)).toBe(true) + } finally { cancellation.abort() + await pending.catch(() => undefined) + vi.useRealTimers() } - await failed - expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toEqual([]) - expect(existsSync(job.databasePath)).toBe(true) } ) From f97ca2a49d9c711dab54a656a7f8a47ae6c6749c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:23:11 -0700 Subject: [PATCH 009/220] Add Qoder session history and search (#24614) * Add Qoder session history and search with real CLI coverage * Allow the real Qoder marker file to end with a newline * Keep Qoder tool output out of history previews and search * Keep Qoder search pages readable by older clients * Verify persisted Qoder history after a real generated and resumed task * Negotiate Qoder filters before searching an older execution host * Combine search client imports for the CI plugin gate * Keep the relay search oracle aligned with legacy agent filtering * test(qoder): align search capability contracts and pin old-host fencing --- src/cli/agent-session-search-format.ts | 5 +- src/cli/handlers/search.test.ts | 11 +- src/cli/search-command-arguments.test.ts | 8 +- .../session-search-scope-entry-points.test.ts | 4 +- .../session-search-scope-service.test.ts | 37 +++- .../session-search-service-registry.test.ts | 45 ++++- .../session-search-service-registry.ts | 33 +++- .../qoder-1.1.64-generated-resumed.jsonl | 29 +++ .../remote-session-scanner-sources.ts | 11 ++ .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 10 + .../session-scanner-log-agent-fixtures.ts | 14 ++ .../ai-vault/session-scanner-parse-cache.ts | 3 + .../ai-vault/session-scanner-qoder-parser.ts | 116 +++++++++++ .../ai-vault/session-scanner-qoder.test.ts | 105 ++++++++++ .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ipc/ai-vault-search.test.ts | 6 +- src/main/qoder/real-cli.test.ts | 187 ++++++++++++++++++ .../rpc/methods/ai-vault-search.test.ts | 28 ++- src/relay/session-search-transport.test.ts | 10 +- .../right-sidebar/AiVaultPanelSearch.test.tsx | 6 + .../right-sidebar/AiVaultPanelSearch.tsx | 7 + src/renderer/src/i18n/locales/en.json | 1 + .../web-ai-vault-search-compat.test.ts | 3 +- .../__fixtures__/pre-qoder-search-request.ts | 58 ++++++ src/shared/ai-vault-resume-command.ts | 1 + src/shared/ai-vault-search-client.ts | 14 +- src/shared/ai-vault-search-contract.ts | 7 +- .../ai-vault-search-qoder-client.test.ts | 122 ++++++++++++ src/shared/ai-vault-types.ts | 2 + ...oder-history-search-downgrade.unit.test.ts | 125 ++++++++++++ 32 files changed, 985 insertions(+), 28 deletions(-) create mode 100644 src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl create mode 100644 src/main/ai-vault/session-scanner-qoder-parser.ts create mode 100644 src/main/ai-vault/session-scanner-qoder.test.ts create mode 100644 src/main/qoder/real-cli.test.ts create mode 100644 src/shared/__fixtures__/pre-qoder-search-request.ts create mode 100644 src/shared/ai-vault-search-qoder-client.test.ts create mode 100644 tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts diff --git a/src/cli/agent-session-search-format.ts b/src/cli/agent-session-search-format.ts index ddcebaad94f..ebefb6ff7af 100644 --- a/src/cli/agent-session-search-format.ts +++ b/src/cli/agent-session-search-format.ts @@ -77,11 +77,14 @@ function formatDebug(debug: SessionSearchResults['debug']): string[] { } function formatUnavailable( - reason: 'disabled' | 'not-ready' | 'no-service' | 'scope-unknown' + reason: 'disabled' | 'not-ready' | 'no-service' | 'scope-unknown' | 'unsupported-agent' ): string { if (reason === 'disabled') { return 'Session search is off on this host.' } + if (reason === 'unsupported-agent') { + return 'This host does not support history search for the selected agent. Update Orca on that host or select another agent.' + } // The CLI scopes with --path, never with an identity, so this only reaches a // caller that built a request by hand. if (reason === 'scope-unknown') { diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts index 4df1d1ef8c9..e970d08ac37 100644 --- a/src/cli/handlers/search.test.ts +++ b/src/cli/handlers/search.test.ts @@ -102,7 +102,11 @@ describe('orca search over the runtime RPC', () => { const { call } = await runSearch([['query', 'resize race']]) expect(call).toHaveBeenCalledTimes(1) - expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { query: 'resize race', limit: 20 }) + expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { + query: 'resize race', + limit: 20, + supportsQoderHistory: true + }) }) const flagCases: [string, CliFlags, Record][] = [ @@ -157,7 +161,10 @@ describe('orca search over the runtime RPC', () => { it.each(flagCases)('sends %s', async (_name, flags, params) => { const { call } = await runSearch(flags) - expect(call).toHaveBeenCalledWith('aiVault.searchSessions', params) + expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { + ...params, + supportsQoderHistory: true + }) }) it('calls the status RPC for --index-status', async () => { diff --git a/src/cli/search-command-arguments.test.ts b/src/cli/search-command-arguments.test.ts index 27b06286f52..4e9a7fa8805 100644 --- a/src/cli/search-command-arguments.test.ts +++ b/src/cli/search-command-arguments.test.ts @@ -126,10 +126,16 @@ describe('orca search argument parsing', () => { it('rejects an unknown --agent and names the known ones', () => { expect(() => parseSearch(['search', 'q', '--agent', 'claude', '--agent', 'bogus'])).toThrow( - /Unknown --agent "bogus"\. Known agents: claude, codebuddy, codex, / + /Unknown --agent "bogus"\. Known agents: claude, codebuddy, qoder, codex, / ) }) + it('accepts Qoder without changing another agent filter', () => { + expect( + request(['search', 'q', '--agent', 'qoder', '--agent', 'codex']).filters?.agents + ).toEqual(['qoder', 'codex']) + }) + it('rejects more --path values than the contract accepts', () => { const paths = Array.from({ length: 65 }, (_, index) => ['--path', `/p${index}`]).flat() expect(() => parseSearch(['search', 'q', ...paths])).toThrow('Too many --path values (65)') diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index c1bba274e6c..505dc3e4e2c 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -78,7 +78,7 @@ describe('every search entry point carries the scope identity through', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await relayHandler()({ query: 'needle', within: WITHIN }) + await relayHandler()({ query: 'needle', supportsQoderHistory: true, within: WITHIN }) expect(service.search).toHaveBeenCalledWith(expect.anything(), { kind: 'resolved', paths: ['/work/app'] @@ -88,7 +88,7 @@ describe('every search entry point carries the scope identity through', () => { it('hands the relay’s own verdict down, that host carrying no repo catalog', async () => { const service = fakeSearchService() setSessionSearchService(service) - await relayHandler()({ query: 'needle', within: WITHIN }) + await relayHandler()({ query: 'needle', supportsQoderHistory: true, within: WITHIN }) expect(service.search).toHaveBeenCalledWith( { query: 'needle', limit: 20 }, { diff --git a/src/main/ai-vault-search/session-search-scope-service.test.ts b/src/main/ai-vault-search/session-search-scope-service.test.ts index 394bcae4415..595f47baef1 100644 --- a/src/main/ai-vault-search/session-search-scope-service.test.ts +++ b/src/main/ai-vault-search/session-search-scope-service.test.ts @@ -26,7 +26,11 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } + }, 'ipc' ) // Beside the request, not inside `filters.scopePaths`, which carries a wire cap. @@ -41,7 +45,11 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', within: { kind: 'project', projectKey: 'repo:repo-1' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'project', projectKey: 'repo:repo-1' } + }, 'ipc' ) // An exact match, so a leaked `within` would fail here as an extra key. @@ -56,7 +64,11 @@ describe('scope identity at the search choke point', () => { setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) await searchSessionService( - { query: 'needle', within: { kind: 'project', projectKey: 'repo:elsewhere' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'project', projectKey: 'repo:elsewhere' } + }, 'ipc' ) // The service owns the answer, because it owns the consent and readiness @@ -74,7 +86,11 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) await searchSessionService( - { query: 'needle', within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'workspace', worktreeId: 'repo-1::/work/app' } + }, 'ipc' ) expect(service.search).toHaveBeenCalledWith( @@ -98,7 +114,11 @@ describe('scope identity at the search choke point', () => { settings: { workspaceDir: '/home/me/ws', nestWorkspaces: false } })) await searchSessionService( - { query: 'needle', within: { kind: 'project', projectKey: 'repo:repo-1' } }, + { + query: 'needle', + supportsQoderHistory: true, + within: { kind: 'project', projectKey: 'repo:repo-1' } + }, 'ipc' ) const call = service.search.mock.lastCall @@ -113,7 +133,7 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await searchSessionService({ query: 'needle' }, 'ipc') + await searchSessionService({ query: 'needle', supportsQoderHistory: true }, 'ipc') expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }, undefined) }) @@ -121,7 +141,10 @@ describe('scope identity at the search choke point', () => { const service = fakeSearchService() setSessionSearchService(service) installSessionSearchScopeCatalogSource(() => CATALOG) - await searchSessionService({ query: 'needle', filters: { scopePaths: ['/other'] } }, 'ipc') + await searchSessionService( + { query: 'needle', supportsQoderHistory: true, filters: { scopePaths: ['/other'] } }, + 'ipc' + ) expect(service.search).toHaveBeenCalledWith( { query: 'needle', limit: 20, filters: { scopePaths: ['/other'] } }, undefined diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts index b0f61ba6a99..36ca58e7d80 100644 --- a/src/main/ai-vault-search/session-search-service-registry.test.ts +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' -import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import { + unavailableSessionSearchStatus, + createSessionSearchClient +} from '../../shared/ai-vault-search-client' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { setSessionSearchService, searchSessionService, @@ -51,12 +55,49 @@ describe('session search service registry', () => { 'ipc' ) expect(service.reconcile).not.toHaveBeenCalled() - expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }, undefined) + expect(service.search).toHaveBeenCalledWith( + { + query: 'needle', + limit: 20, + filters: { agents: AI_VAULT_AGENTS.filter((a) => a !== 'qoder') } + }, + undefined + ) expect(result).not.toHaveProperty('debug') expect(await searchSessionService({ query: 'needle', debug: true }, 'ipc')).toHaveProperty( 'debug' ) }) + it.each(['ipc', 'runtime', 'relay'] as const)( + 'negotiates Qoder hits before retrieval on %s, without widening explicit filters', + async (transport) => { + const service = fakeSearchService() + setSessionSearchService(service) + await searchSessionService( + { query: 'proof', filters: { agents: ['claude', 'qoder'] } }, + transport + ) + expect(service.search).toHaveBeenLastCalledWith( + { query: 'proof', limit: 20, filters: { agents: ['claude'] } }, + undefined + ) + expect( + await searchSessionService({ query: 'proof', filters: { agents: ['qoder'] } }, transport) + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + const client = createSessionSearchClient( + (method, request) => + method === 'aiVault.searchStatus' + ? sessionSearchServiceStatus(request, transport) + : searchSessionService(request, transport), + transport + ) + await client.searchSessions({ query: 'proof', filters: { agents: ['qoder'] } }) + expect(service.search).toHaveBeenLastCalledWith( + { query: 'proof', limit: 20, filters: { agents: ['qoder'] } }, + undefined + ) + } + ) it('waits for reconcile before search, and clears its timeout', async () => { vi.useFakeTimers() const service = fakeSearchService() diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index deffbcfa18f..2740c34cac6 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -14,6 +14,7 @@ import { type SessionSearchTransport } from '../../shared/ai-vault-search-transport' import type { SessionSearchService } from './session-search-service' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' let service: SessionSearchService | null = null @@ -34,7 +35,23 @@ export async function searchSessionService( // The choke point every entry point funnels through, so every host kind // resolves alike; the verdict goes to the service, which answers off and // not-ready first. - const { within, ...request } = parsed + const { within, supportsQoderHistory, ...request } = parsed + // Older clients reject the whole page when a hit has an unknown agent tag. + const requestedAgents = request.filters?.agents + const compatibleAgents = (requestedAgents?.length ? requestedAgents : AI_VAULT_AGENTS).filter( + (agent) => supportsQoderHistory || agent !== 'qoder' + ) + const compatibleRequest = supportsQoderHistory + ? request + : { + ...request, + filters: { + ...request.filters, + agents: compatibleAgents.length + ? compatibleAgents + : AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') + } + } const hostScope = within ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) : undefined @@ -42,10 +59,15 @@ export async function searchSessionService( request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false - const result = AiVaultSearchResponseSchema.parse(await current.search(request, hostScope)) + const result = AiVaultSearchResponseSchema.parse( + await current.search(compatibleRequest, hostScope) + ) if (result.kind !== 'results') { return result } + if (compatibleAgents.length === 0) { + return { ...result, hits: [], page: { cursor: null, hasMore: false } } + } const { debug, ...fields } = result return { ...fields, @@ -61,9 +83,10 @@ export async function sessionSearchServiceStatus( ): Promise { AiVaultSearchStatusRequestSchema.parse(raw) return redactStatusForTransport( - AiVaultSearchStatusSchema.parse( - service ? await service.status() : unavailableSessionSearchStatus() - ), + AiVaultSearchStatusSchema.parse({ + ...(service ? await service.status() : unavailableSessionSearchStatus()), + supportsQoderHistory: true + }), transport ) } diff --git a/src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl b/src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl new file mode 100644 index 00000000000..23b8dca2b6c --- /dev/null +++ b/src/main/ai-vault/__fixtures__/qoder-1.1.64-generated-resumed.jsonl @@ -0,0 +1,29 @@ +{"type": "workspace-directories", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "directories": ["/tmp/qoder-proof"]} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925189036} +{"type": "worktree-state", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "worktreeSession": null} +{"type": "user", "uuid": "5231d503-07ef-4da9-86a6-8b24ce29b5d9", "timestamp": "2026-10-02T07:13:09.827Z", "message": {"role": "user", "content": "Create proof.txt containing exactly QODER_ORCA_PROOF_1002. Then respond with QODER_ORCA_COMPLETE_1002."}, "permissionMode": "acceptEdits", "origin": {"kind": "human"}, "promptId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "humanInput": {"text": "Create proof.txt containing exactly QODER_ORCA_PROOF_1002. Then respond with QODER_ORCA_COMPLETE_1002.", "mode": "prompt"}, "requestSetId": "ed2acf2f-b319-48c6-be63-34e7489c0dd8", "parentUuid": null, "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "0d6e72ea-d565-472d-81b4-9fe05f0d97c8", "explicit": false, "timestamp": 1790925189964} +{"type": "assistant", "uuid": "31d468b2-4206-4e9f-8866-5ca51de917e9", "timestamp": "2026-10-02T07:13:12.821Z", "message": {"id": "chatcmpl-efc1370b-f5de-9bb4-87df-6376580124bd", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "thinking", "thinking": "I'm creating the file now.\n", "signature": ""}]}, "parentUuid": "0d6e72ea-d565-472d-81b4-9fe05f0d97c8", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "45a9c19e-5c8c-4e9d-9a9b-5629fa545ec3", "timestamp": "2026-10-02T07:13:12.821Z", "message": {"id": "chatcmpl-efc1370b-f5de-9bb4-87df-6376580124bd", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "text", "text": "Creating the file.\n\n", "citations": null}]}, "parentUuid": "31d468b2-4206-4e9f-8866-5ca51de917e9", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "timestamp": "2026-10-02T07:13:12.821Z", "message": {"id": "chatcmpl-efc1370b-f5de-9bb4-87df-6376580124bd", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": "tool_use", "stop_sequence": null, "content": [{"type": "tool_use", "id": "call_f70efeb224b74814a8be55f8", "name": "Write", "input": {"content": "QODER_ORCA_PROOF_1002", "file_path": "/tmp/qoder-proof/proof.txt"}}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}, "parentUuid": "45a9c19e-5c8c-4e9d-9a9b-5629fa545ec3", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "explicit": false, "timestamp": 1790925192821} +{"type": "user", "uuid": "ae5db723-c9f4-4a35-902d-d4b8de01e431", "timestamp": "2026-10-02T07:13:13.317Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_f70efeb224b74814a8be55f8", "content": "File created successfully at: /tmp/qoder-proof/proof.txt"}]}, "sourceToolAssistantUUID": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "promptId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "toolUseResult": {"success": true, "file_path": "/tmp/qoder-proof/proof.txt", "bytesWritten": 21, "additions": 0, "deletions": 0}, "parentUuid": "7236b8c2-7833-4dd3-b7f0-e72acee559d0", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "ae5db723-c9f4-4a35-902d-d4b8de01e431", "explicit": false, "timestamp": 1790925193317} +{"type": "assistant", "uuid": "6fcecbbd-5bf1-4186-8766-369beb156bc9", "timestamp": "2026-10-02T07:13:15.794Z", "message": {"id": "chatcmpl-aee1d927-eb24-9aad-8b58-9d66ab7a1fb4", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "thinking", "thinking": "\"Containing exactly QODER_ORCA_PROOF_1002\" — I wrote without trailing newline. That's exact.\n", "signature": ""}]}, "parentUuid": "ae5db723-c9f4-4a35-902d-d4b8de01e431", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "timestamp": "2026-10-02T07:13:15.794Z", "message": {"id": "chatcmpl-aee1d927-eb24-9aad-8b58-9d66ab7a1fb4", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": "end_turn", "stop_sequence": null, "content": [{"type": "text", "text": "QODER_ORCA_COMPLETE_1002", "citations": null}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}, "parentUuid": "6fcecbbd-5bf1-4186-8766-369beb156bc9", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "explicit": false, "timestamp": 1790925195794} +{"type": "last-prompt", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "lastPrompt": "Create proof.txt containing exactly QODER_ORCA_PROOF_1002. Then respond with QODER_ORCA_COMPLETE_1002."} +{"type": "workspace-directories", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "directories": ["/tmp/qoder-proof"]} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925189036} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "explicit": false, "timestamp": 1790925195794} +{"type": "worktree-state", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "worktreeSession": null} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925244267} +{"type": "user", "uuid": "029ca680-b833-4301-9ce2-0d3d372d93dc", "timestamp": "2026-10-02T07:14:04.785Z", "message": {"role": "user", "content": "What exact marker did you write to proof.txt? Reply only with that marker."}, "permissionMode": "default", "origin": {"kind": "human"}, "promptId": "fd63d141-bc1c-4fe5-b46e-117d6e84579c", "humanInput": {"text": "What exact marker did you write to proof.txt? Reply only with that marker.", "mode": "prompt"}, "requestSetId": "aa08407f-0504-47a9-8e97-53595595be15", "parentUuid": "51a7fb68-5c13-4e92-bfd8-a26c25940566", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "029ca680-b833-4301-9ce2-0d3d372d93dc", "explicit": false, "timestamp": 1790925244785} +{"type": "assistant", "uuid": "8212543a-36a1-489c-a5f6-65ee294d2faf", "timestamp": "2026-10-02T07:14:07.626Z", "message": {"id": "chatcmpl-dc06221e-db1d-97aa-9a40-69d27c884243", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": null, "stop_sequence": null, "content": [{"type": "thinking", "thinking": "The user asks what exact marker I wrote. Let me just answer. I could verify by reading the file, but the instruction says don't re-read a file just to verify. The user asks a direct question; answer with the marker only.\n\nBut accuracy matters — \"exact marker\". I'm confident it's QODER_ORCA_PROOF_1002.", "signature": ""}]}, "parentUuid": "029ca680-b833-4301-9ce2-0d3d372d93dc", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "assistant", "uuid": "3f2475e3-00e5-4e36-9b3c-0d02618d177a", "timestamp": "2026-10-02T07:14:07.626Z", "message": {"id": "chatcmpl-dc06221e-db1d-97aa-9a40-69d27c884243", "type": "message", "role": "assistant", "model": "qmodel_38max", "stop_reason": "end_turn", "stop_sequence": null, "content": [{"type": "text", "text": "QODER_ORCA_PROOF_1002", "citations": null}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}, "parentUuid": "8212543a-36a1-489c-a5f6-65ee294d2faf", "isSidechain": false, "cwd": "/tmp/qoder-proof", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "userType": "external", "entrypoint": "cli", "version": "1.1.64", "gitBranch": "HEAD"} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "3f2475e3-00e5-4e36-9b3c-0d02618d177a", "explicit": false, "timestamp": 1790925247626} +{"type": "last-prompt", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "lastPrompt": "What exact marker did you write to proof.txt? Reply only with that marker."} +{"type": "workspace-directories", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "directories": ["/tmp/qoder-proof"]} +{"type": "runtime-config", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "model": "qmodel_38max", "reasoningEffort": null, "contextWindow": null, "generation": null, "timestamp": 1790925244267} +{"type": "active-leaf", "sessionId": "faa75b79-790e-4c44-8f3e-c7145d18eb7e", "leafUuid": "3f2475e3-00e5-4e36-9b3c-0d02618d177a", "explicit": false, "timestamp": 1790925247626} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index dfd012415d5..16b0938fee9 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,3 +1,4 @@ +import { parseQoderSessionContent } from './session-scanner-qoder-parser' import { remoteSessionDocumentParsers } from './remote-session-document-parsers' import type { RemoteSessionContent } from './remote-session-content-lines' import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' @@ -77,6 +78,16 @@ export function remoteSessionSources( ), partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, + { + ...jsonlSource( + 'qoder', + remoteHome, + hostPlatform, + ['.qoder', 'projects'], + parseQoderSessionContent + ), + partitionSubagentTranscripts: partitionSubagentTranscriptPaths + }, remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 95910d208b7..be2783e215e 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,3 +1,4 @@ +import { parseQoderSessionFile } from './session-scanner-qoder-parser' import { parseCodebuddySessionFile } from './session-scanner-codebuddy-parser' import type { AiVaultSession } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' @@ -88,6 +89,8 @@ export async function parseAgentSessionFile( signal?: AbortSignal ): Promise { switch (candidate.agent) { + case 'qoder': + return parseQoderSessionFile(candidate.file, platform, messages) case 'codebuddy': return parseCodebuddySessionFile(candidate.file, platform, messages) case 'claude': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 66d069b68cc..b9b5756b8fd 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -115,6 +115,16 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // their parent instead. directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME }, + qoder: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs( + options.qoderProjectsDir ?? join(homedir(), '.qoder', 'projects'), + wslHomeDirs, + ['.qoder', 'projects'] + ), + extensions: ['.jsonl'], + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, codebuddy: { rootDirs: (options, wslHomeDirs) => [ options.codebuddyProjectsDir ?? CODEBUDDY_PROJECTS_DIR, diff --git a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts index be080271269..c460ca7cc19 100644 --- a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts +++ b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts @@ -32,6 +32,20 @@ export async function writeLogAgentFixtures( ]) ) + await mkdir(join(roots.qoderProjectsDir, 'project'), { recursive: true }) + await writeFile( + join(roots.qoderProjectsDir, 'project', 'qoder-session.jsonl'), + jsonlBody([ + { type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/qoder'] }, + { + type: 'user', + sessionId: 'qoder-session', + timestamp: '2026-05-01T10:00:00.000Z', + message: { role: 'user', content: 'Qoder title' } + } + ]) + ) + // CodeBuddy writes its own message-record shape under its own root. await mkdir(join(roots.codebuddyProjectsDir, 'project'), { recursive: true }) await writeFile( diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index a00f4edd2cf..3fefd825833 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,3 +1,4 @@ +import { createQoderSessionResumeState } from './session-scanner-qoder-parser' import type { AiVaultSession } from '../../shared/ai-vault-types' import { inSessionParseFileLane } from './session-parse-file-lane' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' @@ -52,6 +53,8 @@ function resumableStateFactoryFor( switch (candidate.agent) { case 'claude': return (messages) => createClaudeSessionResumeState(candidate.file, messages) + case 'qoder': + return (messages) => createQoderSessionResumeState(candidate.file, messages) case 'codebuddy': return (messages) => createCodebuddySessionResumeState(candidate.file, messages) case 'codex': diff --git a/src/main/ai-vault/session-scanner-qoder-parser.ts b/src/main/ai-vault/session-scanner-qoder-parser.ts new file mode 100644 index 00000000000..47c56e973eb --- /dev/null +++ b/src/main/ai-vault/session-scanner-qoder-parser.ts @@ -0,0 +1,116 @@ +import { createInterface } from 'node:readline' +import type { ExecutionHostId } from '../../shared/execution-host' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' +import { accumulatorSessionIdentity, updateTimeline } from './session-scanner-accumulator' +import { + cloneClaudeSessionParseState, + consumeClaudeSessionLine, + createClaudeSessionParseState, + finalizeClaudeSessionParseState, + type ClaudeSessionParseState +} from './session-scanner-primary-parsers' +import { asRecord, extractString, parseJsonObject } from './session-scanner-values' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' +import type { FileWithMtime, ResumableSessionParseState } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' + +type ParserOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +function createQoderState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ClaudeSessionParseState { + const state = createClaudeSessionParseState(file, messages) + state.accumulator.agent = 'qoder' + return state +} + +function consumeQoderLine(state: ClaudeSessionParseState, line: string): void { + // Qoder 1.1.64 shares Claude's turns, titles and tool blocks, with extra startup records. + const record = parseJsonObject(line) + if (!record) { + return + } + const message = asRecord(record.message) + if (message && Array.isArray(message.content)) { + const content = message.content.filter((block) => asRecord(block)?.type === 'text') + consumeClaudeSessionLine(state, JSON.stringify({ ...record, message: { ...message, content } })) + } else { + consumeClaudeSessionLine(state, line) + } + if ( + record.type === 'workspace-directories' && + !state.accumulator.cwd && + Array.isArray(record.directories) + ) { + state.accumulator.cwd = extractString(record.directories[0]) + } + if (record.type === 'runtime-config') { + state.accumulator.model = extractString(record.model) ?? state.accumulator.model + updateTimeline(state.accumulator, record.timestamp) + } +} + +function resumeState(state: ClaudeSessionParseState): ResumableSessionParseState { + return { + consumeLine: (line) => consumeQoderLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => resumeState(cloneClaudeSessionParseState(state)), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => finalizeClaudeSessionParseState(state, platform, options) + } +} + +export function createQoderSessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { + return resumeState(createQoderState(file, messages)) +} + +export async function parseQoderSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink +) { + const stream = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') + const lines = createInterface({ input: stream, crlfDelay: Infinity }) + try { + return await parseLines(file, lines, platform, {}, messages) + } finally { + lines.close() + stream.destroy() + } +} + +export async function parseQoderSessionContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform = process.platform, + options: ParserOptions = {}, + signal?: AbortSignal +) { + return parseLines(file, remoteSessionContentLines(content, signal), platform, options) +} + +async function parseLines( + file: FileWithMtime, + lines: AsyncIterable | Iterable, + platform: NodeJS.Platform, + options: ParserOptions, + messages?: TranscriptMessageSink +) { + const state = createQoderState(file, messages) + for await (const line of lines) { + consumeQoderLine(state, line) + } + return finalizeClaudeSessionParseState(state, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-qoder.test.ts b/src/main/ai-vault/session-scanner-qoder.test.ts new file mode 100644 index 00000000000..78c99ddab88 --- /dev/null +++ b/src/main/ai-vault/session-scanner-qoder.test.ts @@ -0,0 +1,105 @@ +import { readFile, mkdtemp, rm, stat } from 'node:fs/promises' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' +import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' +import { scanAiVaultSessions } from './session-scanner' +import { + createQoderSessionResumeState, + parseQoderSessionContent +} from './session-scanner-qoder-parser' +import { remoteSessionSources } from './remote-session-scanner-sources' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import type { TranscriptMessage } from './session-transcript-consumers' + +const roots: string[] = [] +afterEach(async () => { + resetSessionParseCacheForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +it('decodes a real generated and resumed Qoder 1.1.64 session for preview and search', async () => { + const path = join(__dirname, '__fixtures__', 'qoder-1.1.64-generated-resumed.jsonl') + const content = await readFile(path, 'utf8') + const file = { path, mtimeMs: 1, modifiedAt: '2026-10-02T07:14:07.626Z' } + const messages: TranscriptMessage[] = [] + const state = createQoderSessionResumeState(file, { + active: true, + push: (message) => messages.push(message) + }) + const lines = content.trim().split('\n') + const split = lines.findIndex((line) => line.includes('What exact marker')) + for (const line of lines.slice(0, split)) { + state.consumeLine(line) + } + const before = await state.finalize('darwin') + const resumed = state.clone() + for (const line of lines.slice(split)) { + resumed.consumeLine(line) + } + const after = await resumed.finalize('darwin') + expect(after).toMatchObject({ + agent: 'qoder', + cwd: '/tmp/qoder-proof', + model: 'qmodel_38max', + sessionId: 'faa75b79-790e-4c44-8f3e-c7145d18eb7e' + }) + expect(after?.resumeCommand).toContain("qodercli --resume 'faa75b79-790e-4c44-8f3e-c7145d18eb7e'") + expect(after?.previewMessages.at(-1)?.text).toBe('QODER_ORCA_PROOF_1002') + expect(before?.previewMessages.at(-1)?.text).toBe('QODER_ORCA_COMPLETE_1002') + expect(messages.some((m) => m.role === 'assistant' && m.text === 'QODER_ORCA_PROOF_1002')).toBe( + true + ) + expect(messages.some((m) => m.text.includes("I'm creating the file now"))).toBe(false) + expect(messages.every((message) => message.role !== 'tool')).toBe(true) + expect(messages.some((message) => message.text.includes('File created successfully'))).toBe(false) + expect( + after?.previewMessages.some((message) => message.text.includes('File created successfully')) + ).toBe(false) + const remote = await parseQoderSessionContent(file, content, 'linux', { + executionHostId: 'ssh:test' + }) + expect(remote).toMatchObject({ + agent: 'qoder', + executionHostId: 'ssh:test', + cwd: '/tmp/qoder-proof' + }) +}) + +it('discovers Qoder folder history and prunes nested workers on local, WSL, and SSH hosts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-qoder-vault-')) + roots.push(root) + const options = isolatedScanRoots(root) + const path = join(options.qoderProjectsDir, 'folder', 'qoder-session.jsonl') + const records = [ + { type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/folder'] }, + { + type: 'user', + sessionId: 'qoder-session', + message: { role: 'user', content: 'Qoder folder proof' } + } + ] + await writeJsonlFile(path, records) + await writeJsonlFile( + join(options.qoderProjectsDir, 'folder', 'qoder-session', 'subagents', 'worker.jsonl'), + records + ) + const result = await scanAiVaultSessions({ ...options, wslHomeDirs: [] }) + const sessions = result.sessions.filter((s) => s.agent === 'qoder') + expect(sessions).toHaveLength(1) + expect(sessions[0]).toMatchObject({ title: 'Qoder folder proof', cwd: '/tmp/folder' }) + expect(AI_VAULT_AGENT_SOURCES.qoder?.rootDirs(options, ['/home/test'])).toContain( + join('/home/test', '.qoder', 'projects') + ) + for (const platform of ['darwin-arm64', 'linux-x64', 'win32-x64'] as const) { + const source = remoteSessionSources( + platform === 'win32-x64' ? 'C:\\Users\\test' : '/home/test', + getRemoteHostPlatform(platform) + ).find((s) => s.agent === 'qoder') + expect(source).toBeDefined() + expect(source?.partitionSubagentTranscripts).toBeDefined() + } + expect((await stat(path)).size).toBeGreaterThan(0) +}) diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0d9b8cef5ab..c21a7b9a7b3 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -55,6 +55,7 @@ export function isolatedScanRoots(root: string) { return { claudeProjectsDir: join(root, 'claude-projects'), codebuddyProjectsDir: join(root, 'codebuddy-projects'), + qoderProjectsDir: join(root, 'qoder-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), antigravityBrainDir: join(root, 'antigravity-brain'), diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index be97a4b3187..2a285409ac2 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -15,6 +15,7 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' export type AiVaultScanOptions = { claudeProjectsDir?: string codebuddyProjectsDir?: string + qoderProjectsDir?: string codexSessionsDir?: string additionalCodexSessionsDirs?: readonly string[] // Why: tests inject a sandbox "real ~/.codex" so real-home attribution diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index be4028460a0..bdcf8054f24 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -85,7 +85,8 @@ describe('desktop IPC and preload search boundary', () => { const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host') expect(sshSearch).toHaveBeenCalledWith('ssh-host', 'aiVault.searchSessions', { query: 'needle', - limit: 20 + limit: 20, + supportsQoderHistory: true }) expect(result).toMatchObject({ hits: [{ executionHostId: 'ssh:ssh-host', source: { presence: 'present' } }] @@ -105,7 +106,8 @@ describe('desktop IPC and preload search boundary', () => { const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'runtime:env-1') expect(runtimeSearch).toHaveBeenCalledWith('env-1', 'aiVault.searchSessions', { query: 'needle', - limit: 20 + limit: 20, + supportsQoderHistory: true }) expect(result).toMatchObject({ hits: [{ executionHostId: 'runtime:env-1', source: { presence: 'present' } }] diff --git a/src/main/qoder/real-cli.test.ts b/src/main/qoder/real-cli.test.ts new file mode 100644 index 00000000000..7cd1e8a139d --- /dev/null +++ b/src/main/qoder/real-cli.test.ts @@ -0,0 +1,187 @@ +import { + cp, + mkdtemp, + mkdir, + readFile, + readdir, + realpath, + rm, + stat, + writeFile +} from 'node:fs/promises' +import { createServer } from 'node:http' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeAndAccept } from '../../shared/agent-hook-listener-test-harness' +import { parseQoderSessionFile } from '../ai-vault/session-scanner-qoder-parser' + +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal() + return { ...original, homedir: () => sandbox.home || original.homedir() } +}) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { qoderHookService } from './hook-service' +import { markQoderWorkspaceTrusted } from './workspace-trust' + +it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( + 'generates and resumes a real task through managed hooks', + async () => { + const realHome = homedir() + sandbox.home = await realpath(await mkdtemp(join(tmpdir(), 'orca-qoder-real-'))) + const config = join(sandbox.home, '.qoder') + const workspace = join(sandbox.home, 'folder') + const statuses: { + event: unknown + state: unknown + agent: unknown + session: string | undefined + }[] = [] + const listener = createHookListenerState() + const server = createServer(async (request, response) => { + let content = '' + for await (const chunk of request) { + content += chunk + } + const fields = new URLSearchParams(content) + const payload = JSON.parse(fields.get('payload') ?? '{}') + const normalized = normalizeAndAccept(listener, 'qoder', payload) + statuses.push({ + event: payload.hook_event_name, + state: normalized?.payload.state, + agent: normalized?.payload.agentType, + session: normalized?.providerSession?.id + }) + response.writeHead(request.url === '/hook/qoder' ? 200 : 404).end() + }) + try { + await mkdir(workspace) + await mkdir(config) + await cp(join(realHome, '.qoder', '.auth'), join(config, '.auth'), { recursive: true }) + await writeFile( + join(config, 'settings.json'), + JSON.stringify({ general: { enableAutoUpdate: false } }) + ) + expect(qoderHookService.install().state).toBe('installed') + markQoderWorkspaceTrusted(workspace, sandbox.home) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('No test receiver port') + } + const env = { + ...process.env, + HOME: sandbox.home, + USERPROFILE: sandbox.home, + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'test-token', + ORCA_PANE_KEY: 'qoder-proof-pane', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_TRANSPORT: '', + ORCA_BACKGROUND_LAUNCH: '1' + } + const command = process.env.ORCA_QODER_CLI_PATH ?? join(realHome, '.local', 'bin', 'qodercli') + const args = [ + '--config-dir', + config, + '--cwd', + workspace, + '--max-model-request-retries', + '0', + '--print' + ] + const generated = await runProcess({ + program: command, + args: [ + ...args, + '--permission-mode', + 'accept_edits', + 'Create proof.txt containing exactly QODER_MANAGED_PROOF. Then reply QODER_MANAGED_COMPLETE.' + ], + env, + timeoutMs: 90000 + }) + expect(generated.code).toBe(0) + expect((await readFile(join(workspace, 'proof.txt'), 'utf8')).trim()).toBe( + 'QODER_MANAGED_PROOF' + ) + const session = statuses.find((s) => s.event === 'SessionStart')?.session + expect(session).toBeTruthy() + if (!session) { + throw new Error('Qoder did not identify its session') + } + const resumed = await runProcess({ + program: command, + args: [ + ...args, + '--resume', + session, + 'What exact marker did you write? Reply only with that marker.' + ], + env, + timeoutMs: 90000 + }) + expect(resumed.code).toBe(0) + expect(resumed.stdout.trim()).toBe('QODER_MANAGED_PROOF') + const projects = join(config, 'projects') + const transcript = (await readdir(projects, { recursive: true })).find( + (path) => path.endsWith(`${session}.jsonl`) && !path.includes('subagents') + ) + if (!transcript) { + throw new Error('Qoder did not persist the generated and resumed session') + } + const path = join(projects, transcript) + const modified = await stat(path) + const history = await parseQoderSessionFile({ + path, + mtimeMs: modified.mtimeMs, + modifiedAt: modified.mtime.toISOString() + }) + expect(history).toMatchObject({ agent: 'qoder', sessionId: session, cwd: workspace }) + expect(history?.previewMessages.at(-1)?.text).toBe('QODER_MANAGED_PROOF') + expect(history?.resumeCommand).toContain(`qodercli --resume '${session}'`) + expect(statuses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'SessionStart', + state: 'done', + agent: 'qoder', + session + }), + expect.objectContaining({ event: 'UserPromptSubmit', state: 'working', agent: 'qoder' }), + expect.objectContaining({ event: 'Stop', state: 'done', agent: 'qoder' }) + ]) + ) + const evidencePath = process.env.ORCA_QODER_PROOF_PATH + if (evidencePath) { + await writeFile( + evidencePath, + JSON.stringify( + { + generated: generated.stdout.trim(), + resumed: resumed.stdout.trim(), + statuses, + history: { + agent: history?.agent, + sessionId: history?.sessionId, + lastReply: history?.previewMessages.at(-1)?.text, + resumeVerified: true + } + }, + null, + 2 + ) + ) + } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + await rm(sandbox.home, { recursive: true, force: true }) + sandbox.home = '' + } + }, + 180000 +) diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index ae4a6bf7a5f..6f3b4301f70 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' import { RpcDispatcher } from '../dispatcher' import { OrcaRuntimeService } from '../../orca-runtime' import { AI_VAULT_METHODS } from './ai-vault' @@ -52,7 +53,11 @@ describe('session search runtime RPC', () => { expect(text.includes('/host/transcript.jsonl')).toBe(clientKind === undefined) expect(text.includes('resumeCommand')).toBe(clientKind === undefined) expect(service.search).toHaveBeenCalledExactlyOnceWith( - { query: 'needle', limit: 20 }, + { + query: 'needle', + limit: 20, + filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + }, undefined ) const status = await rpc.dispatch( @@ -62,6 +67,27 @@ describe('session search runtime RPC', () => { expect(status).toMatchObject({ ok: true, result: { enabled: true, generation: 7 } }) } ) + it.each([undefined, 'runtime', 'mobile'] as const)( + 'preserves explicitly supported Qoder filters for client kind %s', + async (clientKind) => { + const service = fakeSearchService() + setSessionSearchService(service) + expect( + await dispatcher().dispatch( + request({ + query: 'needle', + supportsQoderHistory: true, + filters: { agents: ['qoder', 'codex'] } + }), + { clientKind } + ) + ).toMatchObject({ ok: true }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'needle', limit: 20, filters: { agents: ['qoder', 'codex'] } }, + undefined + ) + } + ) it('maps the old runtime dispatcher refusal and rejects malformed responses', async () => { const legacy = dispatcher(true) const client = createSessionSearchClient(async (method, params) => { diff --git a/src/relay/session-search-transport.test.ts b/src/relay/session-search-transport.test.ts index f0765af8f93..40752398159 100644 --- a/src/relay/session-search-transport.test.ts +++ b/src/relay/session-search-transport.test.ts @@ -3,6 +3,7 @@ import { RelayDispatcher } from './dispatcher' import { AiVaultHandler } from './ai-vault-handler' import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' import { createSessionSearchClient } from '../shared/ai-vault-search-client' +import { AI_VAULT_AGENTS } from '../shared/ai-vault-types' import { fakeSearchService } from '../shared/ai-vault-search-test-fixture' import { setSessionSearchService } from '../main/ai-vault-search/session-search-service-registry' @@ -52,7 +53,14 @@ describe('session search over real relay frames', () => { expect(JSON.stringify(raw)).not.toContain('/host/transcript') expect(JSON.stringify(raw)).not.toContain('/host/codex') expect(JSON.stringify(raw)).not.toContain('resumeCommand') - expect(service.search).toHaveBeenLastCalledWith({ query: 'needle', limit: 20 }, undefined) + expect(service.search).toHaveBeenLastCalledWith( + { + query: 'needle', + limit: 20, + filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + }, + undefined + ) expect(service.reconcile).not.toHaveBeenCalled() expect(await client.searchStatus()).toMatchObject({ enabled: true, generation: 7 }) await expect(mux.request('aiVault.searchSessions', { query: 42 })).rejects.toThrow() diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx index afce262f459..75c7b6f3de6 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx @@ -49,6 +49,12 @@ function renderPanel(search: PanelSearch) { } describe('AiVaultPanelSearch', () => { + it('explains an unsupported agent without reporting zero matches or a missing host service', () => { + renderPanel(panelSearch({ response: { kind: 'unavailable', reason: 'unsupported-agent' } })) + expect(screen.getByRole('status').textContent).toContain( + 'does not support history search for the selected agent' + ) + }) it('names every computer the merge could not search, with its reason', () => { const response = searchResults() renderPanel( diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx index 833921a9ce4..ecee06a14cd 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelSearch.tsx @@ -24,6 +24,8 @@ function hostSkipReason(outcome: AiVaultSearchHostOutcome['outcome']): string | return 'unreachable' case 'scope-unknown': return 'scope not found there' + case 'unsupported-agent': + return 'agent not supported' } } @@ -111,6 +113,11 @@ export function AiVaultPanelSearch({ 'sessionSearch.panel.noService', 'Search is unavailable on this computer. It may need an Orca update or a runtime with search support.' ) + } else if (unavailable === 'unsupported-agent') { + message = translate( + 'sessionSearch.panel.unsupportedAgent', + 'This computer does not support history search for the selected agent. Update Orca on that computer or select another agent.' + ) } else if (unavailable === 'scope-unknown') { message = translate( 'sessionSearch.panel.scopeUnknown', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1e448298aa5..b751ce57730 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18579,6 +18579,7 @@ "remoteDisabled": "Search is disabled on this computer. Enable transcript indexing on that computer to search its sessions.", "notReady": "The search index is not ready yet. Try again shortly.", "noService": "Search is unavailable on this computer. It may need an Orca update or a runtime with search support.", + "unsupportedAgent": "This computer does not support history search for the selected agent. Update Orca on that computer or select another agent.", "failed": "Could not search this computer. Check its connection and try again.", "changed": "The index changed while searching. Search again for current results.", "truncated": "Some results or matching text were limited. Narrow your search for more precise results.", diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts index 215ffc928f5..1300be5499a 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -20,7 +20,8 @@ describe('web session search preload compatibility', () => { const result = await api.searchSessions({ query: 'needle' }) expect(callRuntimeResult).toHaveBeenCalledExactlyOnceWith('aiVault.searchSessions', { query: 'needle', - limit: 20 + limit: 20, + supportsQoderHistory: true }) expect(result).toMatchObject({ kind: 'results', hits: [{ source: { presence: 'present' } }] }) expect(JSON.stringify(result)).not.toContain('resumeCommand') diff --git a/src/shared/__fixtures__/pre-qoder-search-request.ts b/src/shared/__fixtures__/pre-qoder-search-request.ts new file mode 100644 index 00000000000..bfe585f0326 --- /dev/null +++ b/src/shared/__fixtures__/pre-qoder-search-request.ts @@ -0,0 +1,58 @@ +// Pre-Qoder b49abdb request parser and closed agent enum; keep independent of the current catalog. +import { resolveSessionSearchLimit } from '../ai-vault-search-limit' +import { z } from 'zod' +export const AI_VAULT_AGENTS = [ + 'claude', + 'codebuddy', + 'codex', + 'hermes', + 'pi', + 'omp', + 'prime-agent', + 'cursor', + 'gemini', + 'antigravity', + 'rovo', + 'copilot', + 'opencode', + 'opencode2', + 'zcode', + 'grok', + 'openclaw', + 'devin', + 'droid', + 'cline', + 'kimi', + 'muse' +] as const +const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64 +const AI_VAULT_SEARCH_SORTS = ['relevance', 'newest'] as const +import { AiVaultSearchScopeIdentitySchema } from '../ai-vault-search-scope' + +export const AiVaultSearchFiltersSchema = z.object({ + agents: z.array(z.enum(AI_VAULT_AGENTS)).optional(), + scopePaths: z.array(z.string().min(1).max(4096)).max(AI_VAULT_SCOPE_PATHS_MAX_COUNT).optional(), + since: z.string().datetime({ offset: true }).optional(), + sort: z.enum(AI_VAULT_SEARCH_SORTS).optional() +}) + +// Strip unknown fields so legacy tier/refresh are accepted without affecting the query. +export const AiVaultSearchRequestSchema = z + .object({ + query: z.string(), + scope: z.enum(['conversation', 'all']).optional(), + freshness: z.enum(['indexed', 'wait-until-current']).optional(), + limit: z.number().optional().transform(resolveSessionSearchLimit), + cursor: z.string().optional(), + filters: AiVaultSearchFiltersSchema.optional(), + /** Scope by identity, resolved into paths by whichever host answers. */ + within: AiVaultSearchScopeIdentitySchema.optional(), + debug: z.boolean().optional() + }) + // Two scopes in one request have no defined intersection, and guessing one + // would be the silent widening this field exists to remove. Neither is still + // legal and still means every session. + .refine( + (request) => request.within === undefined || (request.filters?.scopePaths ?? []).length === 0, + { message: 'A search carries either a scope identity or explicit scope paths, not both' } + ) diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index efb2ddbe5d3..60f50023188 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -218,6 +218,7 @@ function buildAgentResumeInvocation( return `${baseCommand} resume ${sessionArg}` case 'cline': return `${baseCommand} --id ${sessionArg}` + case 'qoder': case 'codebuddy': case 'claude': case 'zcode': diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index dd68bbd6c67..66501b2994b 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -46,9 +46,21 @@ export function createSessionSearchClient( return { searchSessions: async (request) => { const parsed = AiVaultSearchRequestSchema.parse(request) + let hostRequest = parsed let raw: unknown try { - raw = await call('aiVault.searchSessions', parsed) + // IPC and its all-hosts merge are this build; each remote leg negotiates its own host. + if (transport !== 'ipc' && parsed.filters?.agents?.includes('qoder')) { + const status = AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + if (status.supportsQoderHistory !== true) { + const agents = parsed.filters.agents.filter((agent) => agent !== 'qoder') + if (agents.length === 0) { + return { kind: 'unavailable', reason: 'unsupported-agent' } + } + hostRequest = { ...parsed, filters: { ...parsed.filters, agents } } + } + } + raw = await call('aiVault.searchSessions', { ...hostRequest, supportsQoderHistory: true }) } catch (error) { if (isUnknownSessionSearchMethod(error)) { return { kind: 'unavailable', reason: 'no-service' } diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts index 15733400234..8966b1bfe77 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -23,6 +23,7 @@ export const AiVaultSearchRequestSchema = z limit: z.number().optional().transform(resolveSessionSearchLimit), cursor: z.string().optional(), filters: AiVaultSearchFiltersSchema.optional(), + supportsQoderHistory: z.boolean().optional(), /** Scope by identity, resolved into paths by whichever host answers. */ within: AiVaultSearchScopeIdentitySchema.optional(), debug: z.boolean().optional() @@ -90,7 +91,8 @@ export const AiVaultSearchHostOutcomeSchema = z.object({ 'no-service', 'unreachable', // This host does not know the workspace or project the scope named. - 'scope-unknown' + 'scope-unknown', + 'unsupported-agent' ]) }) const routeSchema = z.enum(['phrase', 'and', 'or', 'typo+phrase', 'typo+and', 'typo+or']) @@ -125,13 +127,14 @@ export const AiVaultSearchResponseSchema = z.discriminatedUnion('kind', [ kind: z.literal('unavailable'), // `scope-unknown` only ever answers a request that carried `within`, so a // client too old to send one can never receive a reason it cannot parse. - reason: z.enum(['disabled', 'not-ready', 'no-service', 'scope-unknown']) + reason: z.enum(['disabled', 'not-ready', 'no-service', 'scope-unknown', 'unsupported-agent']) }) ]) export const AiVaultSearchStatusRequestSchema = z.object({}) /** Consent flip for one host's index. Answered with that host's status after the change is applied. */ export const AiVaultSetSearchEnabledParamsSchema = z.object({ enabled: z.boolean() }) export const AiVaultSearchStatusSchema = z.object({ + supportsQoderHistory: z.boolean().optional(), enabled: z.boolean(), phase: z.enum(['idle', 'indexing', 'current', 'degraded', 'closed']), filesIndexed: z.number().int().nonnegative(), diff --git a/src/shared/ai-vault-search-qoder-client.test.ts b/src/shared/ai-vault-search-qoder-client.test.ts new file mode 100644 index 00000000000..0df8dd802ea --- /dev/null +++ b/src/shared/ai-vault-search-qoder-client.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSessionSearchClient, unavailableSessionSearchStatus } from './ai-vault-search-client' +import { AiVaultSearchRequestSchema as LegacyRequestSchema } from './__fixtures__/pre-qoder-search-request' +import { searchHit, searchResults } from './ai-vault-search-test-fixture' + +describe('Qoder search negotiation', () => { + it('keeps the frozen old parser closed to Qoder', () => { + expect( + LegacyRequestSchema.safeParse({ query: 'q', filters: { agents: ['qoder'] } }).success + ).toBe(false) + }) + + it.each(['runtime', 'relay'] as const)( + 'sends no search request for sole unsupported Qoder over %s', + async (transport) => { + const search = vi.fn((params: unknown) => { + LegacyRequestSchema.parse(params) + return searchResults() + }) + const status = vi.fn(() => unavailableSessionSearchStatus()) + const client = createSessionSearchClient( + async (method, params) => (method === 'aiVault.searchStatus' ? status() : search(params)), + transport + ) + expect(await client.searchSessions({ query: 'q', filters: { agents: ['qoder'] } })).toEqual({ + kind: 'unavailable', + reason: 'unsupported-agent' + }) + expect(status).toHaveBeenCalledTimes(1) + expect(search).not.toHaveBeenCalled() + } + ) + + it.each(['runtime', 'relay'] as const)( + 'retains every non-Qoder filter through the actual old enum over %s', + async (transport) => { + const request = { + query: 'q', + scope: 'conversation' as const, + limit: 42, + cursor: 'page-1', + debug: true, + filters: { + agents: ['qoder', 'codex', 'claude'] as const, + scopePaths: ['/host/folder'], + since: '2026-08-01T00:00:00Z', + sort: 'newest' as const + } + } + const search = vi.fn((params: unknown) => { + expect(LegacyRequestSchema.parse(params)).toEqual({ + ...request, + filters: { ...request.filters, agents: ['codex', 'claude'] } + }) + return searchResults() + }) + const client = createSessionSearchClient( + async (method, params) => + method === 'aiVault.searchStatus' ? unavailableSessionSearchStatus() : search(params), + transport + ) + await client.searchSessions({ + ...request, + filters: { ...request.filters, agents: [...request.filters.agents] } + }) + expect(search).toHaveBeenCalledTimes(1) + } + ) + + it.each(['runtime', 'relay'] as const)( + 'preserves Qoder identity only after positive host attestation over %s', + async (transport) => { + const call = vi.fn(async (method: string) => + method === 'aiVault.searchStatus' + ? { ...unavailableSessionSearchStatus(), supportsQoderHistory: true } + : { ...searchResults(), hits: [{ ...searchHit(), agent: 'qoder' }] } + ) + const result = await createSessionSearchClient(call, transport).searchSessions({ + query: 'q', + filters: { agents: ['qoder'] } + }) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + expect(call).toHaveBeenLastCalledWith('aiVault.searchSessions', { + query: 'q', + limit: 20, + filters: { agents: ['qoder'] }, + supportsQoderHistory: true + }) + expect(result).toMatchObject({ kind: 'results', hits: [{ agent: 'qoder' }] }) + } + ) + + it('does not gate local IPC before the per-host aggregator negotiates', async () => { + const call = vi.fn(async () => searchResults()) + await createSessionSearchClient(call, 'ipc').searchSessions({ + query: 'q', + filters: { agents: ['qoder'] } + }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchSessions', { + query: 'q', + limit: 20, + filters: { agents: ['qoder'] }, + supportsQoderHistory: true + }) + }) + + it('does not dispatch a search when the capability probe loses host contact', async () => { + const call = vi.fn(async () => { + throw new Error('host disconnected') + }) + await expect( + createSessionSearchClient(call, 'relay').searchSessions({ + query: 'q', + filters: { agents: ['qoder', 'codex'] } + }) + ).rejects.toThrow('host disconnected') + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) +}) diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index e337f98d7a0..26431f05810 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', 'codebuddy', + 'qoder', 'codex', 'hermes', 'pi', @@ -53,6 +54,7 @@ export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', codebuddy: 'CodeBuddy', + qoder: 'Qoder', codex: 'Codex', hermes: 'Hermes', pi: 'Pi', diff --git a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts new file mode 100644 index 00000000000..0a00604408d --- /dev/null +++ b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../src/shared/ai-vault-search-client' +import { searchResults, fakeSearchService } from '../../../src/shared/ai-vault-search-test-fixture' +import { + searchSessionService, + setSessionSearchService +} from '../../../src/main/ai-vault-search/session-search-service-registry' +import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +afterEach(() => setSessionSearchService(null)) + +test('a pre-Qoder release can read current host search pages without losing other agents', async () => { + const checkout = await materializeReleaseCheckout('v1.4.211') + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/shared/ai-vault-search-contract.ts' + ) + const schema = baseline.AiVaultSearchResponseSchema + if ( + !schema || + typeof schema !== 'object' || + !('safeParse' in schema) || + typeof schema.safeParse !== 'function' + ) { + throw new Error('The pinned release has no search response parser') + } + const qoder = { ...searchResults().hits[0], agent: 'qoder' as const } + expect(schema.safeParse({ ...searchResults(), hits: [qoder] })).toHaveProperty('success', false) + const service = fakeSearchService() + service.search.mockImplementation(async (request) => ({ + ...searchResults(), + hits: + !request.filters?.agents || request.filters.agents.includes('qoder') + ? [qoder] + : searchResults().hits + })) + setSessionSearchService(service) + const oldResponse = await searchSessionService({ query: 'proof' }, 'relay') + expect(schema.safeParse(oldResponse)).toHaveProperty('success', true) + expect(oldResponse).toMatchObject({ hits: [{ agent: 'codex' }] }) + const client = createSessionSearchClient( + (_method, request) => searchSessionService(request, 'relay'), + 'relay' + ) + expect(await client.searchSessions({ query: 'proof' })).toMatchObject({ + hits: [{ agent: 'qoder' }] + }) + const oldRequest = baseline.AiVaultSearchRequestSchema + if ( + !oldRequest || + typeof oldRequest !== 'object' || + !('safeParse' in oldRequest) || + typeof oldRequest.safeParse !== 'function' + ) { + throw new Error('The pinned release has no search request parser') + } + expect(oldRequest.safeParse({ query: 'proof', supportsQoderHistory: true })).toHaveProperty( + 'success', + true + ) +}) + +test.each(['v1.4.211', 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'])( + 'a current client narrows Qoder filters before calling the actual %s request parser', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/shared/ai-vault-search-contract.ts' + ) + const requestSchema = baseline.AiVaultSearchRequestSchema + if ( + !requestSchema || + typeof requestSchema !== 'object' || + !('parse' in requestSchema) || + typeof requestSchema.parse !== 'function' + ) { + throw new Error('Pinned host has no search request parser') + } + const call = vi.fn(async (method: string, request: Record) => { + if (method === 'aiVault.searchStatus') { + return { ...unavailableSessionSearchStatus(), enabled: true, phase: 'current' } + } + requestSchema.parse(request) + return searchResults() + }) + const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' } + const client = createSessionSearchClient(call, 'relay') + expect( + await client.searchSessions({ + query: 'proof', + filters: { agents: ['codex', 'qoder'] }, + within + }) + ).toMatchObject({ hits: [{ agent: 'codex' }] }) + expect(call).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ + filters: { agents: ['codex'] }, + within + }) + ) + call.mockClear() + expect( + await client.searchSessions({ query: 'proof', filters: { agents: ['qoder'] }, within }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledWith('aiVault.searchStatus', {}) + if (ref.startsWith('b49')) { + expect( + await client.searchSessions({ query: 'proof', filters: { agents: [...AI_VAULT_AGENTS] } }) + ).toMatchObject({ hits: [{ agent: 'codex' }] }) + expect(call).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ + filters: { agents: AI_VAULT_AGENTS.filter((agent) => agent !== 'qoder') } + }) + ) + } + } +) From 00da8e1e54cfe83eca16d790524a058b46fbc8f8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:23:14 -0700 Subject: [PATCH 010/220] fix(cursor): show the primary model pool in compact usage (#24830) * fix(cursor): show the primary usage pool without hiding exhaustion Use Cursor's reported plan percentage when its base allowance disagrees. Select Cursor Models for compact display while preserving maximum-pool warning, overflow, sorting and collapse behavior. Adopts the plan mapping and primary headline intent from PR23531. Co-authored-by: DakaAlvarez <149860458+Dacadev97@users.noreply.github.com> * fix(cursor): describe compact usage summaries Correct the existing six translations and fallback to describe one summary per provider after the compact Cursor primary-pool adoption. Preserve quota mapping, selectors, alerts, sorting and detailed presentation. Validated source patch: d2a233e5f90a2cf8ccfb02dfafe99e0e03add48d with normal installed commit hooks, localization catalogs and changed-code quality. Standalone copy uses a private index and preserves the reviewed candidate ancestry. Co-authored-by: DakaAlvarez <149860458+Dacadev97@users.noreply.github.com> --------- Co-authored-by: DakaAlvarez <149860458+Dacadev97@users.noreply.github.com> --- .../rate-limits/cursor-usage-mapping.test.ts | 34 +++- src/main/rate-limits/cursor-usage-mapping.ts | 11 +- .../status-bar/StatusBarProviderSegment.tsx | 4 +- .../status-bar/UsageRosterPanel.tsx | 15 +- .../status-bar/cursor-usage-headline.test.tsx | 163 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 11 files changed, 220 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/components/status-bar/cursor-usage-headline.test.tsx diff --git a/src/main/rate-limits/cursor-usage-mapping.test.ts b/src/main/rate-limits/cursor-usage-mapping.test.ts index 8e1b5192fde..d816041a994 100644 --- a/src/main/rate-limits/cursor-usage-mapping.test.ts +++ b/src/main/rate-limits/cursor-usage-mapping.test.ts @@ -28,14 +28,42 @@ describe('mapCursorUsageSummary', () => { expect(mapped.monthly?.resetsAt).toBe(Date.parse(CYCLE.billingCycleEnd)) }) - it('prefers the used/limit pair over the rounded percentage Cursor renders', () => { + it('uses the reported plan percentage when the base allowance reports 100%', () => { const mapped = mapCursorUsageSummary({ ...CYCLE, - individualUsage: { plan: { enabled: true, used: 1_000, limit: 3_000, totalPercentUsed: 33 } } + individualUsage: { plan: { enabled: true, used: 2_000, limit: 2_000, totalPercentUsed: 12 } } }) - expect(mapped.monthly?.usedPercent).toBeCloseTo(33.333, 3) + expect(mapped.monthly?.usedPercent).toBe(12) }) + it.each([0, '0', 140, -2])( + 'keeps reported plan percentage %s authoritative', + (totalPercentUsed) => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: true, used: 100, limit: 100, totalPercentUsed }, + onDemand: { enabled: true, used: 95, limit: 100, totalPercentUsed: 4 } + } + }) + expect(mapped.monthly?.usedPercent).toBe(Math.max(0, Math.min(100, Number(totalPercentUsed)))) + expect(mapped.buckets.find((bucket) => bucket.name === 'On-demand')?.usedPercent).toBe(95) + } + ) + + it.each([undefined, null, '', 'invalid', Number.POSITIVE_INFINITY, Number.NaN])( + 'falls back to the raw allowance for missing or invalid plan percentage %s', + (totalPercentUsed) => { + const mapped = mapCursorUsageSummary( + parseCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, used: 25, limit: 100, totalPercentUsed } } + }) + ) + expect(mapped.monthly?.usedPercent).toBe(25) + } + ) + it('falls back to the percentage when no cents allowance is reported', () => { const mapped = mapCursorUsageSummary({ ...CYCLE, diff --git a/src/main/rate-limits/cursor-usage-mapping.ts b/src/main/rate-limits/cursor-usage-mapping.ts index 66a29d46469..0af9b5f6bd9 100644 --- a/src/main/rate-limits/cursor-usage-mapping.ts +++ b/src/main/rate-limits/cursor-usage-mapping.ts @@ -100,11 +100,7 @@ function windowMinutesFor(startMs: number | null, endMs: number | null): number return Math.max(1, Math.round((endMs - startMs) / 60_000)) } -/** - * Percent consumed for one pool. `used / limit` wins over the sibling percentage - * fields: the raw pair is internally consistent, while the percentages are - * pre-rounded for the dashboard's own copy and disagree with it on real accounts. - */ +// On-demand's raw allowance avoids rounding in its reported percentage. function poolPercent( pool: CursorPool | null | undefined, percentField: keyof CursorPool @@ -153,7 +149,10 @@ export function mapCursorUsageSummary(summary: CursorUsageSummary): CursorUsageM buckets.push({ name: CURSOR_ON_DEMAND_BUCKET_NAME, ...toWindow(onDemandPercent) }) } - const planPercent = planEnabled ? poolPercent(plan, 'totalPercentUsed') : null + // The raw base allowance can report 100% while the actual plan still has capacity. + const planPercent = planEnabled + ? (finiteNumber(plan?.totalPercentUsed) ?? poolPercent(plan, 'totalPercentUsed')) + : null const membership = summary.membershipType return { monthly: planPercent === null ? null : toWindow(planPercent), diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 0e6aaae68d9..a0be19112c7 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -14,7 +14,7 @@ import { getProviderDisplayName, getProviderUsageStatusLabel } from './tooltip' -import { getTightestUsageSection } from './UsageRosterPanel' +import { getTightestUsageSection, getUsageHeadlineSection } from './UsageRosterPanel' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' @@ -286,7 +286,7 @@ export function ProviderSegment({ ) } - const tightest = getTightestUsageSection(p) + const tightest = mode === 'compact' ? getUsageHeadlineSection(p) : getTightestUsageSection(p) // Fetching with no prior data if (p.status === 'fetching' && !tightest) { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index 6163f1ba548..88fcff689b6 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -5,6 +5,7 @@ import { SettingsSegmentedControl } from '@/components/settings/SettingsFormCont import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { translate } from '@/i18n/i18n' import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter' +import { CURSOR_MODELS_BUCKET_NAME } from '../../../../shared/cursor-usage-buckets' import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' import { clampUsedPercent, @@ -72,6 +73,16 @@ export function getTightestUsageSection(p: ProviderRateLimits): UsageSection | n return { ...tightest, label: shortLabel(p, tightest, true) } } +export function getUsageHeadlineSection(p: ProviderRateLimits): UsageSection | null { + if (p.provider === 'cursor') { + const primary = usedSections(p).find((section) => section.label === CURSOR_MODELS_BUCKET_NAME) + if (primary) { + return { ...primary, label: shortLabel(p, primary, true) } + } + } + return getTightestUsageSection(p) +} + // The soonest-resetting window summarizes the agent's next reset in one line. function soonestResetLabel(sections: UsageSection[], now: number): string | null { const resets = sections @@ -133,7 +144,7 @@ export function UsageRow({ const name = getProviderDisplayName(p.provider) const plan = formatPlanLabel(p.planType) const reset = hasUsage ? soonestResetLabel(sections, now) : null - const tightest = mode === 'compact' ? getTightestUsageSection(p) : null + const tightest = mode === 'compact' ? getUsageHeadlineSection(p) : null return (
@@ -282,7 +293,7 @@ export function UsageRosterPanel({ label: translate('auto.components.status.bar.UsageRosterPanel.compact', 'Compact'), tooltip: translate( 'auto.components.status.bar.UsageRosterPanel.compactTooltip', - 'Condensed usage: only the tightest window' + 'Condensed usage: one summary per provider' ) } ]} diff --git a/src/renderer/src/components/status-bar/cursor-usage-headline.test.tsx b/src/renderer/src/components/status-bar/cursor-usage-headline.test.tsx new file mode 100644 index 00000000000..4f65b6d546d --- /dev/null +++ b/src/renderer/src/components/status-bar/cursor-usage-headline.test.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { getUsageTone, ProviderSegment, UsageOverflowChip } from './StatusBarProviderSegment' +import { + getTightestUsageSection, + getUsageHeadlineSection, + UsageRosterPanel, + UsageRow +} from './UsageRosterPanel' +import { pickCollapsedUsageChips } from './status-bar-usage-collapse' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values: Record = {}) => + Object.entries(values).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +vi.mock('@/hooks/useResetCountdownClock', () => ({ useResetCountdownClock: () => 0 })) +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ children }: React.PropsWithChildren) =>
{children}
+})) +vi.mock('@/components/settings/SettingsFormControls', () => ({ + SettingsSegmentedControl: () => null +})) + +function windowAt(usedPercent: number): RateLimitWindow { + return { usedPercent, windowMinutes: 43_200, resetsAt: null, resetDescription: null } +} + +function cursorPools(primary: number, other: number, onDemand?: number): ProviderRateLimits { + return { + provider: 'cursor', + session: null, + weekly: null, + monthly: windowAt(12), + buckets: [ + { name: 'Cursor Models', ...windowAt(primary) }, + { name: 'Other Models', ...windowAt(other) }, + ...(onDemand === undefined ? [] : [{ name: 'On-demand', ...windowAt(onDemand) }]) + ], + updatedAt: 0, + error: null, + status: 'ok' + } +} + +describe('Cursor compact usage headline', () => { + it.each([0, 7, 41])('shows primary pool %s with the chosen percentage display', (primary) => { + const p = cursorPools(primary, 90, 100) + for (const display of ['used', 'remaining'] as const) { + const shown = display === 'used' ? primary : 100 - primary + const segment = renderToStaticMarkup( + + ) + expect(segment).toContain(`${shown}% ${display === 'used' ? 'used' : 'left'} Cursor Models`) + const row = renderToStaticMarkup( + + ) + expect(row).toContain('data-usage-window="Cursor Models"') + expect(row).toContain(`${shown}%`) + expect(row.match(/data-usage-window=/g)).toHaveLength(1) + } + }) + + it.each(['Other Models', 'On-demand'] as const)( + 'keeps exhausted %s urgent in overflow and collapse decisions', + (bucket) => { + const p = bucket === 'Other Models' ? cursorPools(7, 100) : cursorPools(0, 0, 100) + expect(getUsageTone(p)).toBe('urgent') + expect(getTightestUsageSection(p)?.label).toBe(bucket) + expect(getUsageHeadlineSection(p)?.label).toBe('Cursor Models') + const overflow = renderToStaticMarkup(