diff --git a/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts b/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts new file mode 100644 index 00000000000..6d91d7b078a --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts @@ -0,0 +1,147 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { Store } from '../loading-store/store' +import { WindowSession } from './terminal-topology-window-session-fixture' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const WORKTREE = FIXTURE_GIT_WORKTREE_ID +const TAB = 'tab-1' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function record(leafId: string, id: string): SleepingAgentSessionRecord { + return { + paneKey: `${TAB}:${leafId}`, + tabId: TAB, + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id }, + prompt: 'finish the task', + state: 'done', + capturedAt: 2, + updatedAt: 1, + origin: 'worktree-sleep' + } +} + +/** One tab split into two bound panes; the right pane already sleeps. */ +async function storeWithSplitTab(hostId?: string): Promise<{ store: Store; directory: string }> { + const directory = mkdtempSync(join(tmpdir(), 'orca-layout-sleep-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(WORKTREE, TAB, LEFT) + window.setLayout( + TAB, + { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + }, + { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' } + ) + window.setSleeping({ [`${TAB}:${RIGHT}`]: record(RIGHT, 'right-session') }) + store.setWorkspaceSession(window.snapshot(), hostId) + await store.flushPendingOrThrowAsync() + return { store, directory } +} + +describe('Store.applyLayoutCommand sleep and wake (layout.apply)', () => { + it('records a pane’s sleep durably and keeps its pane and binding', async () => { + const { store, directory } = await storeWithSplitTab() + const left = record(LEFT, 'left-session') + + const reply = await store.applyLayoutCommand({ + type: 'sleep', + workspace: WORKTREE, + paneKeys: [left.paneKey], + records: [left] + }) + + expect(reply).toEqual({ ok: true, result: { slept: [left.paneKey] } }) + const session = (await reopenTopologyStore(store, directory)).getWorkspaceSession() + expect(session.sleepingAgentSessionsByPaneKey).toEqual({ + [left.paneKey]: left, + [`${TAB}:${RIGHT}`]: record(RIGHT, 'right-session') + }) + expect(session.terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId).toEqual({ + [LEFT]: 'pty-left', + [RIGHT]: 'pty-right' + }) + }) + + it('wakes only the named pane; the other sleeping pane keeps its record', async () => { + const { store, directory } = await storeWithSplitTab() + const left = record(LEFT, 'left-session') + await store.applyLayoutCommand({ + type: 'sleep', + workspace: WORKTREE, + paneKeys: [left.paneKey], + records: [left] + }) + + const reply = await store.applyLayoutCommand({ + type: 'wake', + workspace: WORKTREE, + paneKeys: [left.paneKey] + }) + + expect(reply).toEqual({ ok: true, result: { woken: [left.paneKey] } }) + const session = (await reopenTopologyStore(store, directory)).getWorkspaceSession() + expect(session.sleepingAgentSessionsByPaneKey).toEqual({ + [`${TAB}:${RIGHT}`]: record(RIGHT, 'right-session') + }) + }) + + it('wakes nothing for a pane that holds no record', async () => { + const { store } = await storeWithSplitTab() + const before = store.getWorkspaceSession() + + const reply = await store.applyLayoutCommand({ + type: 'wake', + workspace: WORKTREE, + paneKeys: [`${TAB}:${LEFT}`] + }) + + expect(reply).toEqual({ ok: true, result: { woken: [] } }) + expect(store.getWorkspaceSession()).toBe(before) + }) + + it('records an SSH workspace’s sleep in that host’s partition only', async () => { + const { store } = await storeWithSplitTab('ssh:target-1') + const left = record(LEFT, 'left-session') + + await store.applyLayoutCommand( + { type: 'sleep', workspace: WORKTREE, paneKeys: [left.paneKey], records: [left] }, + ['ssh:target-1'] + ) + + expect( + store.getWorkspaceSession('ssh:target-1').sleepingAgentSessionsByPaneKey?.[left.paneKey] + ).toEqual(left) + expect(store.getWorkspaceSession().sleepingAgentSessionsByPaneKey).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 6dea8a6eadb..7abd8913c03 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -285,6 +285,42 @@ describe('layout.apply', () => { expect(applyLayoutCommand).toHaveBeenCalledWith(open, ['local']) }) + it('accepts a sleep of one tab’s panes and a wake of exactly one pane', async () => { + const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply') + const parse = (command: Record) => apply?.params?.safeParse({ command }) + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const otherTabPane = 'tab-2:22222222-2222-4222-8222-222222222222' + const record = { + paneKey, + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } + const sleep = { type: 'sleep', workspace: WORKTREE, tabId: 'tab-1' } + const wake = { type: 'wake', workspace: WORKTREE, tabId: 'tab-1' } + + expect(parse({ ...sleep, paneKeys: [paneKey], records: [record] })?.success).toBe(true) + // Never the whole workspace, never past the tab, never a record for another pane or workspace. + expect(parse({ ...sleep, records: [record] })?.success).toBe(false) + expect(parse({ ...sleep, paneKeys: [otherTabPane], records: [] })?.success).toBe(false) + expect( + parse({ ...sleep, paneKeys: [paneKey], records: [{ ...record, paneKey: otherTabPane }] }) + ?.success + ).toBe(false) + expect( + parse({ ...sleep, paneKeys: [paneKey], records: [{ ...record, worktreeId: 'repo-2::/x' }] }) + ?.success + ).toBe(false) + expect(parse({ ...wake, paneKeys: [paneKey] })?.success).toBe(true) + expect(parse({ ...wake })?.success).toBe(false) + expect(parse({ ...wake, paneKeys: [paneKey, paneKey] })?.success).toBe(false) + expect(parse({ ...wake, paneKeys: [otherTabPane] })?.success).toBe(false) + }) + it('applies group sizes to the workspace’s routed partition', async () => { const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1') const ratios = { diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts index 216318aff36..d9bd93036a9 100644 --- a/src/main/runtime/runtime-layout-apply.test.ts +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -49,6 +49,26 @@ describe('applyWindowLayoutCommand', () => { expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() }) + it('records a sleep and a wake in every partition holding the tab, and in no paired server’s', async () => { + const { store, applyLayoutCommand } = storeWith() + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const wake: WindowLayoutCommand = { + type: 'wake', + workspace: 'wt-1', + tabId: 'tab-1', + paneKeys: [paneKey] + } + const sleep: WindowLayoutCommand = { ...wake, type: 'sleep', records: [] } + + await applyWindowLayoutCommand(store, () => ['local', 'ssh:target-1', 'runtime:env-1'], sleep) + await applyWindowLayoutCommand(store, () => ['local', 'ssh:target-1'], wake) + + expect(applyLayoutCommand.mock.calls).toEqual([ + [sleep, ['local', 'ssh:target-1']], + [wake, ['local', 'ssh:target-1']] + ]) + }) + it('routes a group tree command by its workspace, with no tab to look up', async () => { const { store, applyLayoutCommand } = storeWith() const hostIdsFor = vi.fn(() => ['ssh:target-1' as const]) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index b33ed967905..0505fda1c2e 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -85,6 +85,10 @@ export async function applyWindowLayoutCommand( } return reply } + // A pane's sleeping record lives with the pane, in each partition that holds its tab. + if (command.type === 'sleep' || command.type === 'wake') { + return store.applyLayoutCommand(command, holders) + } if (command.type !== 'closePane') { return store.applyLayoutCommand(command, [hostIds[0]]) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts new file mode 100644 index 00000000000..66fc2ce622f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts @@ -0,0 +1,110 @@ +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + leafIdForPane, + createMockTransport, + createPane, + createManager +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const mocks = vi.hoisted(() => ({ wake: vi.fn() })) + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('@/store/terminals/terminal-sleep-layout-owner', () => ({ + recordPaneWakeThroughLayoutOwner: mocks.wake +})) + +function sleepingRecord(paneKey: string, id: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } +} + +describe('wake through main in a layout-owner build', () => { + let mockStoreState: StoreState + let transportFactoryQueue: MockTransport[] = [] + let createdTransportOptions: Record[] = [] + let storeSubscribers: ((state: StoreState) => void)[] = [] + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => createdTransportOptions, + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it('records the wake of the revealed pane alone, once its agent resumed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const paneKey = `tab-1:${leafIdForPane(2)}` + const otherPaneKey = `tab-1:${leafIdForPane(3)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = sleepingRecord(paneKey, 'sess-shown') + mockStoreState.sleepingAgentSessionsByPaneKey[otherPaneKey] = sleepingRecord( + otherPaneKey, + 'sess-other' + ) + mockStoreState.suppressedPtyExitIds['tab-pty'] = true + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [createPane(2), createManager(1), deps] as unknown as Parameters< + typeof connectPanePty + > + const binding = connectPanePty(...args) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit + if (typeof onPtyExit === 'function') { + onPtyExit('tab-pty') + } + await flushAsyncTicks() + // Hidden: the sleep holds until the pane is shown. + expect(mocks.wake).not.toHaveBeenCalled() + + binding.noteVisibilityResume() + await flushAsyncTicks() + + const resumeConnectOptions: { command?: string } | undefined = + transport.connect.mock.calls.at(-1)?.[0] + expect(resumeConnectOptions?.command).toContain('sess-shown') + expect(mocks.wake).toHaveBeenCalledTimes(1) + expect(mocks.wake).toHaveBeenCalledWith(expect.anything(), 'wt-1', paneKey) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 0aa9ddaaf4b..1703afd9594 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -5,6 +5,8 @@ import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-ou import { executeTerminalStartupCommandPaste } from '../terminal-startup-command-paste' import { resolveTerminalPasteRuntime } from '../terminal-paste-runtime' import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { recordPaneWakeThroughLayoutOwner } from '@/store/terminals/terminal-sleep-layout-owner' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import { shouldKeepHiddenStartupRendererQueriesLive } from './hidden-startup-renderer-query' import { createForegroundImmediateBudget } from './foreground-output-budgets' @@ -42,6 +44,14 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio useAppStore.getState(), startup.sleepingRecordEntry ) + // Switch point: main records the wake of this pane alone, the one this window resumed. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + recordPaneWakeThroughLayoutOwner( + useAppStore.getState(), + session.deps.worktreeId, + session.cacheKey + ) + } } } session.mergeStartupEnvWithPaneIdentity = ( diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index 8a0df907877..58583a8d956 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -1,6 +1,7 @@ // Store-free so store slices can send; `layout-owner-command` reads the store for other callers. import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { TabGroupLayoutNode } from '../../../shared/tab-types' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' import type { PaneSide } from '../../../shared/workspace-layout/terminal-pane-tree' @@ -64,6 +65,8 @@ export type WindowLayoutCommand = preview?: true } | { type: 'promotePreviewTab'; tabId: string } + | { type: 'sleep'; tabId: string; paneKeys: string[]; records: SleepingAgentSessionRecord[] } + | { type: 'wake'; tabId: string; paneKeys: [string] } export type LayoutApplyHandlers = { apply: (result: LayoutCommandResult) => void diff --git a/src/renderer/src/store/slices/store-sleep-layout-owner.test.ts b/src/renderer/src/store/slices/store-sleep-layout-owner.test.ts new file mode 100644 index 00000000000..3c40798d1cd --- /dev/null +++ b/src/renderer/src/store/slices/store-sleep-layout-owner.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { recordLayoutApply } from '@/runtime/layout-apply-send' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { + applySleepRuntimeRpcDefault, + createStoreCascadesMockApi +} from './store-cascades-test-harness' + +const mocks = vi.hoisted(() => ({ send: vi.fn() })) + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.send })) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn(() => []) +})) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = createStoreCascadesMockApi() + +const WT = 'repo1::/path/wt1' +const AGENT_LEAF = '11111111-1111-4111-8111-111111111111' +const SHELL_LEAF = '22222222-2222-4222-8222-222222222222' +const AGENT_PANE = `tab-1:${AGENT_LEAF}` + +/** One tab split into an agent pane and a shell pane. */ +function storeWithAgentPane(): ReturnType { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + tabsByWorktree: { + [WT]: [makeTab({ id: 'tab-1', worktreeId: WT, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: AGENT_LEAF }, + second: { type: 'leaf', leafId: SHELL_LEAF } + }, + activeLeafId: AGENT_LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [AGENT_LEAF]: 'pty-agent', [SHELL_LEAF]: 'pty-shell' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] } + }) + store + .getState() + .setAgentStatus( + AGENT_PANE, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: WT }, + { providerSession: { key: 'session_id', id: 'target-session' } } + ) + return store +} + +describe('sleep through main in a layout-owner build', () => { + beforeEach(() => { + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + mockApi.pty.kill.mockResolvedValue(undefined) + applySleepRuntimeRpcDefault(mockApi) + shutdownBufferCaptures.clear() + }) + + it('sends the hibernated pane’s record to main once its terminal stopped', async () => { + const store = storeWithAgentPane() + + await store.getState().shutdownCompletedAgentPaneForHibernation(WT, { + paneKey: AGENT_PANE, + tabId: 'tab-1', + leafId: AGENT_LEAF, + ptyId: 'pty-agent' + }) + + const record = store.getState().sleepingAgentSessionsByPaneKey[AGENT_PANE] + expect(mocks.send).toHaveBeenCalledTimes(1) + expect(mocks.send.mock.calls[0]![2]).toEqual({ + type: 'sleep', + tabId: 'tab-1', + paneKeys: [AGENT_PANE], + records: [record] + }) + expect(mocks.send.mock.invocationCallOrder[0]).toBeGreaterThan( + mockApi.pty.kill.mock.invocationCallOrder[0]! + ) + }) + + it('sends nothing when the hibernation stop fails', async () => { + const store = storeWithAgentPane() + mockApi.pty.kill.mockRejectedValueOnce(new Error('kill_failed')) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(WT, { + paneKey: AGENT_PANE, + tabId: 'tab-1', + leafId: AGENT_LEAF, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('kill_failed') + + expect(mocks.send).not.toHaveBeenCalled() + }) + + it('sends a workspace sleep’s records, and nothing for a workspace removal', async () => { + const store = storeWithAgentPane() + + await store.getState().shutdownWorktreeTerminals(WT, { keepIdentifiers: true }) + + expect(mocks.send.mock.calls.map((call) => call[2])).toEqual([ + { + type: 'sleep', + tabId: 'tab-1', + paneKeys: [AGENT_PANE], + records: [store.getState().sleepingAgentSessionsByPaneKey[AGENT_PANE]] + } + ]) + + mocks.send.mockClear() + await storeWithAgentPane().getState().shutdownWorktreeTerminals(WT) + + expect(mocks.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index 75d6905cad7..6104e6256f8 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -16,6 +16,8 @@ import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' import { removePanesAttention } from '../slices/workspace-attention/subject-attention-transitions' import { settleAttentionRemovedBy } from '../slices/workspace-attention/attention-removal-follow-up' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { recordPaneSleepThroughLayoutOwner } from './terminal-sleep-layout-owner' export function createTerminalPaneHibernationActions( set: TerminalStoreSet, @@ -171,6 +173,10 @@ export function createTerminalPaneHibernationActions( snapshot.commit?.() } } + // Switch point: main records the sleep this window decided, once the stop settled. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + recordPaneSleepThroughLayoutOwner(get(), worktreeId, sleepingAgentSessionRecords) + } settleAttentionRemovedBy(set, get, () => set((s) => { const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts index 3c4da6b3b0a..9bac593bbf6 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -12,6 +12,8 @@ import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' import { createTerminalShutdownGuardController } from './terminal-shutdown-guards' import { commitTerminalShutdownState } from './terminal-shutdown-state' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { recordPaneSleepThroughLayoutOwner } from './terminal-sleep-layout-owner' type ExactTerminalStopResult = { stoppedPtyIds?: string[] @@ -155,6 +157,10 @@ export function createTerminalShutdownActions( } guards.commitHandlerSnapshots() + // Switch point: main records a sleep's resumes once the terminals stopped (none on removal). + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + recordPaneSleepThroughLayoutOwner(get(), worktreeId, sleepingAgentSessionRecords) + } commitTerminalShutdownState({ exitGuardPtyIds, get, diff --git a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts new file mode 100644 index 00000000000..f457f3e2c20 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { recordLayoutApply } from '@/runtime/layout-apply-send' +import { + recordPaneSleepThroughLayoutOwner, + recordPaneWakeThroughLayoutOwner +} from './terminal-sleep-layout-owner' + +const mocks = vi.hoisted(() => ({ send: vi.fn() })) + +vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.send })) + +const WT = 'repo1::/path/wt1' +const STATE = {} +const A1 = 'tab-a:11111111-1111-4111-8111-111111111111' +const A2 = 'tab-a:22222222-2222-4222-8222-222222222222' +const B1 = 'tab-b:33333333-3333-4333-8333-333333333333' + +function record(paneKey: string): SleepingAgentSessionRecord { + return { + paneKey, + worktreeId: WT, + agent: 'codex', + providerSession: { key: 'session_id', id: paneKey }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } +} + +describe('sleep and wake through the layout owner', () => { + beforeEach(() => { + mocks.send.mockReset() + }) + + it('sends one sleep per tab naming only the panes it stopped', () => { + const records = { + [A1]: record(A1), + [A2]: record(A2), + [B1]: record(B1), + // A legacy pane key names no pane main holds. + 'tab-a:1': record('tab-a:1') + } + + recordPaneSleepThroughLayoutOwner(STATE, WT, records) + + expect(mocks.send.mock.calls.map((call) => call[2])).toEqual([ + { type: 'sleep', tabId: 'tab-a', paneKeys: [A1, A2], records: [record(A1), record(A2)] }, + { type: 'sleep', tabId: 'tab-b', paneKeys: [B1], records: [record(B1)] } + ]) + }) + + it('sends nothing when nothing slept, as a worktree removal', () => { + recordPaneSleepThroughLayoutOwner(STATE, WT, {}) + + expect(mocks.send).not.toHaveBeenCalled() + }) + + it('wakes the one pane the window resumed', () => { + recordPaneWakeThroughLayoutOwner(STATE, WT, A2) + + expect(mocks.send).toHaveBeenCalledWith(STATE, WT, { + type: 'wake', + tabId: 'tab-a', + paneKeys: [A2] + }) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts new file mode 100644 index 00000000000..03f0bee7152 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts @@ -0,0 +1,44 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' +import { recordLayoutApply } from '@/runtime/layout-apply-send' + +/** + * A layout-owner build's record of panes this window put to sleep and already stopped: main + * records how each pane's agent resumes, one command per tab. The window decides (planner, user + * sleep) and stops the terminals. + */ +export function recordPaneSleepThroughLayoutOwner( + state: WorktreeRuntimeOwnerState, + worktreeId: string, + records: Record +): void { + const recordsByTabId = new Map() + for (const record of Object.values(records)) { + // A legacy pane key names no pane main holds. + const tabId = parsePaneKey(record.paneKey)?.tabId + if (tabId) { + recordsByTabId.set(tabId, [...(recordsByTabId.get(tabId) ?? []), record]) + } + } + for (const [tabId, tabRecords] of recordsByTabId) { + recordLayoutApply(state, worktreeId, { + type: 'sleep', + tabId, + paneKeys: tabRecords.map((record) => record.paneKey), + records: tabRecords + }) + } +} + +/** A layout-owner build's record of the one pane this window showed and resumed. */ +export function recordPaneWakeThroughLayoutOwner( + state: WorktreeRuntimeOwnerState, + worktreeId: string, + paneKey: string +): void { + const tabId = parsePaneKey(paneKey)?.tabId + if (tabId) { + recordLayoutApply(state, worktreeId, { type: 'wake', tabId, paneKeys: [paneKey] }) + } +} diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index ceac7531baf..daffb5e487e 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -1,10 +1,11 @@ import { z } from 'zod' -import { isTerminalLeafId } from '../stable-pane-id' +import { isTerminalLeafId, parsePaneKey } from '../stable-pane-id' import { isValidHostTerminalTabId } from '../terminal-tab-id' import type { TuiAgent } from '../tui-agent' import { isTuiAgent } from '../tui-agent-config' import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas-params' import { TerminalOrphanGroupLayout } from './terminal-orphan-params' +import { sleepingAgentSessionRecordSchema } from '../workspace-session-sleeping-agents' export const LayoutSubscribeParams = z .object({ @@ -217,6 +218,32 @@ const SetBrowserTabProfileCommand = z.object({ const PromotePreviewTabCommand = z.object({ type: z.literal('promotePreviewTab'), ...OnTab }) +/** Pane keys of the command's own tab only, so a sleep or wake never reaches past it. */ +function paneKeysNameTab(command: { tabId: string; paneKeys: string[] }): boolean { + return command.paneKeys.every((paneKey) => parsePaneKey(paneKey)?.tabId === command.tabId) +} + +/** The window's sleep of panes of one tab it already stopped; main records how each resumes. */ +const SleepCommand = z + .object({ + type: z.literal('sleep'), + ...OnTab, + paneKeys: z.array(z.string().min(1)).min(1), + records: z.array(sleepingAgentSessionRecordSchema) + }) + .refine(paneKeysNameTab) + .refine((command) => + command.records.every( + (record) => + record.worktreeId === command.workspace && command.paneKeys.includes(record.paneKey) + ) + ) + +/** The window's wake of the one pane it shows; never more, so agents are not resumed in bulk. */ +const WakeCommand = z + .object({ type: z.literal('wake'), ...OnTab, paneKeys: z.tuple([z.string().min(1)]) }) + .refine(paneKeysNameTab) + /** * A window's layout command (`layout.apply`, registered only in a layout-owner build). The window * names what it acts on; main mints every new id but a created terminal tab's @@ -245,6 +272,8 @@ export const LayoutApplyParams = z.object({ ReplaceBrowserPageCommand, SetBrowserTabProfileCommand, OpenEditorTabCommand, - PromotePreviewTabCommand + PromotePreviewTabCommand, + SleepCommand, + WakeCommand ]) }) diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index 624996e0dc2..f27b7c53823 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -83,7 +83,7 @@ export const sleepingAgentLaunchConfigSchema = z.preprocess((raw) => { return parsed.success ? parsed.data : undefined }, sleepingAgentLaunchConfigBaseSchema.optional()) -const sleepingAgentSessionRecordSchema = z +export const sleepingAgentSessionRecordSchema = z .object({ paneKey: z.string().refine((value) => value.length > 0), tabId: terminalTabIdSchema.optional(),