From 1b0febc4cce798334654e519ef827bc1fdc28cb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:05:32 -0700 Subject: [PATCH] Wake slept agents when opening a worktree on mobile (#7906) Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 84 ++++++++++++++ src/main/runtime/orca-runtime.ts | 14 ++- src/main/runtime/rpc/methods/worktree.test.ts | 27 ++++- src/main/runtime/rpc/methods/worktree.ts | 7 +- .../window/attach-main-window-services.ts | 1 + src/preload/api-types.ts | 1 + src/preload/index.ts | 6 + .../terminal-pane/pty-connection.test.ts | 58 ++++++++++ .../terminal-pane/pty-connection.ts | 10 ++ .../use-terminal-pane-lifecycle.ts | 37 ++++++- src/renderer/src/constants/terminal.ts | 11 ++ src/renderer/src/hooks/useIpcEvents.test.ts | 11 ++ src/renderer/src/hooks/useIpcEvents.ts | 9 ++ ...-agent-session-suppress-navigation.test.ts | 87 +++++++++++++++ .../src/lib/resume-sleeping-agent-session.ts | 21 +++- ...wake-sleeping-agents-in-background.test.ts | 103 ++++++++++++++++++ .../lib/wake-sleeping-agents-in-background.ts | 57 ++++++++++ src/renderer/src/web/web-preload-api.ts | 3 + 18 files changed, 532 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts create mode 100644 src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts create mode 100644 src/renderer/src/lib/wake-sleeping-agents-in-background.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cf0871979..7cf71bea9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14772,6 +14772,90 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).not.toHaveBeenCalled() }) + it('wakes slept agents on the host renderer when a phone activates a worktree', async () => { + // Seed isUnread:false so the unread-clear branch stays quiet and the + // assertions isolate the mobile slept-agent wake. + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const activateWorktree = vi.fn() + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // A renderer must be attached to receive the wake (headless serve is a + // deliberate non-goal — no renderer, no wake). + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + // INV-2: mobile wake never navigates the desktop (no activateWorktree); it + // routes exclusively through the renderer's own navigation-free wake. + expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(activateWorktree).not.toHaveBeenCalled() + }) + + it('does not wake slept agents for non-mobile session-only activation', async () => { + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + // INV-3: web/desktop runtime clients keep their existing wake-on-activation + // paths untouched — the renderer notifier wake is mobile-scoped. + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => { // Why: seed instanceId so worktree resolution does not emit its own // metadata-stamp write, isolating the assertion to the unread clear. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6bf085ff8..9681ea33e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1293,6 +1293,11 @@ type RuntimeNotifier = { ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void sleepWorktree(worktreeId: string): void + // Why: a phone opening a worktree wakes its slept agents by asking the host + // renderer to run its own navigation-free wake (experimental agent sleep); + // the runtime has no in-memory sleeping records or wake authority. Optional to + // match the many renderer-backed notifier methods only the real bridge wires. + resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, mode: 'mobile-fit' | 'desktop-fit', @@ -12587,7 +12592,7 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean } = {} + opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} ): Promise<{ repoId: string worktreeId: string @@ -12619,6 +12624,13 @@ export class OrcaRuntimeService { }) await this.refreshMobileSessionPtyRecords() this.notifyMobileSessionTabsChanged(worktree.id) + // Why: a phone open must also wake the worktree's slept agents (experimental + // agent sleep). Only the host renderer holds the sleeping records + wake + // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are + // unaffected, and only when a renderer is attached to receive it. + if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + } } return { repoId: repo.id, worktreeId: worktree.id, activated: true } } diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 60836205e..ee4e01102 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -38,7 +38,32 @@ describe('worktree RPC methods', () => { expect(response).toMatchObject({ ok: true }) expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { - notifyClients: false + notifyClients: false, + clientKind: undefined + }) + }) + + it('forwards the mobile clientKind to the runtime on session-only activation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateManagedWorktree: vi + .fn() + .mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + // The mobile WebSocket path always uses dispatchStreaming, which threads the + // authenticated device scope as clientKind even for non-streaming methods. + const replies: string[] = [] + await dispatcher.dispatchStreaming( + makeRequest('worktree.activate', { worktree: 'id:wt-1', notifyClients: false }), + (response) => replies.push(response), + { clientKind: 'mobile' } + ) + + expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { + notifyClients: false, + clientKind: 'mobile' }) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index a95d6ebf0..f84a26d1d 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -59,9 +59,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.activate', params: WorktreeActivate, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind }) => + // Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent + // wake to phones so web/desktop activation behavior is unchanged. runtime.activateManagedWorktree(params.worktree, { - notifyClients: params.notifyClients !== false + notifyClients: params.notifyClients !== false, + clientKind }) }), defineMethod({ diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 2d34a4c9f..cc03c40a9 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -389,6 +389,7 @@ function registerRuntimeWindowLifecycle( }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5c3bea5e5..4ec3557be 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2750,6 +2750,7 @@ export type PreloadApi = { callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void onSystemResumed: (callback: () => void) => () => void readClipboardText: (options?: ReadClipboardTextOptions) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 39756074c..d7385f18f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3478,6 +3478,12 @@ const api = { ipcRenderer.on('ui:sleepWorktree', listener) return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) 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 ce885163f..63a28b781 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1872,6 +1872,64 @@ describe('connectPanePty', () => { expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) }) + it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => { + // Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane + // (no isVisible flip): the armed cold-restore --resume must fire exactly once + // even when the wake is delivered twice (INV-1 idempotency). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-bg' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + // Still hidden: no reveal happened, so nothing respawned on exit. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg') + + // A second navigation-free wake must not spawn again (one-pane/one-PTY). + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { // Race: the user reveals the background tab in the window between the // coordinator confirming the candidate and the kill's exit arriving. The diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2d534757a..df753a471 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -673,6 +673,11 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void + /** Navigation-free hibernation wake: fires the armed cold-restore --resume + * without the size-reassert/foreground-sample side effects of a real reveal. + * Used by the mobile wake fanout so a hidden hibernated pane resumes with no + * desktop hidden→visible transition. */ + wakeHibernatedAgentIfArmed: () => void /** Re-sample process identity when the pane gains intra-tab focus: the tab * icon follows the active leaf, and a shell-marked entry on a still-running * agent pane has no OSC boundary left to correct it. */ @@ -6244,6 +6249,11 @@ export function connectPanePty( consumeHibernatedAgentWake() sampleVisiblePaneForegroundAgent() }, + // Why: mobile wake reaches this pane while it stays hidden on the desktop, so + // it must consume only the armed hibernation wake — no size/foreground reads. + wakeHibernatedAgentIfArmed() { + consumeHibernatedAgentWake() + }, sampleForegroundAgentOnFocus() { sampleVisiblePaneForegroundAgent() }, 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 e342778ee..4f9d93ed9 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 @@ -113,8 +113,10 @@ import { import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, type SplitTerminalPaneDetail, - type CloseTerminalPaneDetail + type CloseTerminalPaneDetail, + type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' @@ -881,11 +883,12 @@ export function useTerminalPaneLifecycle({ imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { const now = Date.now() - const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( - e, - pendingTerminalImeCandidateKeyReleases, - now - ) + const pendingCandidateReleaseGuardActive = + shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) const imeKeyboardOptions = { compositionActive: imeCompositionTracker.isActive(), candidateKeyGuardActive: @@ -1746,6 +1749,28 @@ export function useTerminalPaneLifecycle({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tabId, cwd]) + // Why: mobile wake fanout — this pane self-selects by worktreeId and fires its + // own armed hibernation --resume while staying hidden on the desktop (no + // reveal, no focus/navigation change). Not-yet-mounted panes are covered by + // the background-mount fresh-connect cold-restore path instead. + useEffect(() => { + const onWakeHibernatedAgents = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.worktreeId !== worktreeId) { + return + } + for (const panePtyBinding of panePtyBindingsRef.current.values()) { + ;( + panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void } + ).wakeHibernatedAgentIfArmed?.() + } + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + return () => { + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + } + }, [worktreeId, panePtyBindingsRef]) + useEffect(() => { const previousIsVisible = getPreviousVisibleForTerminalPane({ previous: previousVisibleForReconcileRef.current, diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 0baa8a777..ed1a89aa6 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -8,6 +8,13 @@ export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-ter export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane' export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree' +// Why: mobile wake (experimental agent sleep) must fire the cold-restore +// --resume of a worktree's mounted hidden hibernated panes without a desktop +// hidden→visible reveal. Each mounted TerminalPane self-selects on this event +// by worktreeId and invokes its own armed hibernation wake — a fanout, since +// pane bindings are per-instance with no global registry. +export const WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT = 'orca-wake-hibernated-agents-worktree' + // Why: sidebar open/close is an instantaneous width change. If we wait for // the ResizeObserver rAF (and the 150ms debounced global fit) to catch up, // the user sees the terminal in a wrongly-fit state for ~16ms+ then a snap @@ -68,3 +75,7 @@ export type CloseTerminalPaneDetail = { export type BackgroundMountTerminalWorktreeDetail = { worktreeId: string } + +export type WakeHibernatedAgentsWorktreeDetail = { + worktreeId: string +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0623acbba..c4c51907c 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -978,6 +978,7 @@ describe('useIpcEvents browser tab create routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: ( @@ -1198,6 +1199,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -1441,6 +1443,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -1928,6 +1931,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -2779,6 +2783,7 @@ describe('useIpcEvents browser tab close routing', () => { return () => {} }, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3257,6 +3262,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3473,6 +3479,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3684,6 +3691,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3922,6 +3930,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -4168,6 +4177,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -4400,6 +4410,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e318b7120..13202f13a 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -9,6 +9,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { wakeSleepingAgentsForWorktreeInBackground } from '@/lib/wake-sleeping-agents-in-background' import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, @@ -1942,6 +1943,14 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { + // Why: a phone opened this worktree; wake its slept agents on the host + // renderer navigation-free (no desktop worktree/tab/view change). + wakeSleepingAgentsForWorktreeInBackground(worktreeId) + }) + ) + // Hydrate initial update status then subscribe to changes window.api.updater.getStatus().then((status) => { useAppStore.getState().setUpdateStatus(status as UpdateStatus) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts new file mode 100644 index 000000000..bc5affb0c --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +function makeRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeTerminalTab(id: string, worktreeId: string): Record { + return { + id, + ptyId: null, + worktreeId, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('resumeSleepingAgentSessionsForWorktree navigation suppression', () => { + it('resumes without navigating the desktop when navigation is suppressed', () => { + // Mobile-scoped wake: the desktop sits on a different worktree/view. The + // resume must spawn the recovery tab without changing the active surface. + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-other', + activeTabId: 'other-tab', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-other': 'other-tab' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1', { suppressNavigation: true }) + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + // A resume tab is created for the slept worktree... + expect(resumedTab?.launchAgent).toBe('claude') + // ...but the desktop's active worktree/tab/view are untouched (INV-2). + expect(state.activeWorktreeId).toBe('wt-other') + expect(state.activeTabId).toBe('other-tab') + expect(state.activeTabType).toBe('browser') + }) + + it('still navigates to the resumed tab for default (desktop) callers', () => { + // Regression guard: the suppress-navigation flag must be opt-in — desktop + // resume keeps flipping the active view to the recovered terminal. + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabId: 'tab-1', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-1': 'tab-1' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(useAppStore.getState().activeTabType).toBe('terminal') + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 3579ca804..ba283ab37 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -57,7 +57,12 @@ function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { state.setTabBarOrder(worktreeId, order) } -function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean { +// Why: mobile-driven wake runs on the desktop host renderer, so it must create +// the resume tab without stealing the desktop's active worktree/tab/view. +function launchSleepingAgentSession( + record: SleepingAgentSessionRecord, + options?: { suppressNavigation?: boolean } +): boolean { const state = useAppStore.getState() const launchConfig = record.launchConfig const startupPlan = buildAgentResumeStartupPlan({ @@ -86,7 +91,8 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean } const tab = state.createTab(record.worktreeId, undefined, undefined, { - launchAgent: record.agent + launchAgent: record.agent, + ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) }) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, @@ -110,7 +116,9 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean providerSession: record.providerSession }) state.clearSleepingAgentSession(record.paneKey) - state.setActiveTabType('terminal') + if (!options?.suppressNavigation) { + state.setActiveTabType('terminal') + } appendTabToWorktreeOrder(record.worktreeId, tab.id) return true } @@ -239,7 +247,10 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): ) } -export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { +export function resumeSleepingAgentSessionsForWorktree( + worktreeId: string, + options?: { suppressNavigation?: boolean } +): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) .filter((record) => record.worktreeId === worktreeId) @@ -298,7 +309,7 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb if (isPaneOwned) { continue } - if (launchSleepingAgentSession(record)) { + if (launchSleepingAgentSession(record, options)) { launched += 1 freshlyLaunchedClaimKeys.add(claimKey) clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts new file mode 100644 index 000000000..4fbe281df --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT +} from '@/constants/terminal' + +const resumeSpy = vi.fn() +vi.mock('./resume-sleeping-agent-session', () => ({ + resumeSleepingAgentSessionsForWorktree: (worktreeId: string, options?: unknown) => + resumeSpy(worktreeId, options) +})) + +// Why: control passive-vs-non-passive classification directly so the test asserts +// the gating, not the predicate internals. +const isPassiveSpy = vi.fn() +vi.mock('./sleeping-agent-pane-ownership', () => ({ + isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record) +})) + +let sleepingRecords: Record = {} +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ sleepingAgentSessionsByPaneKey: sleepingRecords }) + } +})) + +import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' + +function recordEvents(): { events: string[]; stop: () => void } { + const events: string[] = [] + const onWake = (event: Event): void => { + events.push(`wake:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + } + const onMount = (event: Event): void => { + events.push(`mount:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + return { + events, + stop: () => { + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + } + } +} + +beforeEach(() => { + sleepingRecords = {} + isPassiveSpy.mockReset() + resumeSpy.mockReset() +}) + +afterEach(() => { + resumeSpy.mockReset() +}) + +describe('wakeSleepingAgentsForWorktreeInBackground', () => { + it('fires wake, background-mount, then resume when a passive record exists', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1' } } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // (a) pane-level wake of mounted hidden panes fires before (b) background-mount + // of not-yet-mounted panes. + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + // (c) non-passive records resume with navigation suppressed (INV-2). + expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + }) + + it('skips background-mount when only non-passive records exist', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1' } } + isPassiveSpy.mockReturnValue(false) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: no passive record → no not-yet-mounted pane to fresh-connect, so + // background-mount must not run (it would strand a plain shell / mount work). + expect(rec.events).toEqual(['wake:wt-1']) + expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + }) + + it('does nothing when the worktree has no sleeping records', () => { + sleepingRecords = { k1: { worktreeId: 'other-wt' } } + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: mobile browsing a worktree with nothing slept must not mount it (and + // its PTYs) on the desktop host. + expect(rec.events).toEqual([]) + expect(resumeSpy).not.toHaveBeenCalled() + expect(isPassiveSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts new file mode 100644 index 000000000..05369adbf --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -0,0 +1,57 @@ +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail, + type WakeHibernatedAgentsWorktreeDetail +} from '@/constants/terminal' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' + +/** + * Wakes a worktree's slept agents on the desktop host renderer with NO desktop + * navigation — used when a phone (`clientKind: 'mobile'`) opens the worktree. + * Runs up to three steps, in order: + * (a) fire the armed cold-restore `--resume` of the worktree's mounted hidden + * hibernated panes (the experimental agent-sleep records; the primary + * wake mechanism, since those records are passive for path C); + * (b) background-mount so a hibernated pane that is NOT currently mounted + * (post-restart / evicted) mounts offscreen and takes the fresh-connect + * cold-restore path; + * (c) resume the non-passive record classes (manual sleep of a still-working + * agent, `origin: 'quit'`) with navigation suppressed. + * Woken PTYs auto-publish to mobile via the renderer graph republish, so no + * spawn is awaited. + */ +export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): void { + const worktreeRecords = Object.values( + useAppStore.getState().sleepingAgentSessionsByPaneKey + ).filter((record) => record.worktreeId === worktreeId) + // Why: nothing is slept here, so there is no wake work. Skipping is what keeps + // a phone browsing many worktrees from permanently background-mounting each one + // (and reattaching its PTYs) on the desktop host it is paired to. + if (worktreeRecords.length === 0) { + return + } + + window.dispatchEvent( + new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { + detail: { worktreeId } + }) + ) + // Why: only a passive completed-hibernation record has a not-yet-mounted pane + // that needs a fresh-connect cold-restore (step b). Gating on it avoids mounting + // the worktree for non-passive records — which step (c) recovers into a fresh + // tab — so background-mount can't strand a plain shell in the stale tab. + if (worktreeRecords.some(isPassiveCompletedHibernationEvidence)) { + window.dispatchEvent( + new CustomEvent( + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + { + detail: { worktreeId } + } + ) + ) + } + resumeSleepingAgentSessionsForWorktree(worktreeId, { suppressNavigation: true }) +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 7d42be8ba..487c91529 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2344,6 +2344,9 @@ function createWebUiApi(): NonNullable['ui']> { respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, onSleepWorktree: () => noopUnsubscribe, + // Why: paired web is a full renderer that wakes on activation; mobile wake is + // desktop-host-scoped, so the web client never receives this signal. + onResumeSleepingAgents: () => noopUnsubscribe, onTerminalZoom: () => noopUnsubscribe, // Why: a paired web client has no OS sleep signal; occlusion-driven // visibilitychange already covers its wake recovery.