From bfdf02ee394e948e6b9c12c5df4560f082553e7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:01:27 -0700 Subject: [PATCH] fix: reconcile packaged terminal restoration --- src/main/index.ts | 5 + ...runtime-structured-session-restore.test.ts | 15 +- src/main/runtime/orca-runtime.ts | 21 ++- src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + src/renderer/src/App.tsx | 23 ++- src/renderer/src/app-startup-routing.test.ts | 34 ++++ src/renderer/src/components/Terminal.tsx | 57 +++--- .../components/terminal-pane/TerminalPane.tsx | 7 +- .../terminal-pane/pty-connection-types.ts | 2 + .../terminal-pane/pty-connection.test.ts | 28 +++ .../terminal-pane/pty-connection.ts | 5 + .../use-terminal-pane-lifecycle.ts | 4 + ...sume-sleeping-agent-session-replay.test.ts | 42 +++++ ...ent-session-launch-windows-quoting.test.ts | 8 +- .../src/lib/sleeping-agent-session-launch.ts | 44 ++--- .../worktree-agent-activation-gate.test.ts | 78 ++++++-- .../src/lib/worktree-agent-activation-gate.ts | 170 +++++++----------- .../worktree-agent-live-surface-adoption.ts | 65 +++++++ .../worktree-agent-structured-inventory.ts | 81 +++++++++ ...ivation-preserved-pane-replacement.test.ts | 1 + ...worktree-reactivation-tab-forkbomb.test.ts | 12 +- .../local-structured-session-tabs-sync.ts | 32 +++- src/renderer/src/store/slices/terminals.ts | 162 +++++++++-------- src/renderer/src/web/web-preload-api.ts | 1 + 25 files changed, 643 insertions(+), 258 deletions(-) create mode 100644 src/renderer/src/lib/worktree-agent-live-surface-adoption.ts create mode 100644 src/renderer/src/lib/worktree-agent-structured-inventory.ts diff --git a/src/main/index.ts b/src/main/index.ts index 20cd2e0a16c..228b506cfc3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -861,6 +861,11 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) + await runtime?.prepareStructuredAgentSessionStartupRestoration() +}) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => recoverLegacyWorkerTerminalsForRendererStartup({ firstWindowStartupServicesReady, diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 42d82056208..13d23426cc9 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -5,7 +5,7 @@ import { OrcaRuntimeService } from './orca-runtime' afterEach(() => setStructuredAgentSessionHost(null)) describe('structured session cold restoration', () => { - it('hydrates PTY inventory before recovering durable TUI owners and only runs once', async () => { + it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { const runtime = new OrcaRuntimeService() const hydrate = vi.fn() const refresh = vi.fn(async () => new Set()) @@ -29,8 +29,10 @@ describe('structured session cold restoration', () => { listSessionTabs: () => [] } as never) - await runtime.restoreStructuredAgentSessionTabs() - await runtime.restoreStructuredAgentSessionTabs() + const first = runtime.restoreStructuredAgentSessionTabs() + const second = runtime.restoreStructuredAgentSessionTabs() + expect(second).toBe(first) + await Promise.all([first, second]) expect(hydrate).toHaveBeenCalledWith('workspace-1', { allowAttachedWindow: true, @@ -39,8 +41,15 @@ describe('structured session cold restoration', () => { expect(hydrate).toHaveBeenCalledWith() expect(refresh).toHaveBeenCalledOnce() expect(restoreReadableSessions).toHaveBeenCalledOnce() + expect(ensureHost).toHaveBeenCalledOnce() + expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( + refresh.mock.invocationCallOrder[0] ?? Infinity + ) expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity ) + expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( + hydrate.mock.invocationCallOrder[0] ?? Infinity + ) }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a563ebe1719..6e9f953bf86 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2828,6 +2828,7 @@ export class OrcaRuntimeService { new TerminalFocusNavigationCoalescer() private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null private structuredAgentSessionTabRestorePromise: Promise | null = null + private structuredAgentSessionStartupRestorePromise: Promise | null = null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -9804,7 +9805,24 @@ export class OrcaRuntimeService { return this.structuredAgentSessionTabRestorePromise } + prepareStructuredAgentSessionStartupRestoration(): Promise { + this.structuredAgentSessionStartupRestorePromise ??= + this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { + this.structuredAgentSessionStartupRestorePromise = null + throw error + }) + return this.structuredAgentSessionStartupRestorePromise + } + + private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { + // Durable agent records must exist before daemon inventory can be reconciled against them. + await this.ensureStructuredAgentSessionHost() + await this.refreshMobileSessionPtyRecords() + await getStructuredAgentSessionHost()?.restoreReadableSessions() + } + private async restoreStructuredAgentSessionTabsOnce(): Promise { + await this.prepareStructuredAgentSessionStartupRestoration() for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -9812,10 +9830,7 @@ export class OrcaRuntimeService { }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - await this.refreshMobileSessionPtyRecords() - await this.ensureStructuredAgentSessionHost() const host = getStructuredAgentSessionHost() - await host?.restoreReadableSessions() for (const session of host?.listSessionTabs() ?? []) { this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4f41621d87b..d4a60884162 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -951,6 +951,8 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ + prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ recoverLegacyWorkerTerminalsForRendererStartup: () => Promise /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 18364e534b7..ed6e2b219dd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -525,6 +525,8 @@ const api = { }, awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + prepareTerminalStartupRestoration: (): Promise => + ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), startupDiagnostic: (event: string, details?: Record): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d205125700f..184813f6961 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -113,7 +113,10 @@ import { setRuntimeGraphSyncEnabled } from './runtime/sync-runtime-graph' import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync' -import { useLocalStructuredSessionTabsSync } from './runtime/local-structured-session-tabs-sync' +import { + restoreLocalStructuredSessionTabsOnce, + useLocalStructuredSessionTabsSync +} from './runtime/local-structured-session-tabs-sync' import { StructuredAgentSessionStatusBridge } from './components/native-chat/StructuredAgentSessionStatusBridge' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { MacosTccPromptNoticeHost } from './hooks/MacosTccPromptNoticeHost' @@ -493,6 +496,7 @@ function App(): React.JSX.Element { setSshConnectionState: s.setSshConnectionState, hydratePersistedUI: s.hydratePersistedUI, setHydrationSucceeded: s.setHydrationSucceeded, + setTerminalStartupRestorationReady: s.setTerminalStartupRestorationReady, openModal: s.openModal, closeModal: s.closeModal, markFeatureTipsSeen: s.markFeatureTipsSeen, @@ -1006,6 +1010,12 @@ function App(): React.JSX.Element { actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) }) + await timeRendererStartupStep('prepare-terminal-startup-restoration', () => + window.api.app.prepareTerminalStartupRestoration() + ) + if (cancelled) { + return + } // Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing. // See docs/cmd-j-empty-query-ordering.md. timeRendererStartupSyncStep('visit-timestamp-prune', () => { @@ -1115,15 +1125,25 @@ function App(): React.JSX.Element { await timeRendererStartupStep('reconnect-terminals', () => actions.reconnectPersistedTerminals(abortController.signal) ) + if (cancelled) { + return + } await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) + await timeRendererStartupStep('project-structured-session-tabs', () => + restoreLocalStructuredSessionTabsOnce() + ) + if (cancelled) { + return + } // Why here: reconnect just published restored PTY ids; sweeping them now // re-offers stale Codex panes whose tabs never mount this session. sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState()) syncZoomCSSVar() // Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk. actions.setHydrationSucceeded(true) + actions.setTerminalStartupRestorationReady(true) logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) @@ -1226,6 +1246,7 @@ function App(): React.JSX.Element { pendingReconnectPtyIdByTabId: {} }) } + actions.setTerminalStartupRestorationReady(true) } } void actions.initGitHubCache() diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index a6e3998694c..8214baae678 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -321,6 +321,40 @@ describe('renderer startup runtime routing', () => { expect(reconnectIndex).toBeGreaterThan(capabilityIndex) }) + it('orders packaged restoration before adoption, projection, and default creation', () => { + const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const terminalSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + 'utf8' + ) + const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") + const prepareIndex = appSource.indexOf( + "timeRendererStartupStep('prepare-terminal-startup-restoration'" + ) + const reconnectIndex = appSource.indexOf("timeRendererStartupStep('reconnect-terminals'") + const projectIndex = appSource.indexOf( + "timeRendererStartupStep('project-structured-session-tabs'" + ) + const readyIndex = appSource.indexOf('actions.setTerminalStartupRestorationReady(true)') + const gateStart = terminalSource.indexOf('const startupActivationGateWorktreeIdsRef') + const gateEnd = terminalSource.indexOf('const handleNewTab', gateStart) + const gateBlock = terminalSource.slice(gateStart, gateEnd) + const gateIndex = gateBlock.indexOf('gateWorktreeAgentActivation(activeWorktreeId)') + const createIndex = gateBlock.indexOf( + 'createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })' + ) + + expect(hydrateIndex).toBeGreaterThanOrEqual(0) + expect(hydrateIndex).toBeLessThan(prepareIndex) + expect(prepareIndex).toBeLessThan(reconnectIndex) + expect(reconnectIndex).toBeLessThan(projectIndex) + expect(projectIndex).toBeLessThan(readyIndex) + expect(gateBlock).toContain('terminalStartupRestorationReady') + expect(gateIndex).toBeGreaterThanOrEqual(0) + expect(gateIndex).toBeLessThan(createIndex) + expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome === 'blocked'") + }) + it('does not load the terminal workbench on the no-workspace landing path', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index de3f07547a1..26b3b71d07a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -342,6 +342,7 @@ function Terminal(): React.JSX.Element | null { const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const terminalStartupRestorationReady = useAppStore((s) => s.terminalStartupRestorationReady) const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded) const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted) const openFiles = useAppStore((s) => s.openFiles) @@ -1465,40 +1466,50 @@ function Terminal(): React.JSX.Element | null { ]) // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher. useEffect(() => () => disposeAllParkedTerminalWatchers(), []) - // Auto-create first tab when worktree activates + const startupActivationGateWorktreeIdsRef = useRef(new Set()) useEffect(() => { - if (!workspaceSessionReady) { - return - } - if (!activeWorktreeId) { + if ( + !workspaceSessionReady || + !terminalStartupRestorationReady || + !hydrationSucceeded || + !activeWorktreeId + ) { return } // Why: host session-tabs are authoritative in the paired web client; a local fallback races the host's initial terminal and duplicates tabs. if (isWebRuntimeSessionActive(getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId))) { return } - - // Why: give a newly activated worktree a focusable surface when nothing renders, without recreating one after the user closes the last visible tab. - const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) - if (!shouldAutoCreateInitialTerminal(renderableTabCount)) { - return - } - // Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps). - createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) - }, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel]) - - const startupActivationGateWorktreeIdsRef = useRef(new Set()) - useEffect(() => { - if (!workspaceSessionReady || !hydrationSucceeded || !activeWorktreeId) { - return - } if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) { return } startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId) - // Why: startup bypasses activateAndRevealWorktree, but must still adopt daemon PTYs before resuming persisted agents. - void gateWorktreeAgentActivation(activeWorktreeId) - }, [activeWorktreeId, hydrationSucceeded, workspaceSessionReady]) + let cancelled = false + void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => { + if ( + cancelled || + outcome === 'blocked' || + useAppStore.getState().activeWorktreeId !== activeWorktreeId + ) { + return + } + // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. + const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) + if (shouldAutoCreateInitialTerminal(renderableTabCount)) { + createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) + } + }) + return () => { + cancelled = true + } + }, [ + activeWorktreeId, + createTab, + hydrationSucceeded, + reconcileWorktreeTabModel, + terminalStartupRestorationReady, + workspaceSessionReady + ]) const handleNewTab = useCallback( (shellOverride?: string) => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 1c9c2cf1a61..d84585be399 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -763,11 +763,11 @@ function TerminalPane( () => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId] ) const consumeTabIssueCommandSplit = useAppStore((store) => store.consumeTabIssueCommandSplit) - useEffect(() => { + const settleTabStartupCommand = useCallback(() => { if (startup) { - consumeTabStartupCommand(tabId) + consumeTabStartupCommand(tabId, startup) } - }, [startup, tabId, consumeTabStartupCommand]) + }, [consumeTabStartupCommand, startup, tabId]) useLayoutEffect(() => { if (isVisible && shouldMeasureHiddenStartup) { @@ -1418,6 +1418,7 @@ function TerminalPane( setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound: settleTabStartupCommand, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 9c2f717b8a6..4a7f82e928c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -98,6 +98,8 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Settles the captured one-shot startup only after this pane owns a concrete PTY. */ + onStartupBound?: () => void deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void /** Records a DECSET 2031 subscription answered from main's * '2031-subscribe' fact, mirroring the xterm CSI handler's registry write diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index b5f5d09351a..819b09545ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4005,6 +4005,34 @@ describe('connectPanePty', () => { ) }) + it('settles a queued startup only after the pane binds its spawned PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-resume') + transportFactoryQueue.push(transport) + const onStartupBound = vi.fn() + const startup = { + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup, onStartupBound }) as never + ) + + expect(onStartupBound).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject(startup) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('pty-resume') + onPtySpawn?.('pty-resume') + + expect(onStartupBound).toHaveBeenCalledTimes(1) + }) + it('seeds a working status for Command Code startup prompts after spawn', async () => { const { connectPanePty } = await import('./pty-connection') const sshPtyId = toAppSshPtyId('ssh-a', 'pty-command-code') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4adab9421fe..d7831168068 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1202,6 +1202,7 @@ export function connectPanePty( // mutation does not propagate back. const paneStartup = deps.startup ?? null deps.startup = undefined + let startupPtyBound = false // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. // Use the stable layout leaf UUID, not the renderer-local numeric pane id. @@ -3023,6 +3024,10 @@ export function connectPanePty( deps.updateTabPtyId(deps.tabId, ptyId) } } + if (paneStartup && !startupPtyBound) { + startupPtyBound = true + deps.onStartupBound?.() + } if (options.seedInitialAgentStatus) { applyInitialAgentStatus() } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 4e23a47538e..1e7640da6f3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -317,6 +317,8 @@ type UseTerminalPaneLifecycleDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Settles the captured one-shot startup only after a pane owns a concrete PTY. */ + onStartupBound?: () => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setExpandedPane: (paneId: number | null) => void @@ -640,6 +642,7 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, @@ -871,6 +874,7 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound, deferPtyInput: (paneId, data, forward) => { const suppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression if (!suppression) { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts index 195fa721812..9b8cb76519c 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts @@ -34,6 +34,48 @@ function makeTerminalTab(id: string): Record { } describe('resumeSleepingAgentSessionsForWorktree replay protection', () => { + it('publishes the resume command and ownership claim with the first visible tab state', () => { + const record = makeRecord() + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + let firstVisible: + | { + command: string | undefined + claim: unknown + layout: ReturnType['terminalLayoutsByTabId'][string] + } + | undefined + const unsubscribe = useAppStore.subscribe((state) => { + const tab = state.tabsByWorktree['wt-1']?.[0] + if (tab && !firstVisible) { + firstVisible = { + command: state.pendingStartupByTabId[tab.id]?.command, + claim: state.automaticAgentResumeClaimsByTabId[tab.id], + layout: state.terminalLayoutsByTabId[tab.id] + } + } + }) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + unsubscribe() + + expect(firstVisible?.command).toContain('resume') + expect(firstVisible?.command).toContain(record.providerSession.id) + expect(firstVisible?.claim).toEqual({ + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }) + expect(firstVisible?.layout).toMatchObject({ + root: { type: 'leaf', leafId: expect.any(String) }, + activeLeafId: expect.any(String) + }) + const root = firstVisible?.layout?.root + expect(firstVisible?.layout?.activeLeafId).toBe(root?.type === 'leaf' ? root.leafId : undefined) + }) + it('stores provider-session metadata in the queued startup and runtime claim', () => { const record = makeRecord() useAppStore.setState({ diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index 9fe908050a0..59d54fbafb9 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' const mockCreateTab = vi.fn() -const mockQueueTabStartupCommand = vi.fn() const store = { settings: { @@ -47,7 +46,6 @@ const store = { browserTabsByWorktree: {} as Record, tabBarOrderByWorktree: {} as Record, createTab: mockCreateTab, - queueTabStartupCommand: mockQueueTabStartupCommand, claimAutomaticAgentResume: vi.fn(), clearSleepingAgentSession: vi.fn(), setActiveTabType: vi.fn(), @@ -83,10 +81,10 @@ const record: SleepingAgentSessionRecord = { async function launch(): Promise { const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch') launchSleepingAgentSession(record) - const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as - | { command: string } + const options = mockCreateTab.mock.calls.at(-1)?.[3] as + | { pendingStartup?: { command: string } } | undefined - return queued?.command + return options?.pendingStartup?.command } describe('launchSleepingAgentSession Windows shell quoting', () => { diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 6d5f0d5ded2..43d7bdb3b30 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -100,30 +100,30 @@ export function launchSleepingAgentSession( const tab = state.createTab(record.worktreeId, undefined, undefined, { launchAgent: record.agent, + pendingStartup: { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + resumeProviderSession: record.providerSession, + launchAgent: record.agent, + ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + showSessionRestoredBanner: true, + telemetry: { + agent_kind: tuiAgentToAgentKind(record.agent), + launch_source: 'sidebar', + request_kind: 'resume' + } + }, + automaticResumeClaim: { + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }, ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) }) - state.queueTabStartupCommand(tab.id, { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - resumeProviderSession: record.providerSession, - launchAgent: record.agent, - ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - showSessionRestoredBanner: true, - telemetry: { - agent_kind: tuiAgentToAgentKind(record.agent), - launch_source: 'sidebar', - request_kind: 'resume' - } - }) - state.claimAutomaticAgentResume(tab.id, { - worktreeId: record.worktreeId, - launchAgent: record.agent, - providerSession: record.providerSession - }) state.clearSleepingAgentSession(record.paneKey) if (!options?.suppressNavigation) { state.setActiveTabType('terminal') diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 1fcf20c3430..c09f8b5f57f 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { PtyListedSession } from '../../../shared/pty-listed-session' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { TerminalTab } from '../../../shared/types' +import type { TerminalSlice } from '@/store/slices/terminals' import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' const WORKTREE_ID = 'repo::/worktree' @@ -72,16 +74,41 @@ function testDeps(args: { structured?: boolean resumeCount?: number }) { - const createTab = vi.fn() const resume = vi.fn(() => args.resumeCount ?? 1) const sleeping = args.sleeping ?? [] const ptyIdsByTabId: Record = {} + const tabsByWorktree: Record = { [WORKTREE_ID]: [] } + let createdCount = 0 + const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => { + createdCount += 1 + const id = options?.id ?? `created-${createdCount}` + const tab: TerminalTab = { + id, + ptyId: options?.initialPtyId ?? null, + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: tabsByWorktree[worktreeId]?.length ?? 0, + createdAt: 1 + } + tabsByWorktree[worktreeId] ??= [] + tabsByWorktree[worktreeId].push(tab) + ptyIdsByTabId[tab.id] = tab.ptyId ? [tab.ptyId] : [] + return tab + }) + const updateTabPtyId = vi.fn((tabId: string, ptyId: string) => { + ptyIdsByTabId[tabId] = [...new Set([...(ptyIdsByTabId[tabId] ?? []), ptyId])] + }) const store = { createTab, ptyIdsByTabId, + tabsByWorktree, sleepingAgentSessionsByPaneKey: Object.fromEntries( sleeping.map((record) => [record.paneKey, record]) ), + updateTabPtyId, + replaceTerminalLayoutPanePtyId: vi.fn(), terminalLayoutsByTabId: Object.fromEntries( sleeping.map((record) => { const leafId = record.paneKey.slice(record.paneKey.indexOf(':') + 1) @@ -158,6 +185,18 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) + it('reads structured ownership before adopting daemon PTYs', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps } = testDeps({ sessions: [listed(ptyId)] }) + const hasStructuredSession = vi.fn(async () => false) + + await runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + + expect(hasStructuredSession.mock.invocationCallOrder[0]).toBeLessThan( + deps.listSessions.mock.invocationCallOrder[0] ?? Infinity + ) + }) + it('blocks automatic resume when packaged restore never becomes ready', async () => { const { deps, createTab, resume } = testDeps({}) @@ -262,11 +301,17 @@ describe('worktree agent activation gate', () => { }) }) - it('uses the exact runtime surface while packaged renderer PTY bindings are absent', async () => { + it('adopts the exact structured TUI surface without creating a duplicate tab', async () => { const tabId = 'structured-agent-session-live-session' const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` - const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(livePtyId)], + sleeping: [live], + resumeCount: 0 + }) + const ownerTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d' + const ownerLeafId = '2659ee80-d3fc-454f-b4ea-0638de1ae345' await expect( runWorktreeAgentActivationGate(WORKTREE_ID, { @@ -279,18 +324,25 @@ describe('worktree agent activation gate', () => { { owner: 'tui', terminal: { - paneKey: - '3359f7c8-9bd8-4931-8104-52b6bdbd108d:2659ee80-d3fc-454f-b4ea-0638de1ae345', + paneKey: `${ownerTabId}:${ownerLeafId}`, ptyId: livePtyId, - tabId: '3359f7c8-9bd8-4931-8104-52b6bdbd108d' + tabId: ownerTabId } } ] ]) }) }) - ).resolves.toBe('resumed') + ).resolves.toBe('adopted') + expect(createTab).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + id: ownerTabId, + initialLeafId: ownerLeafId, + initialPtyId: livePtyId, + activate: false, + recordInteraction: false + }) expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) }) @@ -307,7 +359,7 @@ describe('worktree agent activation gate', () => { expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) }) - it('does not claim a structured owner whose PTY is absent from live inventory', async () => { + it('blocks when a structured TUI owner is absent from live inventory', async () => { const tabId = 'structured-agent-session-live-session' const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` @@ -333,12 +385,12 @@ describe('worktree agent activation gate', () => { ]) }) }) - ).resolves.toBe('resumed') + ).resolves.toBe('blocked') - expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + expect(resume).not.toHaveBeenCalled() }) - it('does not claim an owner for a different structured session tab', async () => { + it('uses the restored owner surface even when its tab predates structured naming', async () => { const tabId = 'structured-agent-session-other-session' const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') const livePtyId = `${WORKTREE_ID}@@live-agent` @@ -362,7 +414,9 @@ describe('worktree agent activation gate', () => { }) ).resolves.toBe('resumed') - expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) }) it('suppresses the exact structured session when native already owns it', async () => { diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index d3951196e9f..49350a67549 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -1,7 +1,6 @@ import { useAppStore } from '@/store' import type { PtyListedSession } from '../../../shared/pty-listed-session' import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { parseWorkspaceKey } from '../../../shared/workspace-scope' @@ -10,27 +9,24 @@ import { type ResumeSleepingAgentSessionsOptions } from './resume-sleeping-agent-session' import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' +import { bindLivePtyToExactSurface } from './worktree-agent-live-surface-adoption' +import { + readWorktreeStructuredActivationInventory, + type StructuredActivationInventory +} from './worktree-agent-structured-inventory' type ActivationStore = Pick< ReturnType, | 'createTab' | 'ptyIdsByTabId' | 'sleepingAgentSessionsByPaneKey' + | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'unifiedTabsByWorktree' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' > -type StructuredActivationInventory = { - snapshot: RuntimeMobileSessionTabsResult - ownerBySessionId: ReadonlyMap< - string, - { - owner: 'native' | 'tui' - terminal?: { paneKey: string; ptyId: string; tabId: string } - } - > -} - type ActivationGateDeps = { getState: () => ActivationStore awaitReady?: () => Promise @@ -45,7 +41,11 @@ const inFlightByWorktreeId = new Map { - if (useAppStore.getState().workspaceSessionReady) { + const isReady = () => { + const state = useAppStore.getState() + return state.workspaceSessionReady && state.terminalStartupRestorationReady + } + if (isReady()) { return Promise.resolve(true) } return new Promise((resolve) => { @@ -55,16 +55,13 @@ function waitForWorkspaceSessionReady(): Promise { unsubscribe?.() resolve(ready) } - const timeout = setTimeout( - () => settle(useAppStore.getState().workspaceSessionReady), - WORKSPACE_SESSION_READY_TIMEOUT_MS - ) + const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS) unsubscribe = useAppStore.subscribe((state) => { - if (state.workspaceSessionReady) { + if (state.workspaceSessionReady && state.terminalStartupRestorationReady) { settle(true) } }) - if (useAppStore.getState().workspaceSessionReady) { + if (isReady()) { settle(true) } }) @@ -152,6 +149,42 @@ export async function runWorktreeAgentActivationGate( } catch { return 'blocked' } + let structured = false + let structuredInventory: StructuredActivationInventory | null = null + try { + const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId) + structuredInventory = + typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null + structured = Boolean( + hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession + ) + } catch { + return 'blocked' + } + + const structuredTabs = structuredInventory?.snapshot.tabs.filter( + (tab) => tab.type === 'agent-session' + ) + if ( + structuredTabs?.some((tab) => { + const owner = structuredInventory?.ownerBySessionId.get(tab.sessionId) + return ( + !owner || + (owner.owner === 'tui' && + (!owner.terminal || parsePaneKey(owner.terminal.paneKey)?.tabId !== owner.terminal.tabId)) + ) + }) + ) { + return 'blocked' + } + if ( + structured && + !structuredInventory && + workspaceHasSleepingAgentSessions(deps.getState(), worktreeId) + ) { + return 'blocked' + } + let sessions: PtyListedSession[] try { sessions = await deps.listSessions() @@ -164,6 +197,18 @@ export async function runWorktreeAgentActivationGate( sessionBelongsToWorkspace(session.id, worktreeId) ) const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id)) + for (const owner of structuredInventory?.ownerBySessionId.values() ?? []) { + if (owner.owner !== 'tui') { + continue + } + if ( + !owner.terminal || + !liveWorkspacePtyIds.has(owner.terminal.ptyId) || + !bindLivePtyToExactSurface(deps.getState(), worktreeId, owner.terminal) + ) { + return 'blocked' + } + } if (liveWorkspaceSessions.length > 0) { for (const session of liveWorkspaceSessions) { const store = deps.getState() @@ -181,20 +226,8 @@ export async function runWorktreeAgentActivationGate( } } - let structured = false - let structuredInventory: StructuredActivationInventory | null = null - try { - const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId) - structuredInventory = - typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null - structured = Boolean( - hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession - ) - if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { - return 'structured' - } - } catch { - return 'blocked' + if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { + return 'structured' } const launched = deps.resume(worktreeId, { skipClaimKeys: liveSleepingAgentClaimKeys( @@ -225,76 +258,7 @@ export function gateWorktreeAgentActivation( awaitReady: waitForWorkspaceSessionReady, listSessions: () => typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(), - hasStructuredSession: async (candidate) => { - if (typeof window === 'undefined') { - return false - } - const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }) - if (!response.ok) { - throw new Error('structured session inventory unavailable') - } - const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } - const snapshot = (result.snapshots ?? []).find( - (candidateSnapshot) => - candidateSnapshot.worktree === candidate && - candidateSnapshot.tabs.some((tab) => tab.type === 'agent-session') - ) - if (!snapshot) { - return false - } - const ownerBySessionId = new Map< - string, - { - owner: 'native' | 'tui' - terminal?: { paneKey: string; ptyId: string; tabId: string } - } - >() - await Promise.all( - snapshot.tabs.flatMap((tab) => - tab.type === 'agent-session' - ? [ - window.api.runtime - .call({ - method: 'agentSession.handoffStatus', - params: { sessionId: tab.sessionId } - }) - .then((statusResponse) => { - if (!statusResponse.ok) { - return - } - const status = statusResponse.result as { - owner?: unknown - terminal?: { - paneKey?: unknown - ptyId?: unknown - tabId?: unknown - } - } - if (status.owner === 'native') { - ownerBySessionId.set(tab.sessionId, { owner: 'native' }) - } else if ( - status.owner === 'tui' && - typeof status.terminal?.paneKey === 'string' && - typeof status.terminal?.ptyId === 'string' && - status.terminal.ptyId.length > 0 && - typeof status.terminal.tabId === 'string' - ) { - ownerBySessionId.set(tab.sessionId, { - owner: 'tui', - terminal: { - paneKey: status.terminal.paneKey, - ptyId: status.terminal.ptyId, - tabId: status.terminal.tabId - } - }) - } - }) - ] - : [] - ) - ) - return { snapshot, ownerBySessionId } - }, + hasStructuredSession: readWorktreeStructuredActivationInventory, resume: resumeSleepingAgentSessionsForWorktree }).finally(() => { if (inFlightByWorktreeId.get(worktreeId) === gate) { diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts new file mode 100644 index 00000000000..df95951ed76 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -0,0 +1,65 @@ +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { useAppStore } from '@/store' + +type LiveSurfaceAdoptionStore = Pick< + ReturnType, + | 'createTab' + | 'ptyIdsByTabId' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' +> + +function layoutContainsLeaf( + root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'], + leafId: string +): boolean { + if (!root) { + return false + } + return root.type === 'leaf' + ? root.leafId === leafId + : layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId) +} + +export function bindLivePtyToExactSurface( + store: LiveSurfaceAdoptionStore, + worktreeId: string, + terminal: { paneKey: string; ptyId: string; tabId: string } +): boolean { + const pane = parsePaneKey(terminal.paneKey) + if (!pane || pane.tabId !== terminal.tabId) { + return false + } + const ownerEntries = Object.entries(store.tabsByWorktree).flatMap(([ownerWorktreeId, tabs]) => + tabs.filter((tab) => tab.id === terminal.tabId).map((tab) => ({ ownerWorktreeId, tab })) + ) + const competingBinding = Object.entries(store.ptyIdsByTabId).some( + ([tabId, ptyIds]) => tabId !== terminal.tabId && ptyIds.includes(terminal.ptyId) + ) + if (ownerEntries.length > 1 || competingBinding) { + return false + } + const existing = ownerEntries[0] + if (existing) { + const layout = store.terminalLayoutsByTabId[terminal.tabId] + if ( + existing.ownerWorktreeId !== worktreeId || + !layoutContainsLeaf(layout?.root ?? null, pane.leafId) + ) { + return false + } + store.updateTabPtyId(terminal.tabId, terminal.ptyId) + store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId) + return true + } + const created = store.createTab(worktreeId, undefined, undefined, { + id: terminal.tabId, + initialLeafId: pane.leafId, + initialPtyId: terminal.ptyId, + activate: false, + recordInteraction: false + }) + return created.id === terminal.tabId +} diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.ts new file mode 100644 index 00000000000..940ffee9154 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-structured-inventory.ts @@ -0,0 +1,81 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' + +export type StructuredActivationInventory = { + snapshot: RuntimeMobileSessionTabsResult + ownerBySessionId: ReadonlyMap< + string, + { + owner: 'native' | 'tui' + terminal?: { paneKey: string; ptyId: string; tabId: string } + } + > +} + +export async function readWorktreeStructuredActivationInventory( + worktreeId: string +): Promise { + if (typeof window === 'undefined') { + return false + } + const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }) + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshot = (result.snapshots ?? []).find( + (candidate) => + candidate.worktree === worktreeId && + candidate.tabs.some((tab) => tab.type === 'agent-session') + ) + if (!snapshot) { + return false + } + const ownerBySessionId = new Map< + string, + { + owner: 'native' | 'tui' + terminal?: { paneKey: string; ptyId: string; tabId: string } + } + >() + await Promise.all( + snapshot.tabs.flatMap((tab) => + tab.type === 'agent-session' + ? [ + window.api.runtime + .call({ + method: 'agentSession.handoffStatus', + params: { sessionId: tab.sessionId } + }) + .then((statusResponse) => { + if (!statusResponse.ok) { + return + } + const status = statusResponse.result as { + owner?: unknown + terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown } + } + if (status.owner === 'native') { + ownerBySessionId.set(tab.sessionId, { owner: 'native' }) + } else if ( + status.owner === 'tui' && + typeof status.terminal?.paneKey === 'string' && + typeof status.terminal?.ptyId === 'string' && + status.terminal.ptyId.length > 0 && + typeof status.terminal.tabId === 'string' + ) { + ownerBySessionId.set(tab.sessionId, { + owner: 'tui', + terminal: { + paneKey: status.terminal.paneKey, + ptyId: status.terminal.ptyId, + tabId: status.terminal.tabId + } + }) + } + }) + ] + : [] + ) + ) + return { snapshot, ownerBySessionId } +} diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index 027661ec7e6..9d3e915a0bd 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -31,6 +31,7 @@ function baseState(worktree: ReturnType): Partial activeRepoId: 'repo-1', activeView: 'terminal', workspaceSessionReady: true, + terminalStartupRestorationReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index 667a7dff132..78095a915b3 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -22,6 +22,7 @@ function baseState(worktree: ReturnType): Partial activeRepoId: 'repo-1', activeView: 'terminal', workspaceSessionReady: true, + terminalStartupRestorationReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -57,7 +58,11 @@ afterEach(() => { describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { it('defers packaged-startup resume until restored PTYs finish reconnecting', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } - useAppStore.setState({ ...baseState(worktree), workspaceSessionReady: false }) + useAppStore.setState({ + ...baseState(worktree), + workspaceSessionReady: false, + terminalStartupRestorationReady: false + }) const paneKey = 'packaged-restart-pane:0' useAppStore.setState({ sleepingAgentSessionsByPaneKey: { @@ -96,7 +101,10 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { await Promise.resolve() expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) - useAppStore.setState({ workspaceSessionReady: true }) + useAppStore.setState({ + workspaceSessionReady: true, + terminalStartupRestorationReady: true + }) await gate const restored = useAppStore.getState() expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 2ff5b0a0582..cccdfe945c2 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -5,6 +5,7 @@ import { useAppStore } from '../store' import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync' const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' +let localStructuredSessionTabsRestorePromise: Promise | null = null type SessionTabsEvent = | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) @@ -54,6 +55,23 @@ export function applyStructuredSessionTabSnapshots( }) } +export function restoreLocalStructuredSessionTabsOnce(): Promise { + localStructuredSessionTabsRestorePromise ??= window.api.runtime + .call({ method: 'session.tabs.listAll', params: {} }) + .then((response) => { + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + applyStructuredSessionTabSnapshots(result.snapshots ?? []) + }) + .catch((error) => { + localStructuredSessionTabsRestorePromise = null + throw error + }) + return localStructuredSessionTabsRestorePromise +} + async function startLocalStructuredSessionTabsSync(args: { isDisposed: () => boolean setUnsubscribe: (unsubscribe: () => void) => void @@ -63,12 +81,10 @@ async function startLocalStructuredSessionTabsSync(args: { return } const supported = status.capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - void window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }).then((response) => { - if (!args.isDisposed() && response.ok) { - const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } - applyStructuredSessionTabSnapshots(result.snapshots ?? []) - } - }) + await restoreLocalStructuredSessionTabsOnce() + if (args.isDisposed()) { + return + } if (!supported) { return } @@ -94,7 +110,9 @@ async function startLocalStructuredSessionTabsSync(args: { } export function useLocalStructuredSessionTabsSync(): void { - const ready = useAppStore((state) => state.workspaceSessionReady) + const ready = useAppStore( + (state) => state.workspaceSessionReady && state.terminalStartupRestorationReady + ) useEffect(() => { if (!ready) { return diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 89fc0657479..f15ddc21160 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -57,7 +57,11 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output- import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' -import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' +import { + clearTransientTerminalState, + emptyLayoutSnapshot, + singlePaneLayoutSnapshot +} from './terminal-helpers' import { collectReleasedLeafIds, hydrateWorkspaceTerminalRows, @@ -529,6 +533,40 @@ export type AutomaticAgentResumeClaim = { providerSession: AgentProviderSessionMetadata } +type TabStartupCommand = { + command: string + /** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */ + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + /** Initial prompt-start status for agents that lack native prompt hooks. */ + initialAgentStatus?: { agent: TuiAgent; prompt: string } + /** Show the restored-session banner when this startup command mounts. */ + showSessionRestoredBanner?: boolean + /** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */ + telemetry?: AgentStartedTelemetry +} + +function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand { + // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. + const launchToken = startup.launchConfig + ? (startup.launchToken ?? createBrowserUuid()) + : undefined + return { + ...startup, + ...(launchToken ? { launchToken } : {}) + } +} + export type CodexRestartNotice = { previousAccountLabel: string nextAccountLabel: string @@ -595,31 +633,7 @@ export type TerminalSlice = { resolution: Pick ) => void clearNativeChatLaunchDraft: (tabId: string) => void - pendingStartupByTabId: Record< - string, - { - command: string - /** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */ - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - /** Initial prompt-start status for agents that lack native prompt hooks. */ - initialAgentStatus?: { agent: TuiAgent; prompt: string } - /** Show the restored-session banner when this startup command mounts. */ - showSessionRestoredBanner?: boolean - /** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */ - telemetry?: AgentStartedTelemetry - } - > + pendingStartupByTabId: Record pendingInitialCwdByTabId: Record /** Queued setup-split requests; TerminalPane splits and runs the command in a new pane so the main terminal stays immediately interactive. */ pendingSetupSplitByTabId: Record< @@ -630,6 +644,9 @@ export type TerminalSlice = { pendingIssueCommandSplitByTabId: Record }> tabBarOrderByWorktree: Record workspaceSessionReady: boolean + /** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */ + terminalStartupRestorationReady: boolean + setTerminalStartupRestorationReady: (value: boolean) => void restoredRuntimeHostIdByWorkspaceSessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record markDefaultTerminalTabsApplied: (worktreeId: string) => void @@ -660,6 +677,12 @@ export type TerminalSlice = { options?: { pendingActivationSpawn?: boolean initialPtyId?: string + /** Stable leaf identity for adopting an already-live pane without changing its pane key. */ + initialLeafId?: string + /** Published atomically with the tab so its first mount cannot spawn a bare shell. */ + pendingStartup?: TabStartupCommand + /** Published atomically with pendingStartup for automatic resume ownership. */ + automaticResumeClaim?: AutomaticAgentResumeClaim activate?: boolean recordInteraction?: boolean /** Pre-allocated tab id (main mints it for CLI/runtime PTYs with a baked pane key); minted fresh on omit or cross-worktree collision. */ @@ -776,45 +799,13 @@ export type TerminalSlice = { sourceTabId: string targetTabId: string }) => void - queueTabStartupCommand: ( - tabId: string, - startup: { - command: string - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - initialAgentStatus?: { agent: TuiAgent; prompt: string } - showSessionRestoredBanner?: boolean - telemetry?: AgentStartedTelemetry - } - ) => void + queueTabStartupCommand: (tabId: string, startup: TabStartupCommand) => void queueTabInitialCwd: (tabId: string, cwd: string) => void consumeTabInitialCwd: (tabId: string) => string | null - consumeTabStartupCommand: (tabId: string) => { - command: string - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - initialAgentStatus?: { agent: TuiAgent; prompt: string } - showSessionRestoredBanner?: boolean - telemetry?: AgentStartedTelemetry - } | null + consumeTabStartupCommand: ( + tabId: string, + expected?: TabStartupCommand + ) => TabStartupCommand | null queueTabSetupSplit: ( tabId: string, startup: { command: string; env?: Record; direction: SetupSplitDirection } @@ -1106,6 +1097,10 @@ export const createTerminalSlice: StateCreator nativeChatLaunchDraftByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, + terminalStartupRestorationReady: false, + setTerminalStartupRestorationReady: (value) => { + set({ terminalStartupRestorationReady: value }) + }, restoredRuntimeHostIdByWorkspaceSessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, markDefaultTerminalTabsApplied: (worktreeId) => @@ -1361,6 +1356,15 @@ export const createTerminalSlice: StateCreator ) } const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() + const requestedInitialLeafId = + options?.initialLeafId && isTerminalLeafId(options.initialLeafId) + ? options.initialLeafId + : undefined + // Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation. + const initialLeafId = + options?.initialPtyId || options?.pendingStartup + ? (requestedInitialLeafId ?? createBrowserUuid()) + : undefined const shouldActivate = options?.activate !== false const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` @@ -1522,9 +1526,23 @@ export const createTerminalSlice: StateCreator ...orphanCleanupPatch.ptyIdsByTabId, [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] }, + pendingStartupByTabId: options?.pendingStartup + ? { + ...orphanCleanupPatch.pendingStartupByTabId, + [tab.id]: normalizeTabStartupCommand(options.pendingStartup) + } + : orphanCleanupPatch.pendingStartupByTabId, + automaticAgentResumeClaimsByTabId: options?.automaticResumeClaim + ? { + ...orphanCleanupPatch.automaticAgentResumeClaimsByTabId, + [tab.id]: options.automaticResumeClaim + } + : orphanCleanupPatch.automaticAgentResumeClaimsByTabId, terminalLayoutsByTabId: { ...orphanCleanupPatch.terminalLayoutsByTabId, - [tab.id]: emptyLayoutSnapshot() + [tab.id]: initialLeafId + ? singlePaneLayoutSnapshot(initialLeafId, options?.initialPtyId) + : emptyLayoutSnapshot() } } }) @@ -3795,17 +3813,10 @@ export const createTerminalSlice: StateCreator }, queueTabStartupCommand: (tabId, startup) => { - // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. - const launchToken = startup.launchConfig - ? (startup.launchToken ?? createBrowserUuid()) - : undefined set((s) => ({ pendingStartupByTabId: { ...s.pendingStartupByTabId, - [tabId]: { - ...startup, - ...(launchToken ? { launchToken } : {}) - } + [tabId]: normalizeTabStartupCommand(startup) } })) }, @@ -3832,13 +3843,16 @@ export const createTerminalSlice: StateCreator return pending }, - consumeTabStartupCommand: (tabId) => { + consumeTabStartupCommand: (tabId, expected) => { const pending = get().pendingStartupByTabId[tabId] - if (!pending) { + if (!pending || (expected && pending !== expected)) { return null } set((s) => { + if (s.pendingStartupByTabId[tabId] !== pending) { + return {} + } const next = { ...s.pendingStartupByTabId } delete next[tabId] return { pendingStartupByTabId: next } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 3c20f83d519..f8bc4af065d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -571,6 +571,7 @@ function createWebPreloadApi(): Partial { // Staging already wrote through to browser storage, so there is nothing left to join. awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), + prepareTerminalStartupRestoration: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null),