Record sleep and wake through main (dark, build switch off by default) (#29350)

* feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER

A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the
window's layout command to the stored partition through the shared applyLayoutCommand,
minting new ids. A user split then waits for main's answer and mounts the pane id main
minted. Every other build substitutes 'window', so neither branch is compiled in.

* fix(layout): write only the records a layout.apply command touched

A load-then-save rewrite also normalizes records the command never touched. The command's
effect is now the diff between the save before and after it, written onto the stored
partition record by record. A split whose main reply says a paired server holds the tab
splits as today.

* fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result

* feat(layout): close, rename and equalize panes through main behind ORCA_LAYOUT_OWNER

In a layout-owner build the window's pane close, title change and equalize go through
layout.apply and the window shows main's answer. A window pane close keeps the legacy
close's rules: the tab's last pane is refused, a pane restarted since main read the close is
left alone, every partition holding the tab closes it, and the tab's remote session id follows
the remaining pane. The rules sit behind a command field only layout.apply sets.

* fix(layout): close the tab when a racing pane close leaves the window one pane

* fix(layout): leave an unmounted tab alone when main refuses its last pane

* feat(layout): move panes and save divider ratios through main behind ORCA_LAYOUT_OWNER

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): a taken pane drop whose pane closed does nothing

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): pin the window's pane move to main's saved tree, and the drops main cannot take

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* fix(layout): close a user's pane even if its terminal restarted, as today

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): pin the restarted-pane close directly, not through a made-up profile state

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* fix(layout): leave a split to today's path only when the workspace's row names a paired server

The split switch point asked which host to route to with a helper that falls back to the focused
server, so a focused server could claim a workspace whose row names no owner. Use the same owner
test the tab mirror uses.

* test(layout): type the split owner mock without a cast

* test(layout): type the layout-owner command mocks with the real signature

* test(layout): type the gesture command mock with the real signature

* fix(layout): close the dark path's gaps: unmounted split panes, refused or failed splits and moves, ratios during a move, release guard

- A pane main saved but the window could not mount (source pane or manager gone during the await)
  is closed again in main, so it does not come back on restart.
- A split main refuses because its copy lacks the workspace, tab or pane, or a failed call, splits
  as today; a refused or failed move moves as today.
- Divider ratios committed while a move is in flight wait for the window's move, then go to main.
- The orcad bundle gets the ORCA_LAYOUT_OWNER define when the switch is on.
- Packaging and the orcad template refuse ORCA_LAYOUT_OWNER=main and any output a dark build marked.
- workspace-layout-record-patch.ts: no non-null assertions; plain comment for the map depth.

* test(layout): count a split pane as mounted from the split mock's own results

* fix(layout): act as today whenever main lacks the target or the call throws, not just for splits

- sendPaneCommandToLayoutOwner runs asToday for workspace/tab/pane_not_found and a thrown call, so
  close, rename and equalize fall back like split and move; a handler that throws no longer runs the
  fallback too.
- A close main refuses as its last pane, while this window still has others, closes as today.
- A move whose local step throws still releases held divider ratios.
- Drop the orcad output marker: nothing read it; the template build refuses the switch itself.

* test(layout): plain names for the last-pane fallback test and the marker comment

* docs(layout): say plainly what an indeterminate write can leave behind

* fix(layout): finish the main merge with this branch's versions of the layout-owner files

* test(build): copy the layout-owner build guard into the compiler-cache checkout fixture

* feat(layout): send tab rename, color and pin through main behind the layout-owner build switch

In an ORCA_LAYOUT_OWNER=main build, the tab bar's rename, color and pin ask main's layout.apply
first; the window then runs today's action. When main lacks the tab or the call fails the
window acts as today; a paired server's workspace keeps today's path. Pinning a tab now also
ends its preview in main's copy, as the tab bar's pin does. Other builds compile the switch out.

* fix(layout): leave a pinned tab's preview mark to the window until main opens files

Changing the shared pin command would change what a paired client's pin does in every build.

* fix(layout): write a folded legacy row in its loaded form when a layout.apply command touches its claim

A legacy terminal row the load folds into a tab-bar tab stays stored as is. Closing the tab it
repeats, or giving it a tab-bar entry by moving it, let the next load resolve the claim the other
way: the row came back or took the other tab's terminal. Such a command now writes the row as
the load had it; every other record still stays as stored.

* feat(layout): accept tab moves, group splits and group sizes on layout.apply

Group sizes have no tab, so they go to the workspace's routed partition.

* refactor(layout): move the window's layout.apply sender out of the terminal pane folder

Tab groups send through it too; it now also acts as today when main lacks the group.

* feat(layout): reorder, move and split tab groups, and save group sizes, through main behind ORCA_LAYOUT_OWNER

A tab drop and Move Tab to Split wait for main's answer and then show main's placement, with main's
new group id; group divider sizes are drawn while dragging and saved by main when the drag ends.
Closing an empty group needs no command: main never holds an empty group.

* docs(layout): wrap the layout-owner sender's doc comment

* fix(layout): review fixes for tab moves through main

- Read a folded row's terminals with the loader's own claim rule, so rows sharing only an SSH
  session are covered too, and drop the row's remote session entry with it.
- The record-patch test allows a folded row only in its loaded form.
- A tab drag ends at once; main's answer shows the move after.
- A lone group main moved whole is placed so only when this window's group is lone too.

* refactor(layout): adopt only a folded row's pane layout beside its row

A row's remote session entry is carried through load and save unchanged, so adopting it from the
load changed nothing (the earlier commit's message overstated it).

* feat(layout): record a window's terminal tab close through layout.apply

closeTabs can now record each terminal tab it closes, or does not find, as closed for the given
reason (pruned as a partition's records are), so a start that commits later is refused, as today's
close records it. layout.apply takes one tab's close with the window's reason: main forces it in
every holder partition, marks a user close's launches, stops no terminal (the window already did,
sparing any another tab shares), and leaves a workspace it lacks to today's close. A sibling the
load supplied that the close only renumbered is no longer written.

* feat(layout): send the window's terminal tab close to main behind ORCA_LAYOUT_OWNER

The window still closes the tab and stops its terminals itself; main records the close through
layout.apply instead of the close intent, which stays for paired servers and when main lacks the
workspace.

* fix(layout): review fixes for the tab close through main

- The window's close sends through a store-free sender, so the store no longer imports itself.
- Main marks a user close's launches by the terminal's own id.
- A recorded close prunes the whole partition's close records, as today, not one workspace's.

* feat(layout): open and close browser tabs and pages through main behind the layout-owner build switch

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(layout): open editor tabs under the window's id, per split, and promote previews through layout.apply

openEditorTab takes the window's tab id (the same open again is a no-op, a held id is refused),
dedupes per group so a file open in another split gets its own tab, pins an existing preview on a
non-preview open, and places the new tab before closing the replaced preview so a group whose only
tab is its preview keeps its id. layout.apply accepts openEditorTab, promotePreviewTab and a
closeTabs with no terminal close record.

* feat(layout): record the window's editor tab opens, closes and preview promotions in main behind ORCA_LAYOUT_OWNER

The window still draws first (openFile returns at once and its callers act on the tab by id).
Main then records the tab under the window's ids and decides the group fallback and preview
replacement by the same shared rule. Switch points: the one editor tab create, the unified tab
close, preview promotion on activation, edit and make-permanent, and the reopened tab's position.

* refactor(layout): name the browser sender's no-op so the window bundle keeps its names

* feat(layout): move a window's dragged-out pane into a new tab through layout.apply behind ORCA_LAYOUT_OWNER

* refactor(layout): keep the topology commit under its line limit

* Record sleep and wake through main (dark, build switch off by default)

In a layout-owner build, main records each sleep the window decided and each
wake of the one pane it showed: a workspace sleep or a pane hibernation sends a
'sleep' per tab once the terminals stopped, and a resumed pane sends a 'wake'
naming only itself. The window keeps deciding, stopping terminals and saving
its own records.

* fix(layout): keep a served browser tab's open, address and close in order while main writes it

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(layout): record sleep and wake in every partition holding the tab

Review fix: a tab a legacy relay reattach left in two partitions gets its
sleeping record in both, as a pane close does. The pane hibernation comment
no longer claims a stop on the branch that stops nothing.

* fix(layout): review fixes for the window's pane drag-out through main

* fix(layout): review fixes for editor tabs through main

openEditorTab refuses a group main lacks instead of placing the tab in another group, and gives
a workspace with no group (the window keeps its emptied root group; main closes it) the sender's
group id. Tests pin close-all, the emptied root group, the refused open into a fresh split, and
that the window sends an edit-mode file's record exactly as its session save keeps it.

* docs(layout): note what a refused served-tab close leaves

* refactor(layout): re-key a moved pane from the move alone

* test(layout): assert the window drew the tab main refused

* refactor(layout): share one record-only layout.apply sender and inline the id schema

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-16 04:35:28 -07:00
committed by GitHub
co-authored by Claude
parent 25d83af9a7
commit dddad7d66a
14 changed files with 625 additions and 3 deletions
@@ -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()
})
})
@@ -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<string, unknown>) => 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 = {
@@ -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])
+4
View File
@@ -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]])
}
@@ -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<string, unknown>[] = []
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)
})
})
@@ -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 = (
@@ -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
@@ -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<typeof recordLayoutApply>() }))
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<typeof AgentStatusModule>()
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<typeof createTestStore> {
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()
})
})
@@ -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] ?? []
@@ -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,
@@ -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<typeof recordLayoutApply>() }))
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]
})
})
})
@@ -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<string, SleepingAgentSessionRecord>
): void {
const recordsByTabId = new Map<string, SleepingAgentSessionRecord[]>()
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] })
}
}
@@ -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
])
})
@@ -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(),