From 0ff240a48b9f5364474ef2fa2fbaa7e2f84205aa Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 00:51:22 -0400 Subject: [PATCH 01/75] test(terminal): pin layout invariants the mirror refactor must keep --- .../terminal-sleep-quit-resume.test.ts | 167 ++++++ .../terminal-topology-model.test.ts | 529 ++++++++++++++++++ .../terminal-topology-profile-fixture.ts | 46 ++ ...etup-split-activation-single-owner.test.ts | 284 ++++++++++ .../terminal-exited-pane-liveness.test.ts | 76 +++ 5 files changed, 1102 insertions(+) create mode 100644 src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-model.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts create mode 100644 src/renderer/src/lib/setup-split-activation-single-owner.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts diff --git a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts new file mode 100644 index 000000000000..297a99a3eb0b --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts @@ -0,0 +1,167 @@ +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 type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { buildAgentResumeStartupPlan } from '../../../shared/tui-agent-resume-startup' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import type { Store } from '../loading-store/store' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +// The mirror refactor moves sleeping records into main's own commits and makes window saves +// presentation-only; a slept agent must still be resumable after quit and relaunch. + +const TAB = 'tab-agent' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PTY = `${WORKTREE}@@0a1b2c3d` +const PANE_KEY = `${TAB}:${LEAF}` + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function record(origin: 'worktree-sleep' | 'quit', capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: TAB, + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish the task', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin + } +} + +/** The window's session after it spawned an agent pane, as its debounced save sends it. */ +function windowSessionWithAgentPane(store: Store): WorkspaceSessionState { + const session = structuredClone(store.getWorkspaceSession()) + session.tabsByWorktree = { + ...session.tabsByWorktree, + [WORKTREE]: [ + { + id: TAB, + ptyId: PTY, + worktreeId: WORKTREE, + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + } + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: PTY } + } + } + return session +} + +async function storeWithAgentPane() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = windowSessionWithAgentPane(store) + store.setWorkspaceSession(structuredClone(window)) + await expect( + store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY }) + ).resolves.toBe(true) + return { directory, store, window } +} + +/** What main holds for the slept pane, and what the window would show from its slice. */ +function heldForPane(store: Store) { + const session = store.getWorkspaceSession() + const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, WORKTREE) + return { + record: session.sleepingAgentSessionsByPaneKey?.[PANE_KEY], + sliceRecord: slice.sleeping[PANE_KEY], + tabs: slice.tabs.map((tab) => tab.id), + binding: slice.layouts[TAB]?.ptyIdsByLeafId?.[LEAF] + } +} + +function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | undefined { + return held + ? buildAgentResumeStartupPlan({ + agent: held.agent, + providerSession: held.providerSession, + cmdOverrides: {}, + platform: 'linux' + })?.launchCommand + : undefined +} + +describe('sleep → quit → resume', () => { + it('keeps the quit capture over the periodic one through quit and relaunch', async () => { + const { directory, store, window } = await storeWithAgentPane() + // The agent is slept; the periodic save carries the first capture. + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) } + store.setWorkspaceSession(structuredClone(window)) + // Quit: the window's quit capture rides only on the synchronous before-unload stage. + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) } + store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const expected = { + record: record('quit', 2), + sliceRecord: record('quit', 2), + tabs: [TAB], + binding: PTY + } + expect(heldForPane(relaunched)).toEqual(expected) + // The relaunched window's first save is its hydrated copy; the record stays. + relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession())) + expect(heldForPane(relaunched)).toEqual(expected) + expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'") + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a record that only the before-unload stage carried', async () => { + const { directory, store, window } = await storeWithAgentPane() + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) } + store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + + const relaunched = await reopenTopologyStore(store, directory) + try { + expect(heldForPane(relaunched)).toEqual({ + record: record('quit', 3), + sliceRecord: record('quit', 3), + tabs: [TAB], + binding: PTY + }) + expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-3'") + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts new file mode 100644 index 000000000000..04a48908298f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -0,0 +1,529 @@ +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 { mulberry32 } from '../../../shared/agent-tui-ansi-fuzz-stream' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants' +import { closeLeafOrTab } from './terminal-topology-commit' +import { + emptyTerminalSessionProfile, + FIXTURE_FOLDER_WORKTREE_ID, + 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: () => [] +})) + +/** + * Model test for terminal layout as main holds it. Seeded gesture sequences run against main's + * real Store, written the way today's desktop window writes them; a plain reference model says + * what main must hold after each one. Making the window a mirror of main changes who writes, not + * this outcome, so every seed must stay green through that refactor. + * Replay one seed: FUZZ_SEED= FUZZ_ITERATIONS=1 pnpm test . + */ + +const WORKTREES = [ + FIXTURE_GIT_WORKTREE_ID, + FIXTURE_FOLDER_WORKTREE_ID, + FLOATING_TERMINAL_WORKTREE_ID +] +const FIRST_SEED = Number(process.env.FUZZ_SEED ?? 0x9417) +const ITERATIONS = Number(process.env.FUZZ_ITERATIONS ?? 6) +const OPS_PER_SEED = 40 + +type ModelPane = { ptyId: string | undefined; sleeping: boolean } +/** What main must hold: tab → leaf → binding, per worktree, in tab order. */ +type Model = Map }> + +const directories: string[] = [] + +function makeIds(random: () => number) { + const hex = (length: number): string => + Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join('') + return { + leaf: () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`, + tab: () => `tab-${hex(8)}`, + pty: (worktreeId: string) => `${worktreeId}@@${hex(8)}` + } +} + +function leafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { + if (!node) { + return [] + } + return node.type === 'leaf' ? [node.leafId] : [...leafIds(node.first), ...leafIds(node.second)] +} + +function withoutLeaf(node: TerminalPaneLayoutNode, leafId: string): TerminalPaneLayoutNode | null { + if (node.type === 'leaf') { + return node.leafId === leafId ? null : node + } + const first = withoutLeaf(node.first, leafId) + const second = withoutLeaf(node.second, leafId) + if (!first || !second) { + return first ?? second + } + return { ...node, first, second } +} + +function sleepingRecord( + worktreeId: string, + tabId: string, + leafId: string +): SleepingAgentSessionRecord { + return { + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${leafId}` }, + prompt: 'model', + state: 'waiting', + capturedAt: 1, + updatedAt: 1, + origin: 'quit' + } +} + +/** The desktop window's copy of the session: it authors tabs and layouts and saves them whole. */ +class WindowSession { + constructor(public session: WorkspaceSessionState) {} + + addTab(worktreeId: string, tabId: string, leafId: string): void { + const tabs = this.session.tabsByWorktree[worktreeId] ?? [] + const tab: TerminalTab = { + id: tabId, + ptyId: null, + worktreeId, + title: `Terminal ${tabs.length + 1}`, + customTitle: null, + color: null, + sortOrder: tabs.length, + createdAt: 1 + } + this.session.tabsByWorktree = { ...this.session.tabsByWorktree, [worktreeId]: [...tabs, tab] } + this.setLayout(tabId, { type: 'leaf', leafId }, {}) + } + + setLayout(tabId: string, root: TerminalPaneLayoutNode, ptyIdsByLeafId: Record) { + this.session.terminalLayoutsByTabId = { + ...this.session.terminalLayoutsByTabId, + [tabId]: { + root, + activeLeafId: leafIds(root)[0] ?? null, + expandedLeafId: null, + ptyIdsByLeafId + } + } + } + + bind(tabId: string, leafId: string, ptyId: string): void { + const layout = this.session.terminalLayoutsByTabId[tabId] + if (layout?.root) { + this.setLayout(tabId, layout.root, { ...layout.ptyIdsByLeafId, [leafId]: ptyId }) + } + this.setTabPtyId(tabId, ptyId) + } + + /** `clearTabPtyId` on exit: the tab's live id goes; the layout keeps the binding as a resume hint. */ + setTabPtyId(tabId: string, ptyId: string | null): void { + this.session.tabsByWorktree = Object.fromEntries( + Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => (tab.id === tabId ? { ...tab, ptyId } : tab)) + ]) + ) + } + + removeTab(tabId: string): void { + this.session.tabsByWorktree = Object.fromEntries( + Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.filter((tab) => tab.id !== tabId) + ]) + ) + const { [tabId]: _removed, ...layouts } = this.session.terminalLayoutsByTabId + void _removed + this.session.terminalLayoutsByTabId = layouts + this.setSleeping( + Object.fromEntries( + Object.entries(this.session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([paneKey]) => !paneKey.startsWith(`${tabId}:`) + ) + ) + ) + } + + setSleeping(records: Record): void { + this.session.sleepingAgentSessionsByPaneKey = records + } + + snapshot(): WorkspaceSessionState { + return structuredClone(this.session) + } +} + +/** Fails on a terminal bound to two leaves or a leaf in two tabs, naming both owners. */ +function expectOwnerInvariants(session: WorkspaceSessionState, context: string): void { + const owners = collectTerminalLeafOwners({ hostId: LOCAL_EXECUTION_HOST_ID, session }) + const describe = (owner: (typeof owners)[number]): string => + `${owner.worktreeId} ${owner.tab.id}:${owner.leafId} → ${owner.ptyId ?? 'unbound'}` + const breaches: string[] = [] + for (const [index, left] of owners.entries()) { + for (const right of owners.slice(index + 1)) { + if (left.leafId === right.leafId && left.tab.id !== right.tab.id) { + breaches.push(`leaf in two tabs: ${describe(left)} | ${describe(right)}`) + } else if (left.leafId !== right.leafId && isSameTerminal(left, right)) { + breaches.push(`terminal on two leaves: ${describe(left)} | ${describe(right)}`) + } + } + } + expect(breaches, context).toEqual([]) +} + +/** Main's projected slices, reduced to what the model states. */ +function projectedTopology(session: WorkspaceSessionState) { + return Object.fromEntries( + WORKTREES.map((worktreeId) => { + const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, worktreeId) + const tabs = slice.tabs.map((tab) => { + const layout = slice.layouts[tab.id] + return { + tabId: tab.id, + leaves: Object.fromEntries( + leafIds(layout?.root).map((leafId) => [leafId, layout?.ptyIdsByLeafId?.[leafId]]) + ) + } + }) + return [worktreeId, { tabs, sleeping: Object.keys(slice.sleeping).sort() }] + }) + ) +} + +function expectedTopology(model: Model) { + return Object.fromEntries( + WORKTREES.map((worktreeId) => { + const entries = [...model.entries()].filter(([, tab]) => tab.worktreeId === worktreeId) + const tabs = entries.map(([tabId, tab]) => ({ + tabId, + leaves: Object.fromEntries([...tab.leaves].map(([leafId, pane]) => [leafId, pane.ptyId])) + })) + const sleeping = entries + .flatMap(([tabId, tab]) => + [...tab.leaves] + .filter(([, pane]) => pane.sleeping) + .map(([leafId]) => `${tabId}:${leafId}`) + ) + .sort() + return [worktreeId, { tabs, sleeping }] + }) + ) +} + +type Op = + | 'new_tab' + | 'split' + | 'close_pane' + | 'close_tab' + | 'move_pane' + | 'pty_exit' + | 'respawn' + | 'sleep' + | 'wake' + | 'restart' + | 'quit_restart' + +const OPS: readonly Op[] = [ + 'new_tab', + 'new_tab', + 'split', + 'split', + 'close_pane', + 'close_tab', + 'move_pane', + 'pty_exit', + 'respawn', + 'sleep', + 'wake', + 'restart', + 'quit_restart' +] + +async function runSeed(seed: number): Promise { + const random = mulberry32(seed) + const pick = (items: readonly T[]): T | undefined => + items.length === 0 ? undefined : items[Math.floor(random() * items.length)] + const ids = makeIds(random) + const directory = mkdtempSync(join(tmpdir(), 'orca-topology-model-')) + directories.push(directory) + let store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = new WindowSession(structuredClone(store.getWorkspaceSession())) + const model: Model = new Map() + const log: string[] = [] + const save = (): void => store.setWorkspaceSession(window.snapshot()) + const panes = () => + [...model].flatMap(([tabId, tab]) => + [...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane })) + ) + const splitTabs = () => [...model].filter(([, tab]) => tab.leaves.size > 1) + + const bind = async (worktreeId: string, tabId: string, leafId: string, ptyId: string) => { + await expect(store.persistPtyBinding({ worktreeId, tabId, leafId, ptyId })).resolves.toBe(true) + window.bind(tabId, leafId, ptyId) + model.get(tabId)!.leaves.set(leafId, { ptyId, sleeping: false }) + } + + /** `closeTerminalSurfaceFromRenderer`: the window removed the surface; main commits the close. */ + const commitClose = async (worktreeId: string, target: TerminalSurfaceCloseTarget) => { + const refusal = await store.runDurableMutation( + closeLeafOrTab({ + worktreeId, + target, + options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason: 'user' }, + requestedSession: store.getWorkspaceSession(), + ownerMatches: () => true, + hostId: () => LOCAL_EXECUTION_HOST_ID, + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: () => {} + }) + ) + expect(refusal).toBeUndefined() + } + + const reopen = async (stageQuit: boolean): Promise => { + if (stageQuit) { + store.stageWorkspaceSessionBeforeUnload(window.snapshot()) + } + store = await reopenTopologyStore(store, directory) + window.session = structuredClone(store.getWorkspaceSession()) + } + + for (let step = 0; step < OPS_PER_SEED; step++) { + const op = pick(OPS)! + switch (op) { + case 'new_tab': { + const worktreeId = pick(WORKTREES)! + const tabId = ids.tab() + const leafId = ids.leaf() + const ptyId = ids.pty(worktreeId) + // pty:spawn can beat the window's debounced layout save. + const spawnFirst = random() < 0.5 + log.push( + `${op} ${worktreeId} ${tabId}:${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}` + ) + model.set(tabId, { worktreeId, leaves: new Map() }) + window.addTab(worktreeId, tabId, leafId) + if (!spawnFirst) { + save() + } + await bind(worktreeId, tabId, leafId, ptyId) + save() + break + } + case 'split': { + const target = pick(panes()) + if (!target) { + continue + } + const leafId = ids.leaf() + const ptyId = ids.pty(target.tab.worktreeId) + const spawnFirst = random() < 0.5 + const direction = random() < 0.5 ? 'vertical' : 'horizontal' + log.push( + `${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}` + ) + const layout = window.session.terminalLayoutsByTabId[target.tabId]! + window.setLayout( + target.tabId, + { + type: 'split', + direction, + first: layout.root!, + second: { type: 'leaf', leafId } + }, + layout.ptyIdsByLeafId ?? {} + ) + if (!spawnFirst) { + save() + } + await bind(target.tab.worktreeId, target.tabId, leafId, ptyId) + save() + break + } + case 'close_pane': { + const [tabId, tab] = pick(splitTabs()) ?? [] + const leafId = tab && pick([...tab.leaves.keys()]) + if (!tabId || !tab || !leafId) { + continue + } + log.push(`${op} ${tabId}:${leafId}`) + const layout = window.session.terminalLayoutsByTabId[tabId]! + const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {} + void _closed + window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings) + const { [`${tabId}:${leafId}`]: _record, ...sleeping } = + window.session.sleepingAgentSessionsByPaneKey ?? {} + void _record + window.setSleeping(sleeping) + tab.leaves.delete(leafId) + await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId }) + save() + break + } + case 'close_tab': { + const tabId = pick([...model.keys()]) + if (!tabId) { + continue + } + log.push(`${op} ${tabId}`) + const { worktreeId } = model.get(tabId)! + window.removeTab(tabId) + model.delete(tabId) + await commitClose(worktreeId, { kind: 'tab', tabId }) + save() + break + } + case 'move_pane': { + const [sourceTabId, source] = pick(splitTabs()) ?? [] + const leafId = source && pick([...source.leaves.keys()]) + if (!sourceTabId || !source || !leafId) { + continue + } + const targetTabId = ids.tab() + const pane = source.leaves.get(leafId)! + log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`) + await expect( + store.moveTerminalLeafToNewTab({ + worktreeId: source.worktreeId, + sourceTabId, + targetTabId, + leafId, + ptyId: pane.ptyId ?? null + }) + ).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null }) + // The window then shows what main moved, as the drag-out does today. + const layout = window.session.terminalLayoutsByTabId[sourceTabId]! + const { [leafId]: movedPty, ...bindings } = layout.ptyIdsByLeafId ?? {} + window.setLayout(sourceTabId, withoutLeaf(layout.root!, leafId)!, bindings) + window.addTab(source.worktreeId, targetTabId, leafId) + if (movedPty) { + window.bind(targetTabId, leafId, movedPty) + } + const records = { ...window.session.sleepingAgentSessionsByPaneKey } + const record = records[`${sourceTabId}:${leafId}`] + if (record) { + delete records[`${sourceTabId}:${leafId}`] + records[`${targetTabId}:${leafId}`] = { + ...record, + paneKey: `${targetTabId}:${leafId}`, + tabId: targetTabId + } + window.setSleeping(records) + } + source.leaves.delete(leafId) + model.set(targetTabId, { + worktreeId: source.worktreeId, + leaves: new Map([[leafId, pane]]) + }) + save() + break + } + case 'pty_exit': { + const target = pick(panes().filter(({ pane }) => pane.ptyId)) + if (!target) { + continue + } + log.push(`${op} ${target.tabId}:${target.leafId}`) + // Main keeps an exited pane's binding; only the window's live id goes. + window.setTabPtyId(target.tabId, null) + save() + break + } + case 'respawn': { + const target = pick(panes()) + if (!target) { + continue + } + const ptyId = ids.pty(target.tab.worktreeId) + log.push(`${op} ${target.tabId}:${target.leafId} ${ptyId}`) + const sleeping = target.pane.sleeping + await bind(target.tab.worktreeId, target.tabId, target.leafId, ptyId) + target.tab.leaves.get(target.leafId)!.sleeping = sleeping + save() + break + } + case 'sleep': + case 'wake': { + const target = pick(panes().filter(({ pane }) => pane.sleeping === (op === 'wake'))) + if (!target) { + continue + } + log.push(`${op} ${target.tabId}:${target.leafId}`) + const paneKey = `${target.tabId}:${target.leafId}` + const records = { ...window.session.sleepingAgentSessionsByPaneKey } + if (op === 'sleep') { + records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) + } else { + delete records[paneKey] + } + window.setSleeping(records) + target.pane.sleeping = op === 'sleep' + save() + break + } + case 'restart': + case 'quit_restart': { + log.push(op) + await reopen(op === 'quit_restart') + break + } + } + const context = `seed ${seed}, step ${step}:\n${log.join('\n')}` + const session = store.getWorkspaceSession() + expectOwnerInvariants(session, context) + expect(projectedTopology(session), context).toEqual(expectedTopology(model)) + } + + // The saved profile, read back cold, holds the same layout. + await reopen(false) + const context = `seed ${seed}, after reopen:\n${log.join('\n')}` + expectOwnerInvariants(store.getWorkspaceSession(), context) + expect(projectedTopology(store.getWorkspaceSession()), context).toEqual(expectedTopology(model)) + await store.freezeWritesAsync() + return log +} + +describe('terminal layout model (main session)', () => { + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('keeps one tab per pane, one pane per terminal, and the expected layout across random gestures', async () => { + const ranOps = new Set() + for (let iteration = 0; iteration < ITERATIONS; iteration++) { + const log = await runSeed(FIRST_SEED + iteration) + for (const entry of log) { + ranOps.add(entry.split(' ')[0]!) + } + } + // Precondition: the seeds reached every gesture, so a green run is not a quiet one. + if (ITERATIONS >= 6) { + expect([...ranOps].sort()).toEqual([...new Set(OPS)].sort()) + } + }, 60_000) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts b/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts new file mode 100644 index 000000000000..d6bb5fdda26f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' + +/** A worktree of the fixture's registered repo, so load keeps its rows. */ +export const FIXTURE_GIT_WORKTREE_ID = 'repo-local::/fixture/local' +export const FIXTURE_FOLDER_WORKTREE_ID = 'repo-local::/fixture/local::workspace:folder-1' + +/** The cutover fixture profile with no terminal session yet. */ +export function emptyTerminalSessionProfile(): string { + const state = buildProfileStateCutoverFixture() + return JSON.stringify({ + ...state, + // A fixed install id: load mints a random one when it is missing. + settings: { + ...state.settings, + telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' } + }, + workspaceSession: getDefaultWorkspaceSession(), + workspaceSessionsByHostId: {} + }) +} + +/** Opens a real Store on SQLite in `directory`, seeding it first when `seed` is given. */ +export async function openTopologyStore(directory: string, seed?: string): Promise { + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + if (seed) { + inner.writeSerializedState(Buffer.from(seed)) + } + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new DelayedAuthority(inner) + }) + await store.flushPendingOrThrowAsync() + return store +} + +/** Quit and relaunch: drain pending writes, close, and load the profile cold. */ +export async function reopenTopologyStore(store: Store, directory: string): Promise { + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + return openTopologyStore(directory) +} diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts new file mode 100644 index 000000000000..0ac2a9d5f647 --- /dev/null +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -0,0 +1,284 @@ +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { useAppStore } from '@/store' +import { registerTerminalPresentationIpcBridge } from '@/hooks/ipc-events/terminal-presentation-ipc-bridge' +import { getDefaultSettings } from '../../../shared/constants' +import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import { activateAndRevealWorktree } from './worktree-activation' +import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' + +/** + * STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a + * split of the (unmounted) primary tab, then the first activation of that worktree. When main's + * split reveal does not reach the window, the activation sweep mints a second tab for the setup + * PTY, so two saved panes own one terminal and one of them freezes. + */ + +const initialState = useAppStore.getState() +const TAB_B = 'b3f1c1de-5e0f-4a51-9d0c-7a8e2a9f6b11' +const PRIMARY_LEAF = '11111111-1111-4111-8111-111111111111' +const SETUP_LEAF = '22222222-2222-4222-8222-222222222222' + +type CreateTerminalListener = Parameters[0] + +function baseState(): Partial { + const worktree = makeWorktree() + return { + repos: [ + { + id: worktree.repoId, + path: path.join(path.sep, 'workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + terminalLayoutsByTabId: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + settings: { + ...getDefaultSettings(path.join(path.sep, 'home')), + agentCmdOverrides: {}, + defaultTuiAgent: 'codex', + setupScriptLaunchMode: 'split-vertical' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } +} + +/** The host's census: both panes are unmounted, so it reports them unowned with their record. */ +function orphanRow(ptyId: string, leafId: string): RuntimeTerminalSummary { + const worktree = makeWorktree() + return { + handle: `handle-${leafId}`, + ptyId, + orphaned: true, + recordedPaneKey: `${TAB_B}:${leafId}`, + worktreeId: worktree.id, + worktreePath: worktree.path, + branch: worktree.branch ?? 'main', + tabId: `pty:${ptyId}`, + leafId: `pty:${ptyId}`, + title: 'Terminal', + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + } +} + +function stubHost( + primaryPtyId: string, + setupPtyId: string +): { reveal: () => CreateTerminalListener } { + const worktree = makeWorktree() + let listener: CreateTerminalListener | undefined + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.list') { + return { + ok: true, + result: { + worktree: worktree.id, + publicationEpoch: 'sta-9417', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + } + } + if (method === 'terminal.list') { + return { + ok: true, + result: { + terminals: [orphanRow(primaryPtyId, PRIMARY_LEAF), orphanRow(setupPtyId, SETUP_LEAF)], + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: [] } + } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const listSessions = vi.fn(async () => + [primaryPtyId, setupPtyId].map((id) => ({ + id, + cwd: worktree.path, + title: 'Terminal', + agentOwnership: 'absent' as const + })) + ) + vi.stubGlobal('window', { + api: { + runtime: { call: runtimeCall }, + pty: { listSessions }, + ui: { + onCreateTerminal: (next: CreateTerminalListener) => { + listener = next + return () => {} + }, + onRequestTerminalTabMount: () => () => {}, + replyTerminalCreate: vi.fn() + } + }, + dispatchEvent: vi.fn() + }) + return { + reveal: () => { + if (!listener) { + throw new Error('presentation bridge did not register') + } + return listener + } + } +} + +/** Main's background reveal of the primary terminal: the tab exists here but never mounted. */ +function seedRevealedPrimaryTab(worktreeId: string, primaryPtyId: string): void { + useAppStore.getState().createTab(worktreeId, undefined, undefined, { + id: TAB_B, + initialLeafId: PRIMARY_LEAF, + initialPtyId: primaryPtyId, + activate: false, + recordInteraction: false + }) +} + +function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] { + if (!node) { + return [] + } + return node.type === 'leaf' + ? [node.leafId] + : [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)] +} + +function setupSplitReveal( + requestId: string, + worktreeId: string, + setupPtyId: string +): Parameters[0] { + return { + requestId, + worktreeId, + ptyId: setupPtyId, + activate: false, + surfaceOwner: false, + tabId: TAB_B, + leafId: SETUP_LEAF, + splitFromLeafId: PRIMARY_LEAF, + splitDirection: 'vertical' + } +} + +/** ptyId → every tab:leaf in the worktree that holds it. */ +function ownersByPty(worktreeId: string): Map { + const state = useAppStore.getState() + const owners = new Map() + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + const layout = state.terminalLayoutsByTabId[tab.id] + for (const leafId of leafIdsInOrder(layout?.root ?? null)) { + const ptyId = layout?.ptyIdsByLeafId?.[leafId] + if (ptyId) { + owners.set(ptyId, [...(owners.get(ptyId) ?? []), `${tab.id}:${leafId}`]) + } + } + } + return owners +} + +/** The sidebar click, then the first-activation sweep the terminal watcher runs (STA-9417 step 2). */ +async function activateFirstTime(worktreeId: string): Promise { + activateAndRevealWorktree(worktreeId) + await expect(gateWorktreeAgentActivation(worktreeId)).resolves.toBe('adopted') +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState(initialState, true) +}) + +describe('STA-9417: setup split of an unmounted tab, then first activation', () => { + async function runScenario(revealSplit: boolean): Promise { + const worktree = makeWorktree() + const primaryPtyId = `${worktree.id}@@primary` + const setupPtyId = `${worktree.id}@@setup` + useAppStore.setState(baseState()) + const host = stubHost(primaryPtyId, setupPtyId) + registerTerminalPresentationIpcBridge([]) + seedRevealedPrimaryTab(worktree.id, primaryPtyId) + + if (revealSplit) { + host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId)) + } + await activateFirstTime(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs.map((tab) => tab.id)).toEqual([TAB_B]) + expect(Object.fromEntries(ownersByPty(worktree.id))).toEqual({ + [primaryPtyId]: [`${TAB_B}:${PRIMARY_LEAF}`], + [setupPtyId]: [`${TAB_B}:${SETUP_LEAF}`] + }) + } + + it('ends with one tab and one owner per terminal when main reveals the split', async () => { + await runScenario(true) + }) + + // STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs. + it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { + await runScenario(false) + }) + + // STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate. + it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { + let duplicates = 0 + for (let run = 0; run < 8; run++) { + const worktree = makeWorktree() + const primaryPtyId = `${worktree.id}@@primary-${run}` + const setupPtyId = `${worktree.id}@@setup-${run}` + useAppStore.setState(initialState, true) + useAppStore.setState(baseState()) + const host = stubHost(primaryPtyId, setupPtyId) + registerTerminalPresentationIpcBridge([]) + seedRevealedPrimaryTab(worktree.id, primaryPtyId) + // Odd runs lose the reveal, as the natural repro did. + if (run % 2 === 0) { + host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId)) + } + await activateFirstTime(worktree.id) + duplicates += [...ownersByPty(worktree.id).values()].filter( + (owners) => owners.length > 1 + ).length + duplicates += (useAppStore.getState().tabsByWorktree[worktree.id] ?? []).length - 1 + } + expect(duplicates).toBe(0) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts new file mode 100644 index 000000000000..141d3141b641 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { createTestStore, makeTab, makeWorktree } from '../slices/store-test-helpers' +import { createStoreSessionMockApi } from '../slices/store-session-test-harness' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +createStoreSessionMockApi() + +// "Is this pane running" reads the liveness map; an exit leaves the pane's layout binding in place +// for its one stale reattach. The mirror refactor keeps this split: liveness stays in the window, +// the binding becomes main's. + +const WORKTREE = 'repo1::/path/wt1' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + }, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' } + } +} + +function storeWithSplitTab() { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt1', hostId: 'local' })] + }, + tabsByWorktree: { + [WORKTREE]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE, ptyId: 'pty-right' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] }, + terminalLayoutsByTabId: { 'tab-1': splitLayout() } + }) + return store +} + +describe('exited pane in a split', () => { + it('drops only the exited PTY from liveness and keeps every layout binding', () => { + const store = storeWithSplitTab() + + // Ctrl-D in the left pane. + store.getState().clearTabPtyId('tab-1', 'pty-left') + + const state = store.getState() + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-right']) + expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(true) + expect(state.terminalLayoutsByTabId['tab-1']).toEqual(splitLayout()) + }) + + it('reads the tab as not running once both panes exit, with the bindings still saved', () => { + const store = storeWithSplitTab() + + store.getState().clearTabPtyId('tab-1', 'pty-left') + store.getState().clearTabPtyId('tab-1', 'pty-right') + + const state = store.getState() + expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(false) + expect(state.tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBeNull() + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ + [LEFT]: 'pty-left', + [RIGHT]: 'pty-right' + }) + }) +}) From cd701606646f66fcf04703fa75af42abc92a6df1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 01:15:28 -0400 Subject: [PATCH 02/75] refactor(terminal): renderer senders attach placement to pty spawn, report-only The window now says where each fresh terminal goes: the first pane of a tab sends new-tab with the tab's creation fields, a split sends the parent leaf, direction, ratio and the tree after the split, background launches send the same, and a Codex restart that rebuilds a rootless layout sends root. A reattach sends nothing. Main still only records whether placement names the tab its binding write picks; its 'absent' value now counts only new leaves without placement, so it reads the fallback mint and graft directly. Extends the shared placement type with optional row, ratio and proposedRoot, each dropped alone if malformed, so older and newer peers keep working. --- .../pty-binding-placement-inertness.test.ts | 2 +- .../terminal-pane-placement-agreement.ts | 11 +- .../codex-detached-pane-restart.test.ts | 4 +- .../codex-detached-pane-restart.ts | 30 +++--- .../terminal-pane/ipc-pty-spawn-request.ts | 2 + .../pty-connection-fresh-spawn-guards.test.ts | 12 +++ .../terminal-pane/pty-connection-types.ts | 2 + .../pty-connection/pty-input-recovery.ts | 1 + .../pty-transport-connect-spawn.test.ts | 20 ++++ .../terminal-pane/pty-transport-types.ts | 3 + .../terminal-pane-pane-created.ts | 10 ++ .../terminal-pane-spawn-placement.test.ts | 101 ++++++++++++++++++ .../terminal-pane-spawn-placement.ts | 23 ++++ .../src/lib/background-pane-exit-output.ts | 46 ++++++++ .../launch-agent-background-session.test.ts | 6 +- .../lib/launch-agent-background-session.ts | 4 + ...unch-worktree-background-terminals.test.ts | 35 +++++- .../launch-worktree-background-terminals.ts | 82 ++++++-------- .../pane-manager-pane-creation.ts | 2 +- .../pane-manager-spawn-placement.test.ts | 85 +++++++++++++++ .../lib/pane-manager/pane-manager-types.ts | 5 +- .../pane-manager/pane-overlay-focus.test.ts | 2 +- .../lib/pane-manager/pane-split-close.test.ts | 5 +- .../src/lib/pane-manager/pane-split-close.ts | 16 ++- .../src/lib/terminal-pane-placement-row.ts | 29 +++++ src/shared/terminal-pane-placement.test.ts | 44 ++++++++ src/shared/terminal-pane-placement.ts | 30 +++++- src/shared/workspace-session-schema.ts | 4 +- 28 files changed, 528 insertions(+), 88 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts create mode 100644 src/renderer/src/lib/background-pane-exit-output.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts create mode 100644 src/renderer/src/lib/terminal-pane-placement-row.ts diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index 2ad1c882f23b..ce412cf4633d 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -232,7 +232,7 @@ describe('placement on the binding write is inert', () => { it(`${scenario.name}: memory and saved state match a write without placement`, async () => { const baseline = await bindAndSave(scenario, undefined) expect(baseline.result).toBe(true) - expect(placementAttributes).toEqual(['absent']) + expect(placementAttributes).toEqual([scenario.prebind ? 'leaf_present' : 'absent']) const cases = scenario.placements('') for (const [index, [, agreement]] of cases.entries()) { const placed = await bindAndSave(scenario, (leaf) => scenario.placements(leaf)[index][0]) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts index 0a104592d94f..3376859499e2 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -2,7 +2,10 @@ import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placem import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' -/** Low-cardinality: it lands on the `persistence.pty-binding` span. */ +/** + * Low-cardinality: it lands on the `persistence.pty-binding` span. `absent` counts new leaves with + * no placement, which only the mint and graft can place; it should read zero before they go. + */ export type TerminalPanePlacementAgreement = | 'absent' | 'leaf_present' @@ -24,14 +27,14 @@ export function terminalPanePlacementAgreement( tabId: string, leafId: string ): TerminalPanePlacementAgreement { - if (!placement) { - return 'absent' - } const tabExists = session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true const root = session.terminalLayoutsByTabId?.[tabId]?.root ?? null if (tabExists && layoutContainsLeafId(root, leafId)) { return 'leaf_present' } + if (!placement) { + return 'absent' + } switch (placement.kind) { case 'new-tab': return tabExists ? 'tab_exists' : 'agrees' diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts index 8aea14403649..1aa4adfcd9b9 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts @@ -126,6 +126,8 @@ describe('codex detached pane restart executor', () => { initiallyHidden: true }) ) + // The leaf is in the root, so main already knows where it goes. + expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]).not.toHaveProperty('placement') expect(window.api.pty.getSize).not.toHaveBeenCalled() expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]?.env).toEqual( expect.objectContaining({ @@ -310,7 +312,7 @@ describe('codex detached pane restart executor', () => { }) ) expect(window.api.pty.spawn).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ replacesPtyId: OLD_PTY }) + expect.objectContaining({ replacesPtyId: OLD_PTY, placement: { kind: 'root' } }) ) }) diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 6c3228ae10bd..027e6ee156ab 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -10,7 +10,7 @@ */ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' @@ -25,6 +25,7 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher' +import { collectLeafIds } from './terminal-pane-layout-tree' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry' @@ -224,6 +225,9 @@ async function executeDetachedCodexPaneRestart( tabId: tab.id, leafId, replacesPtyId: ptyId, + ...(restartReplacesLayoutRoot(currentState.terminalLayoutsByTabId[tab.id], leafId) + ? { placement: { kind: 'root' as const } } + : {}), ...(tab.shellOverride ? { shellOverride: tab.shellOverride } : {}), ...(projectRuntime ? { projectRuntime } : {}), initiallyHidden: true @@ -307,27 +311,23 @@ function reopenCurrentCodexRestartPrompt(located: LocatedCodexPane, replacedPtyI } } -function layoutRootContainsLeaf( - node: TerminalPaneLayoutNode | null | undefined, +// Why: mount replays panes from the root — a root that doesn't name this leaf +// mints a fresh one and silently orphans the replacement PTY. Rewriting is +// only safe when this is the tab's sole bound pane; a split keeps its root. +function restartReplacesLayoutRoot( + layout: TerminalLayoutSnapshot | undefined, leafId: string ): boolean { - if (!node) { - return false - } - if (node.type === 'leaf') { - return node.leafId === leafId - } - return layoutRootContainsLeaf(node.first, leafId) || layoutRootContainsLeaf(node.second, leafId) + return ( + !collectLeafIds(layout?.root).includes(leafId) && + Object.keys(layout?.ptyIdsByLeafId ?? {}).every((id) => id === leafId) + ) } function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: string): void { const store = useAppStore.getState() const layout = store.terminalLayoutsByTabId[tabId] - const boundLeafIds = Object.keys(layout?.ptyIdsByLeafId ?? {}) - // Why: mount replays panes from the root — a root that doesn't name this leaf - // mints a fresh one and silently orphans the replacement PTY. Rewriting is - // only safe when this is the tab's sole bound pane; a split keeps its root. - if (!layoutRootContainsLeaf(layout?.root, leafId) && boundLeafIds.every((id) => id === leafId)) { + if (restartReplacesLayoutRoot(layout, leafId)) { store.setTabLayout( tabId, singlePaneLayoutSnapshot(leafId, newPtyId, layout?.titlesByLeafId?.[leafId] ?? null) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index 667865deb7e1..7961fed61ec2 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -85,6 +85,7 @@ export async function spawnIpcPty( worktreeId, tabId, leafId, + placement, shellOverride, projectRuntime, terminalColorQueryReplies, @@ -131,6 +132,7 @@ export async function spawnIpcPty( worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), + ...(placement && !admittedSessionId ? { placement } : {}), ...(replacesPtyId ? { replacesPtyId } : {}), ...(shellOverride ? { shellOverride } : {}), ...(projectRuntime ? { projectRuntime } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 1f0935b8085b..adf9f511a2c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -237,6 +237,18 @@ describe('connectPanePty', () => { expect(retain()).toBe(false) }) + it('carries the pane placement onto its transport', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport()) + const placement = { kind: 'new-tab' } as const + const deps = createDeps({ tabId: 'tab-placement', placement }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]).toMatchObject({ placement }) + }) + it('fresh-spawns normally when the pane worktree is not being deleted', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 6e8d340b3a37..b45b6478725d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -9,6 +9,7 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, @@ -60,6 +61,7 @@ export type PtyConnectionDeps = { cwd?: string /** Delays a fresh split's spawn without delaying its renderer pane. */ cwdPromise?: Promise + placement?: TerminalPanePlacement /** Input handed off from a predecessor mount of the same deferred split. */ preconnectInput?: readonly PtyPreconnectInputEntry[] /** Captures newly retained input for a remount-safe deferred split handoff. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index aa226a3a78ee..d6f8a6de9c62 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -82,6 +82,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { // and the main-side guard short-circuits. tabId: session.deps.tabId, leafId: session.pane.leafId, + ...(session.deps.placement ? { placement: session.deps.placement } : {}), activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 42171d9368d1..0f788dd7091c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -278,6 +278,26 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('sends placement on a fresh spawn but not on a session reattach', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const placement = { + kind: 'split', + parentLeafId: '11111111-1111-4111-8111-111111111111', + direction: 'horizontal' + } as const + + const fresh = createIpcPtyTransport({ placement }) + await fresh.connect({ url: '', callbacks: {} }) + expect(spawn).toHaveBeenLastCalledWith(expect.objectContaining({ placement })) + fresh.disconnect() + + const reattach = createIpcPtyTransport({ placement }) + await reattach.connect({ url: '', callbacks: {}, sessionId: 'session-1' }) + expect(spawn).toHaveBeenLastCalledWith(expect.not.objectContaining({ placement })) + reattach.disconnect() + }) + it('returns startup cwd fallback metadata to the connection layer', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 4b2de188fce3..c45457dfbc1e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -13,6 +13,7 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { PtyDataMeta } from './pty-dispatcher' import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer' @@ -280,6 +281,8 @@ export type IpcPtyTransportOptions = { worktreeId?: string tabId?: string leafId?: string + /** Sent on fresh spawns only; a reattach names a PTY whose leaf main already knows. */ + placement?: TerminalPanePlacement activate?: boolean shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index f422f99fb537..4e0d7bd960cd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -21,6 +21,7 @@ import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' import { guardParserHandler } from './terminal-parser-handler-guard' import { isPaneReplaying } from './replay-guard' import { connectPanePty } from './pty-connection' +import { completePaneSpawnPlacement } from './terminal-pane-spawn-placement' import { createQueuedStartupConsumer, resolvePaneSeedCwd, @@ -202,6 +203,15 @@ export function createTerminalPaneCreatedHandler( : {}), ...(effectiveSpawnHints?.cwd ? { cwd: effectiveSpawnHints.cwd } : {}), ...(effectiveSpawnHints?.cwdPromise ? { cwdPromise: effectiveSpawnHints.cwdPromise } : {}), + ...(effectiveSpawnHints?.placement + ? { + placement: completePaneSpawnPlacement(effectiveSpawnHints.placement, { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + container: deps.containerRef.current + }) + } + : {}), restoredPtyIdByLeafId: effectiveSpawnHints?.ptyId ? { ...ptyDeps.restoredPtyIdByLeafId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts new file mode 100644 index 000000000000..eec7df33470b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' + +const TAB = { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: 'Build', + color: '#f97316', + sortOrder: 1, + createdAt: 42, + shellOverride: 'wsl.exe', + isPinned: true +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ tabsByWorktree: { 'wt-1': [TAB] } }) } +})) +// Why: happy-dom has no canvas, so xterm cannot open; placement never touches the terminal. +vi.mock('@/lib/pane-manager/pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('@/lib/pane-manager/pane-manager') +const { completePaneSpawnPlacement } = await import('./terminal-pane-spawn-placement') + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +describe('completePaneSpawnPlacement', () => { + const root = document.createElement('div') + + afterEach(() => { + root.replaceChildren() + }) + + it('adds the tab creation fields to a new tab', () => { + const placement = completePaneSpawnPlacement( + { kind: 'new-tab' }, + { worktreeId: 'wt-1', tabId: 'tab-1', container: root } + ) + + expect(placement).toEqual({ + kind: 'new-tab', + row: { + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: 'Build', + color: '#f97316', + createdAt: 42, + shellOverride: 'wsl.exe' + } + }) + }) + + it('leaves a new tab bare when the store no longer has it', () => { + expect( + completePaneSpawnPlacement( + { kind: 'new-tab' }, + { worktreeId: 'wt-1', tabId: 'tab-gone', container: root } + ) + ).toEqual({ kind: 'new-tab' }) + }) + + it('adds the mounted tree after the split', () => { + let placement: TerminalPanePlacement | undefined + const manager = new PaneManager(root, { + linkOpenHint: () => '', + onPaneCreated: (_pane, hints) => { + placement = hints?.placement + ? completePaneSpawnPlacement(hints.placement, { + worktreeId: 'wt-1', + tabId: 'tab-1', + container: root + }) + : undefined + } + }) + const first = manager.createInitialPane({ leafId: LEAF_A }) + manager.splitPane(first.id, 'horizontal', { leafId: LEAF_B, ratio: 0.3 }) + + expect(placement).toEqual({ + kind: 'split', + parentLeafId: LEAF_A, + direction: 'horizontal', + ratio: 0.3, + proposedRoot: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B }, + ratio: 0.3 + } + }) + manager.destroy() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts new file mode 100644 index 000000000000..920e27865464 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts @@ -0,0 +1,23 @@ +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' +import { useAppStore } from '@/store' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' +import { serializePaneTree } from './layout-serialization' + +/** Adds what only the mounted tab knows: the new tab's row, or the tree after the split. */ +export function completePaneSpawnPlacement( + placement: TerminalPanePlacement, + pane: { worktreeId: string; tabId: string; container: HTMLElement | null } +): TerminalPanePlacement { + if (placement.kind === 'new-tab') { + const tab = useAppStore + .getState() + .tabsByWorktree[pane.worktreeId]?.find((entry) => entry.id === pane.tabId) + return tab ? { ...placement, row: terminalPanePlacementRow(tab) } : placement + } + if (placement.kind === 'split') { + const top = pane.container?.firstElementChild + const proposedRoot = top instanceof HTMLElement ? serializePaneTree(top) : null + return proposedRoot ? { ...placement, proposedRoot } : placement + } + return placement +} diff --git a/src/renderer/src/lib/background-pane-exit-output.ts b/src/renderer/src/lib/background-pane-exit-output.ts new file mode 100644 index 000000000000..5d077313bf1e --- /dev/null +++ b/src/renderer/src/lib/background-pane-exit-output.ts @@ -0,0 +1,46 @@ +import { + registerEagerPtyBuffer, + type EagerPtyHandle +} from '@/components/terminal-pane/pty-dispatcher' +import { useAppStore } from '@/store' + +function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void { + const store = useAppStore.getState() + const layout = store.terminalLayoutsByTabId[tabId] + if (!layout) { + return + } + const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout + const nextPtyIds = { ...existingPtyIds } + delete nextPtyIds[leafId] + const trimmedOutput = output.trim() ? output : '' + store.setTabLayout(tabId, { + ...rest, + ...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}), + ...(trimmedOutput + ? { + buffersByLeafId: { + ...existingBuffers, + [leafId]: output + } + } + : existingBuffers + ? { buffersByLeafId: existingBuffers } + : {}) + }) +} + +// Why the incarnation: a relay-recycled id can hold the previous owner's exit, and draining that +// into this handler tears the pane down seconds after it launched. +export function registerBackgroundPaneBuffer( + tabId: string, + leafId: string, + pane: { ptyId: string; incarnationId?: string } +): void { + let eagerBuffer: EagerPtyHandle | null = null + const onExit = (exitPtyId: string): void => { + persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '') + useAppStore.getState().clearTabPtyId(tabId, exitPtyId) + } + eagerBuffer = registerEagerPtyBuffer(pane.ptyId, onExit, pane.incarnationId) +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 23606a83776e..5cf71a04e1eb 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -129,7 +129,11 @@ describe('launchAgentBackgroundSession', () => { }), connectionId: null, worktreeId: 'wt-1', - tabId + tabId, + placement: { + kind: 'new-tab', + row: { launchAgent: 'claude', customTitle: 'Nightly audit' } + } }) ) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 3e997d32e51e..9127479c20e8 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -191,6 +191,10 @@ export async function launchAgentBackgroundSession( worktreeId, tabId: reservedTabId, leafId, + placement: { + kind: 'new-tab', + row: { launchAgent: agent, ...(title ? { customTitle: title } : {}) } + }, telemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: launchSource ?? 'unknown', diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index ea463464751e..eb19560df833 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -87,6 +87,16 @@ describe('launchWorktreeBackgroundTerminals', () => { state.tabsByWorktree['wt-1'].push(tab) return tab }) + // Placement reads the row back from the store, so title and color writes must land there. + const patchTab = (tabId: string, patch: object): void => { + state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].map((tab) => + tab.id === tabId ? { ...tab, ...patch } : tab + ) + } + mockSetTabCustomTitle.mockImplementation((tabId: string, customTitle: string) => + patchTab(tabId, { customTitle }) + ) + mockSetTabColor.mockImplementation((tabId: string, color: string) => patchTab(tabId, { color })) mockCloseTab.mockImplementation((tabId: string) => { state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId) }) @@ -136,7 +146,11 @@ describe('launchWorktreeBackgroundTerminals', () => { connectionId: null, worktreeId: 'wt-1', tabId: 'tab-1', - leafId: '00000000-0000-4000-8000-000000000001' + leafId: '00000000-0000-4000-8000-000000000001', + placement: { + kind: 'new-tab', + row: expect.objectContaining({ customTitle: 'Dev', color: '#f97316' }) + } }) ) expect(mockSpawn).toHaveBeenNthCalledWith( @@ -145,7 +159,8 @@ describe('launchWorktreeBackgroundTerminals', () => { command: 'bash /tmp/setup.sh', env: expect.objectContaining({ ORCA_WORKTREE_PATH: '/repo/worktree' }), tabId: 'tab-2', - leafId: '00000000-0000-4000-8000-000000000002' + leafId: '00000000-0000-4000-8000-000000000002', + placement: { kind: 'new-tab', row: expect.objectContaining({ customTitle: 'Setup' }) } }) ) expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') @@ -168,7 +183,8 @@ describe('launchWorktreeBackgroundTerminals', () => { 1, expect.objectContaining({ tabId: 'tab-1', - leafId: '00000000-0000-4000-8000-000000000001' + leafId: '00000000-0000-4000-8000-000000000001', + placement: { kind: 'new-tab', row: expect.any(Object) } }) ) expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command') @@ -177,7 +193,18 @@ describe('launchWorktreeBackgroundTerminals', () => { expect.objectContaining({ command: 'bash /tmp/setup.sh', tabId: 'tab-1', - leafId: '00000000-0000-4000-8000-000000000002' + leafId: '00000000-0000-4000-8000-000000000002', + placement: { + kind: 'split', + parentLeafId: '00000000-0000-4000-8000-000000000001', + direction: 'horizontal', + proposedRoot: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000001' }, + second: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000002' } + } + } }) ) expect(mockSetTabLayout).toHaveBeenLastCalledWith( diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 791504d6948a..b5b31b5f97c8 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -1,12 +1,10 @@ -import { - registerEagerPtyBuffer, - type EagerPtyHandle -} from '@/components/terminal-pane/pty-dispatcher' import { createBrowserUuid } from '@/lib/browser-uuid' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' +import { registerBackgroundPaneBuffer } from '@/lib/background-pane-exit-output' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { makePaneKey } from '../../../shared/stable-pane-id' @@ -14,7 +12,11 @@ import { buildSetupRunnerCommand, getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' -import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../shared/terminal-tab-types' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch @@ -62,6 +64,19 @@ function buildPaneEnv( } } +function buildSplitRoot( + firstLeafId: string, + secondLeafId: string, + direction: 'horizontal' | 'vertical' +): TerminalPaneLayoutNode { + return { + type: 'split', + direction, + first: { type: 'leaf', leafId: firstLeafId }, + second: { type: 'leaf', leafId: secondLeafId } + } +} + function buildSplitLayout( first: BackgroundPane, second: BackgroundPane, @@ -69,12 +84,7 @@ function buildSplitLayout( secondTitle: string ): TerminalLayoutSnapshot { return { - root: { - type: 'split', - direction, - first: { type: 'leaf', leafId: first.leafId }, - second: { type: 'leaf', leafId: second.leafId } - }, + root: buildSplitRoot(first.leafId, second.leafId, direction), activeLeafId: first.leafId, expandedLeafId: null, ptyIdsByLeafId: { @@ -87,43 +97,6 @@ function buildSplitLayout( } } -function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void { - const store = useAppStore.getState() - const layout = store.terminalLayoutsByTabId[tabId] - if (!layout) { - return - } - const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout - const nextPtyIds = { ...existingPtyIds } - delete nextPtyIds[leafId] - const trimmedOutput = output.trim() ? output : '' - store.setTabLayout(tabId, { - ...rest, - ...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}), - ...(trimmedOutput - ? { - buffersByLeafId: { - ...existingBuffers, - [leafId]: output - } - } - : existingBuffers - ? { buffersByLeafId: existingBuffers } - : {}) - }) -} - -// Why the incarnation: a relay-recycled id can hold the previous owner's exit, and draining that -// into this handler tears the pane down seconds after it launched. -function registerBackgroundPaneBuffer(tabId: string, leafId: string, pane: SpawnedPane): void { - let eagerBuffer: EagerPtyHandle | null = null - const onExit = (exitPtyId: string): void => { - persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '') - useAppStore.getState().clearTabPtyId(tabId, exitPtyId) - } - eagerBuffer = registerEagerPtyBuffer(pane.ptyId, onExit, pane.incarnationId) -} - function buildSetupCommand(setup: WorktreeSetupLaunch): string { // Why: background setup tabs can launch later, so they must reuse the same shell chosen when the runner was written. return buildSetupRunnerCommand( @@ -141,6 +114,7 @@ async function spawnPane(args: { connectionId: string | null tabId: string leafId: string + placement: TerminalPanePlacement command?: string env?: Record }): Promise { @@ -153,7 +127,8 @@ async function spawnPane(args: { connectionId: args.connectionId, worktreeId: args.worktree.id, tabId: args.tabId, - leafId: args.leafId + leafId: args.leafId, + placement: args.placement }) return { ptyId: result.id, @@ -180,6 +155,8 @@ async function createBackgroundTab(args: { const leafId = createBrowserUuid() store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId)) + const created = + useAppStore.getState().tabsByWorktree[args.worktree.id]?.find(({ id }) => id === tab.id) ?? tab let pane: SpawnedPane try { pane = await spawnPane({ @@ -187,6 +164,7 @@ async function createBackgroundTab(args: { connectionId: args.connectionId, tabId: tab.id, leafId, + placement: { kind: 'new-tab', row: terminalPanePlacementRow(created) }, command: args.launch.command, env: args.launch.env }) @@ -223,6 +201,12 @@ async function addSetupSplit(args: { connectionId: args.connectionId, tabId: args.tab.tabId, leafId: setupLeafId, + placement: { + kind: 'split', + parentLeafId: args.tab.primary.leafId, + direction: args.direction, + proposedRoot: buildSplitRoot(args.tab.primary.leafId, setupLeafId, args.direction) + }, command: buildSetupCommand(args.setup), env: args.setup.envVars }) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts index 129685726da4..b86a28fe054f 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -30,7 +30,7 @@ export function createInitialManagedPane( focusPanePreservingOverlays(pane) } - host.publishPaneCreated(pane) + host.publishPaneCreated(pane, { placement: { kind: 'new-tab' } }) return toPublicPane(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts new file mode 100644 index 000000000000..cfff1c078e4c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { PaneManagerOptions } from './pane-manager-types' +import { serializePaneTree } from '@/components/terminal-pane/layout-serialization' + +// Why: happy-dom has no canvas, so xterm cannot open; placement never touches the terminal. +vi.mock('./pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('./pane-manager') + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +describe('PaneManager spawn placement hints', () => { + let root: HTMLDivElement + let onPaneCreated: Mock> + let manager: InstanceType + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', () => {}) + root = document.createElement('div') + document.body.appendChild(root) + onPaneCreated = vi.fn() + manager = new PaneManager(root, { linkOpenHint: () => '', onPaneCreated }) + }) + + afterEach(() => { + manager.destroy() + root.remove() + vi.unstubAllGlobals() + }) + + it('marks a tab first pane as a new tab', () => { + const pane = manager.createInitialPane({ leafId: LEAF_A }) + + expect(onPaneCreated).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ id: pane.id, leafId: LEAF_A }), + { placement: { kind: 'new-tab' } } + ) + }) + + it('names the split pane and direction alongside the other spawn hints', () => { + const first = manager.createInitialPane({ leafId: LEAF_A }) + onPaneCreated.mockClear() + + manager.splitPane(first.id, 'horizontal', { leafId: LEAF_B, cwd: '/repo/app' }) + + expect(onPaneCreated).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ leafId: LEAF_B }), + { + cwd: '/repo/app', + placement: { kind: 'split', parentLeafId: LEAF_A, direction: 'horizontal' } + } + ) + }) + + it('names the anchor pane for a split placed before a mounted subtree', () => { + const first = manager.createInitialPane({ leafId: LEAF_A }) + manager.splitPane(first.id, 'vertical', { leafId: LEAF_B }) + onPaneCreated.mockClear() + + manager.splitPaneAroundLeafIds([LEAF_A, LEAF_B], first.id, 'horizontal', { + leafId: LEAF_C, + placement: 'before' + }) + + expect(onPaneCreated).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ leafId: LEAF_C }), + { placement: { kind: 'split', parentLeafId: LEAF_A, direction: 'horizontal' } } + ) + // The placement type has no before/after, so it only names the anchor; the tree carries the order. + const top = root.firstElementChild + expect(top instanceof HTMLElement ? serializePaneTree(top) : null).toMatchObject({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_C }, + second: { type: 'split', direction: 'vertical' } + }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 80531b7fecf7..30fe50108445 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -9,6 +9,7 @@ import type { WebglAddon } from '@xterm/addon-webgl' import type { SerializeAddon } from '@xterm/addon-serialize' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import type { TerminalWebglAutoDecision } from './terminal-webgl-auto-policy' @@ -26,9 +27,11 @@ export type PaneSpawnHints = { cwd?: string cwdPromise?: Promise ptyId?: string + /** Where the pane's leaf sits; main reads it only for a leaf it does not know yet. */ + placement?: TerminalPanePlacement } -export type PaneSplitOptions = PaneSpawnHints & { +export type PaneSplitOptions = Omit & { ratio?: number leafId?: string } diff --git a/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts b/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts index 93c4a67f97b7..d335ce829a46 100644 --- a/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts @@ -72,7 +72,7 @@ describe.each(['initial', 'active'] as const)('%s pane focus', (operation) => { function focus(f: ReturnType, requested = true) { if (operation === 'initial') { createInitialManagedPane(f.host, { focus: requested }) - expect(f.publishPaneCreated).toHaveBeenCalledWith(f.pane) + expect(f.publishPaneCreated).toHaveBeenCalledWith(f.pane, { placement: { kind: 'new-tab' } }) } else { f.root.append(f.container) f.manager.setActivePane(f.pane.id, { focus: requested }) diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts index 098c5eb2c15e..d7f417f78e64 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts @@ -156,7 +156,10 @@ describe('splitManagedPane', () => { expect(result?.id).toBe(newPane.id) expect(setActivePaneId).toHaveBeenCalledWith(newPane.id) expect(newPane.terminal.focus).toHaveBeenCalledOnce() - expect(publishPaneCreated).toHaveBeenCalledWith(newPane, { cwdPromise }) + expect(publishPaneCreated).toHaveBeenCalledWith(newPane, { + cwdPromise, + placement: { kind: 'split', parentLeafId: existingPane.leafId, direction: 'vertical' } + }) }) it('prepares every pane under a moved mounted subtree for split reparenting', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 37ccf92b385d..acac56e6549b 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -67,7 +67,7 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null wrapInSplit(existingContainer, newPane.container, isVertical, divider, args.opts) args.setActivePaneId(newPane.id) - openSplitPane(args, newPane, args.opts?.cwd) + openSplitPane(args, newPane, existing.leafId, args.opts?.cwd) for (const movedPaneState of movedPaneStates) { scheduleSplitScrollRestore( @@ -139,6 +139,7 @@ function findManagedPanesInContainer( function openSplitPane( args: SplitManagedPaneArgs, newPane: ManagedPaneInternal, + parentLeafId: string, cwd?: string ): void { openTerminal(newPane, { @@ -151,12 +152,17 @@ function openSplitPane( updateMultiPaneState(args.getDragCallbacks()) // Why: forward one-shot spawn/adoption hints so the new pane inherits the // source cwd for local splits or attaches a runtime-spawned PTY for web splits. - const spawnHints = { + args.publishPaneCreated(newPane, { ...(cwd ? { cwd } : {}), ...(args.opts?.cwdPromise ? { cwdPromise: args.opts.cwdPromise } : {}), - ...(args.opts?.ptyId ? { ptyId: args.opts.ptyId } : {}) - } - args.publishPaneCreated(newPane, Object.keys(spawnHints).length > 0 ? spawnHints : undefined) + ...(args.opts?.ptyId ? { ptyId: args.opts.ptyId } : {}), + placement: { + kind: 'split', + parentLeafId, + direction: args.direction, + ...(args.opts?.ratio !== undefined ? { ratio: args.opts.ratio } : {}) + } + }) args.managerOptions.onLayoutChanged?.() } diff --git a/src/renderer/src/lib/terminal-pane-placement-row.ts b/src/renderer/src/lib/terminal-pane-placement-row.ts new file mode 100644 index 000000000000..53ae435a200d --- /dev/null +++ b/src/renderer/src/lib/terminal-pane-placement-row.ts @@ -0,0 +1,29 @@ +import type { TerminalPaneNewTabRow } from '../../../shared/terminal-pane-placement' +import type { TerminalTab } from '../../../shared/terminal-tab-types' + +export function terminalPanePlacementRow(tab: TerminalTab): TerminalPaneNewTabRow { + const { + title, + defaultTitle, + customTitle, + color, + createdAt, + startupCwd, + shellOverride, + quickCommandLabel, + launchAgent, + viewMode + } = tab + return { + title, + defaultTitle, + customTitle, + color, + createdAt, + startupCwd, + shellOverride, + quickCommandLabel, + launchAgent, + viewMode + } +} diff --git a/src/shared/terminal-pane-placement.test.ts b/src/shared/terminal-pane-placement.test.ts index 71e1c027ea48..1ff106cf846c 100644 --- a/src/shared/terminal-pane-placement.test.ts +++ b/src/shared/terminal-pane-placement.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { parseTerminalPanePlacement } from './terminal-pane-placement' const LEAF = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' describe('parseTerminalPanePlacement', () => { it('accepts each kind', () => { @@ -11,6 +12,49 @@ describe('parseTerminalPanePlacement', () => { expect(parseTerminalPanePlacement({ kind: 'root' })).toEqual({ kind: 'root' }) }) + it('keeps a new tab row and a split ratio and proposed tree', () => { + const newTab = { + kind: 'new-tab', + row: { + title: 'Terminal 2', + customTitle: null, + color: '#f97316', + createdAt: 1, + viewMode: 'chat' + } + } + const split = { + kind: 'split', + parentLeafId: LEAF, + direction: 'horizontal', + ratio: 0.3, + proposedRoot: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: OTHER_LEAF }, + second: { type: 'leaf', leafId: LEAF }, + ratio: 0.3 + } + } + expect(parseTerminalPanePlacement(newTab)).toEqual(newTab) + expect(parseTerminalPanePlacement(split)).toEqual(split) + }) + + it('drops only a malformed optional field', () => { + expect(parseTerminalPanePlacement({ kind: 'new-tab', row: { createdAt: 'now' } })).toEqual({ + kind: 'new-tab' + }) + expect( + parseTerminalPanePlacement({ + kind: 'split', + parentLeafId: LEAF, + direction: 'vertical', + ratio: 'half', + proposedRoot: { type: 'tree' } + }) + ).toEqual({ kind: 'split', parentLeafId: LEAF, direction: 'vertical' }) + }) + it('strips fields a newer sender adds', () => { expect(parseTerminalPanePlacement({ kind: 'root', future: 1 })).toEqual({ kind: 'root' }) expect(parseTerminalPanePlacement({ kind: 'new-tab', size: { cols: 120, rows: 40 } })).toEqual({ diff --git a/src/shared/terminal-pane-placement.ts b/src/shared/terminal-pane-placement.ts index 361922446ff3..9627f6d9edc6 100644 --- a/src/shared/terminal-pane-placement.ts +++ b/src/shared/terminal-pane-placement.ts @@ -1,7 +1,28 @@ import { z } from 'zod' import { isTerminalLeafId } from './stable-pane-id' +import { terminalPaneLayoutNodeSchema, terminalTabSchema } from './workspace-session-schema' -const NewTabPlacement = z.object({ kind: z.literal('new-tab') }) +// The creation fields of the tab the renderer made, so main need not wait for its save. +const NewTabRow = terminalTabSchema + .pick({ + title: true, + defaultTitle: true, + customTitle: true, + color: true, + createdAt: true, + startupCwd: true, + quickCommandLabel: true, + launchAgent: true, + viewMode: true + }) + .extend({ shellOverride: z.string().optional() }) + .partial() + +// Optional fields degrade alone: a malformed one is dropped, not the whole placement. +const NewTabPlacement = z.object({ + kind: z.literal('new-tab'), + row: NewTabRow.optional().catch(undefined) +}) const SplitPlacement = z.object({ kind: z.literal('split'), @@ -9,7 +30,10 @@ const SplitPlacement = z.object({ .string() .max(128) .refine((value): boolean => isTerminalLeafId(value)), - direction: z.enum(['horizontal', 'vertical']) + direction: z.enum(['horizontal', 'vertical']), + ratio: z.number().optional().catch(undefined), + /** The tab's tree after the split, which `parentLeafId` alone can't express (subtrees, before/after). */ + proposedRoot: terminalPaneLayoutNodeSchema.optional().catch(undefined) }) // An existing tab whose layout is still empty. @@ -21,6 +45,8 @@ const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ RootPlacement ]) +export type TerminalPaneNewTabRow = z.infer + /** Which tab and leaf a new PTY joins; a binding reads it only for a leaf it does not know yet. */ export type TerminalPanePlacement = z.infer diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 5274cb8be3e9..bf67f580a247 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -51,7 +51,7 @@ const workspaceKeySchema = z.custom( // Why: z.lazy + type annotation keeps the recursive inference working without // forcing zod to resolve the whole tree at definition time. Discriminated on `type` because a // plain union re-tries the leaf branch for every split node of every restored terminal layout. -const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(() => +export const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('type', [ z.object({ type: z.literal('leaf'), @@ -82,7 +82,7 @@ const terminalLayoutSnapshotSchema = z.object({ // ─── Terminal tab (legacy) ────────────────────────────────────────── -const terminalTabSchema = z.object({ +export const terminalTabSchema = z.object({ id: terminalTabIdSchema, ptyId: z.string().nullable(), worktreeId: z.string(), From 97a4165cf28a51c420fa84d7f4620cab85155264 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 01:30:56 -0400 Subject: [PATCH 03/75] fix(terminal): background agent placement row claims no launchAgent the tab lacks The adopted tab is created without launchAgent, so a row naming one would diverge from the renderer's tab once main applies placement. --- .../src/lib/launch-agent-background-session.test.ts | 5 +---- src/renderer/src/lib/launch-agent-background-session.ts | 6 ++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 5cf71a04e1eb..d665133b910d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -130,10 +130,7 @@ describe('launchAgentBackgroundSession', () => { connectionId: null, worktreeId: 'wt-1', tabId, - placement: { - kind: 'new-tab', - row: { launchAgent: 'claude', customTitle: 'Nightly audit' } - } + placement: { kind: 'new-tab', row: { customTitle: 'Nightly audit' } } }) ) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 9127479c20e8..ed6c58d82bcf 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -191,10 +191,8 @@ export async function launchAgentBackgroundSession( worktreeId, tabId: reservedTabId, leafId, - placement: { - kind: 'new-tab', - row: { launchAgent: agent, ...(title ? { customTitle: title } : {}) } - }, + // Why no launchAgent: the adopted tab is created without one, and the row must match it. + placement: { kind: 'new-tab', ...(title ? { row: { customTitle: title } } : {}) }, telemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: launchSource ?? 'unknown', From bd6890e0e5f67918eb034386999b85cc0ea192e2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 01:34:13 -0400 Subject: [PATCH 04/75] fix(terminal): a before-split publishes its pane with the final tree order wrapInSplit now places the new pane first itself, so the pane-created handler's proposedRoot sees the real post-split tree instead of the order before the subtree split moved it. --- .../pane-manager-spawn-placement.test.ts | 30 +++++++++++------ .../lib/pane-manager/pane-split-close.test.ts | 2 +- .../src/lib/pane-manager/pane-split-close.ts | 7 +++- .../lib/pane-manager/pane-subtree-split.ts | 32 ++----------------- .../src/lib/pane-manager/pane-tree-ops.ts | 12 ++++--- 5 files changed, 36 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts index cfff1c078e4c..348cbb1e5a9b 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts @@ -59,10 +59,14 @@ describe('PaneManager spawn placement hints', () => { ) }) - it('names the anchor pane for a split placed before a mounted subtree', () => { + it('publishes a split placed before a mounted subtree with its final order', () => { const first = manager.createInitialPane({ leafId: LEAF_A }) manager.splitPane(first.id, 'vertical', { leafId: LEAF_B }) - onPaneCreated.mockClear() + const treesAtCreation: unknown[] = [] + onPaneCreated.mockReset().mockImplementation(() => { + const top = root.firstElementChild + treesAtCreation.push(top instanceof HTMLElement ? serializePaneTree(top) : null) + }) manager.splitPaneAroundLeafIds([LEAF_A, LEAF_B], first.id, 'horizontal', { leafId: LEAF_C, @@ -73,13 +77,19 @@ describe('PaneManager spawn placement hints', () => { expect.objectContaining({ leafId: LEAF_C }), { placement: { kind: 'split', parentLeafId: LEAF_A, direction: 'horizontal' } } ) - // The placement type has no before/after, so it only names the anchor; the tree carries the order. - const top = root.firstElementChild - expect(top instanceof HTMLElement ? serializePaneTree(top) : null).toMatchObject({ - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: LEAF_C }, - second: { type: 'split', direction: 'vertical' } - }) + // The pane-created handler serializes proposedRoot from the DOM, so the order must be final then. + expect(treesAtCreation).toEqual([ + { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_C }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + } + } + ]) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts index d7f417f78e64..cd3b728cd24d 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts @@ -216,7 +216,7 @@ describe('splitManagedPane', () => { newPane.container, true, expect.anything(), - undefined + { ratio: undefined, newPaneFirst: undefined } ) expect(scheduleSplitScrollRestore).toHaveBeenCalledTimes(2) expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith( diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index acac56e6549b..6cf53fe9a2df 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -34,6 +34,8 @@ type SplitManagedPaneArgs = { direction: 'vertical' | 'horizontal' opts?: PaneSplitOptions sourceContainer?: HTMLElement + /** Before publishing, so the pane-created tree already has the final order. */ + newPaneFirst?: boolean panes: Map root: HTMLElement styleOptions: PaneStyleOptions @@ -65,7 +67,10 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null const movedPaneStates = prepareMovedPanesForSplit(existingContainer, existing, args.panes) - wrapInSplit(existingContainer, newPane.container, isVertical, divider, args.opts) + wrapInSplit(existingContainer, newPane.container, isVertical, divider, { + ratio: args.opts?.ratio, + newPaneFirst: args.newPaneFirst + }) args.setActivePaneId(newPane.id) openSplitPane(args, newPane, existing.leafId, args.opts?.cwd) diff --git a/src/renderer/src/lib/pane-manager/pane-subtree-split.ts b/src/renderer/src/lib/pane-manager/pane-subtree-split.ts index 37e75cfd3e64..e6be21fae506 100644 --- a/src/renderer/src/lib/pane-manager/pane-subtree-split.ts +++ b/src/renderer/src/lib/pane-manager/pane-subtree-split.ts @@ -47,11 +47,12 @@ export function splitPaneAroundMountedSubtree( if (!sourceContainer) { return null } - const createdPane = splitManagedPane({ + return splitManagedPane({ paneId: args.fallbackPaneId, direction: args.direction, opts: args.opts, sourceContainer, + newPaneFirst: args.opts?.placement === 'before', panes: args.panes, root: args.root, styleOptions: args.styleOptions, @@ -63,15 +64,6 @@ export function splitPaneAroundMountedSubtree( setActivePaneId: args.setActivePaneId, isDestroyed: args.isDestroyed }) - if (!createdPane || args.opts?.placement !== 'before') { - return createdPane - } - - const createdInternal = args.panes.get(createdPane.id) - if (createdInternal) { - placeCreatedPaneBeforeSource(sourceContainer, createdInternal.container) - } - return createdPane } function findMountedSubtreeContainer( @@ -125,23 +117,3 @@ function setsEqual(left: ReadonlySet, right: ReadonlySet): boole } return true } - -function placeCreatedPaneBeforeSource( - sourceContainer: HTMLElement, - createdContainer: HTMLElement -): boolean { - const split = createdContainer.parentElement - if (!split || sourceContainer.parentElement !== split) { - return false - } - const divider = Array.from(split.children).find( - (child): child is HTMLElement => - child instanceof HTMLElement && child.classList.contains('pane-divider') - ) - if (!divider) { - return false - } - - split.replaceChildren(createdContainer, divider, sourceContainer) - return true -} diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 778adfb6c15e..6b435177d2b0 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -245,14 +245,14 @@ export function removeDividers(parent: HTMLElement): void { /** * Create a flex split wrapper that replaces `existingContainer` in the DOM, - * then places [existing] [divider] [new] inside it. + * then places [existing] [divider] [new] inside it ([new] first when `newPaneFirst`). */ export function wrapInSplit( existingContainer: HTMLElement, newContainer: HTMLElement, isVertical: boolean, divider: HTMLElement, - opts?: { ratio?: number } + opts?: { ratio?: number; newPaneFirst?: boolean } ): void { const parent = existingContainer.parentElement if (!parent) { @@ -292,7 +292,9 @@ export function wrapInSplit( // Replace existing with split in the DOM, then build children parent.replaceChild(split, existingContainer) - split.appendChild(existingContainer) - split.appendChild(divider) - split.appendChild(newContainer) + if (opts?.newPaneFirst) { + split.append(newContainer, divider, existingContainer) + } else { + split.append(existingContainer, divider, newContainer) + } } From 4a9367e2d67afd47939c2f53e53c82ad1a5e6f24 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 04:28:29 -0400 Subject: [PATCH 05/75] refactor(terminal): main applies the placement a spawn carries Core step C1 of 7. When a new terminal's binding names a tab main does not have yet, main builds the row from the creation fields the window sent; when it names a split whose parent is in the tab, main takes the window's tree (holding exactly today's leaves plus the new one) or splits at the parent with the given direction and ratio. Spawns without placement, or whose placement doesn't fit the tab's current shape, keep today's mint and root graft. The headless split reuses the same split-at-leaf op, and the agreement span now reports exactly when placement was applied. --- .../loading-store/pty-binding-persistence.ts | 23 +- .../pty-binding-placement-inertness.test.ts | 69 +++-- .../pty-binding-session-update.ts | 48 +++- .../terminal-pane-placement-agreement.ts | 5 +- .../terminal-pane-placement-apply.test.ts | 272 ++++++++++++++++++ .../terminal-pane-placement-apply.ts | 68 +++++ .../runtime/headless-terminal-split-layout.ts | 19 +- 7 files changed, 431 insertions(+), 73 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 216cd4acafcf..359c55dbed90 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -59,7 +59,7 @@ export type PersistPtyBindingArgs = { mayReviveRetiredSurface?: boolean /** Span metadata only; see `PtyBindingOrigin`. The write path never reads it. */ origin?: PtyBindingOrigin - /** Where a new leaf goes. Report-only for now: the span records whether it names today's tab. */ + /** Where a new leaf goes; applied only when it names the tab's current shape, see `applyPtyBinding`. */ placement?: TerminalPanePlacement } @@ -164,20 +164,15 @@ export class PtyBindingPersistenceOperations { outcome = 'refused' return { value: false, persist: false } } - // Report-only: a malformed session must not fail the binding it is reporting on. - try { - span.setPlacement( - terminalPanePlacementAgreement( - args.placement, - session, - bindingWorktreeId, - args.tabId, - args.leafId - ) + span.setPlacement( + terminalPanePlacementAgreement( + args.placement, + session, + bindingWorktreeId, + args.tabId, + args.leafId ) - } catch { - span.setPlacement('check_threw') - } + ) const verdict = evaluatePtyBindingFastLane( args, session, diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index ce412cf4633d..ab97fdf82f7d 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -5,23 +5,8 @@ import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placem import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' import { firstLayoutLeafId } from '../restoring-sessions/terminal-layout-normalization' import { fixture } from './profile-state-delayed-authority-fixture' -import type * as AgreementModule from '../terminal-topology/terminal-pane-placement-agreement' import type { PersistPtyBindingArgs } from './pty-binding-persistence' -const agreementCheck = vi.hoisted(() => ({ throws: false })) -vi.mock('../terminal-topology/terminal-pane-placement-agreement', async (importOriginal) => { - const actual = await importOriginal() - return { - terminalPanePlacementAgreement: ( - ...args: Parameters - ) => { - if (agreementCheck.throws) { - throw new Error('malformed session') - } - return actual.terminalPanePlacementAgreement(...args) - } - } -}) vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({ nth_repo_added: 2 }) @@ -39,10 +24,11 @@ const NOW = 1_800_000_000_000 const NEW_TAB: TerminalPanePlacement = { kind: 'new-tab' } const ROOT: TerminalPanePlacement = { kind: 'root' } +// Vertical after a lone root leaf is the shape today's graft guesses, so placement adds nothing. const split = (parentLeafId: string): TerminalPanePlacement => ({ kind: 'split', parentLeafId, - direction: 'horizontal' + direction: 'vertical' }) type Scenario = { @@ -175,7 +161,6 @@ beforeEach(() => { }) afterEach(() => { - agreementCheck.throws = false vi.useRealTimers() _resetTracerForTests() }) @@ -227,7 +212,7 @@ async function bindAndSave( } } -describe('placement on the binding write is inert', () => { +describe('placement that names today’s shape, or falls back, writes today’s state', () => { for (const scenario of SCENARIOS) { it(`${scenario.name}: memory and saved state match a write without placement`, async () => { const baseline = await bindAndSave(scenario, undefined) @@ -245,13 +230,47 @@ describe('placement on the binding write is inert', () => { }) } - it('a throwing agreement check leaves the result and state as without placement', async () => { - const scenario = SCENARIOS[0] - const baseline = await bindAndSave(scenario, undefined) - agreementCheck.throws = true - const placed = await bindAndSave(scenario, () => NEW_TAB) - expect(placementAttributes).toEqual(['check_threw']) - expect(placed).toEqual(baseline) + it('a placed split and a placed row reach the saved ssh partition', async () => { + vi.setSystemTime(NOW) + const { store, readState } = await fixture() + const parent = firstLayoutLeafId( + store.getWorkspaceSession(SSH_HOST).terminalLayoutsByTabId['tab-remote']?.root ?? null + ) + if (!parent) { + throw new Error('fixture lost its existing tab') + } + await store.persistPtyBinding( + { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-remote', + leafId: TEST_LEAF_2, + ptyId: 'pty-ssh-split', + placement: { kind: 'split', parentLeafId: parent, direction: 'horizontal', ratio: 0.3 } + }, + SSH_HOST + ) + await store.persistPtyBinding( + { + worktreeId: REMOTE_WORKTREE, + tabId: 'tab-ssh-new', + leafId: TEST_LEAF_1, + ptyId: 'pty-ssh-new', + placement: { kind: 'new-tab', row: { title: 'logs', color: '#f97316' } } + }, + SSH_HOST + ) + await store.flushPendingOrThrowAsync() + const saved = readState().workspaceSessionsByHostId?.[SSH_HOST] + expect(saved?.terminalLayoutsByTabId['tab-remote']?.root).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: parent }, + second: { type: 'leaf', leafId: TEST_LEAF_2 }, + ratio: 0.3 + }) + expect( + saved?.tabsByWorktree[REMOTE_WORKTREE]?.find(({ id }) => id === 'tab-ssh-new') + ).toMatchObject({ title: 'logs', color: '#f97316', pendingActivationSpawn: true }) }) it('a tombstoned pane is still refused with placement', async () => { diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 3024543505a6..12e8a98a50c8 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -6,6 +6,11 @@ import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' +import { + placedSplitRoot, + placedTerminalTab +} from '../terminal-topology/terminal-pane-placement-apply' import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' import type { PersistPtyBindingArgs } from './pty-binding-persistence' @@ -15,6 +20,17 @@ export function applyPtyBinding( bindingWorktreeId: string, paneKey: string ): void { + // Read before any write: only a placement that names this tab's current shape is applied. + const placement = + terminalPanePlacementAgreement( + args.placement, + session, + bindingWorktreeId, + args.tabId, + args.leafId + ) === 'agrees' + ? args.placement + : undefined const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false @@ -64,14 +80,12 @@ export function applyPtyBinding( terminalMembershipChanged = true hostAdmittedTabCreated = args.hostAdmittedMembership === true // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] + const minted = createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + const nextTabs = [...(tabs ?? []), placedTerminalTab(minted, placement)] session.tabsByWorktree = { ...session.tabsByWorktree, [bindingWorktreeId]: nextTabs @@ -113,13 +127,17 @@ export function applyPtyBinding( layout.expandedLeafId = null } else if (!layoutContainsLeafId(layout.root, args.leafId)) { terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } + // Why: splitPane spawns before its snapshot reaches main; a sender without placement gets a + // minimal leaf at the root so a crash can't strand the pane's binding. + layout.root = + placement?.kind === 'split' + ? placedSplitRoot(layout.root, args.leafId, placement) + : { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } layout.activeLeafId = args.leafId if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { layout.expandedLeafId = null diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts index 3376859499e2..b4e489364b76 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -14,11 +14,10 @@ export type TerminalPanePlacementAgreement = | 'tab_missing' | 'parent_missing' | 'root_occupied' - | 'check_threw' /** - * Whether placement names the tab today's binding write picks, read before that write. Report-only - * until the mint and graft are deleted; the write never branches on it. + * Whether placement names the tab's current shape, read before the binding write. The write + * applies placement only on `agrees`; every other verdict keeps the mint and graft. */ export function terminalPanePlacementAgreement( placement: TerminalPanePlacement | undefined, diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts new file mode 100644 index 000000000000..95200c94d08d --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + parseTerminalPanePlacement, + type TerminalPanePlacement +} from '../../../shared/terminal-pane-placement' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { applyPtyBinding } from '../loading-store/pty-binding-session-update' +import type { PersistPtyBindingArgs } from '../loading-store/pty-binding-persistence' + +const WORKTREE = 'repo::/fixture' +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const C = '33333333-3333-4333-8333-333333333333' +const NEW = '44444444-4444-4444-8444-444444444444' +const NOW = 1_800_000_000_000 + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + direction: 'horizontal' | 'vertical', + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction, + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) + +function tab(id: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WORKTREE, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(root?: TerminalPaneLayoutNode | null): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: root === undefined ? {} : { [WORKTREE]: [tab('tab')] }, + terminalLayoutsByTabId: + root === undefined + ? {} + : { tab: { root, activeLeafId: null, expandedLeafId: null, ptyIdsByLeafId: {} } } + } +} + +function bind( + start: WorkspaceSessionState, + placement?: TerminalPanePlacement, + extra: Partial = {} +): WorkspaceSessionState { + const next = structuredClone(start) + const args: PersistPtyBindingArgs = { + worktreeId: WORKTREE, + tabId: 'tab', + leafId: NEW, + ptyId: 'pty-new', + startupCwd: '/fixture/sub', + ...extra, + ...(placement ? { placement } : {}) + } + applyPtyBinding(args, next, WORKTREE, `tab:${NEW}`) + return next +} + +const rootOf = (state: WorkspaceSessionState): TerminalPaneLayoutNode | null | undefined => + state.terminalLayoutsByTabId.tab?.root + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('new-tab placement', () => { + it('builds the row from the minted row plus only the fields placement carries', () => { + const minted = bind(session()) + const row = { + title: 'build', + defaultTitle: 'Terminal 3', + customTitle: 'Build', + color: '#f97316', + createdAt: 7, + startupCwd: '/fixture/build', + shellOverride: 'zsh', + quickCommandLabel: 'pnpm dev', + launchAgent: 'claude' as const, + viewMode: 'chat' as const + } + const placed = bind(session(), { kind: 'new-tab', row }) + expect(placed.tabsByWorktree[WORKTREE]).toEqual([ + { ...minted.tabsByWorktree[WORKTREE]?.[0], ...row } + ]) + expect(placed.tabsByWorktree[WORKTREE]?.[0]?.pendingActivationSpawn).toBe(true) + const { tabsByWorktree: _placedTabs, ...placedRest } = placed + const { tabsByWorktree: _mintedTabs, ...mintedRest } = minted + expect(placedRest).toEqual(mintedRest) + }) + + it('keeps a minted field the row leaves undefined', () => { + const placed = bind(session(), { + kind: 'new-tab', + row: { title: 'build', startupCwd: undefined, customTitle: undefined } + }) + expect(placed.tabsByWorktree[WORKTREE]?.[0]).toMatchObject({ + title: 'build', + startupCwd: '/fixture/sub', + customTitle: null + }) + }) + + it('is ignored when the tab already exists, and for a leaf already present', () => { + const row = { title: 'other', color: '#000000' } + const existing = session(leaf(A)) + expect(bind(existing, { kind: 'new-tab', row })).toEqual(bind(existing)) + const present = session(leaf(NEW)) + expect(bind(present, { kind: 'new-tab', row })).toEqual(bind(present)) + }) + + it('without a row writes the minted tab', () => { + expect(bind(session(), { kind: 'new-tab' })).toEqual(bind(session())) + }) +}) + +describe('split placement', () => { + it('inserts at the parent leaf with direction and ratio', () => { + const start = session(split('vertical', leaf(A), leaf(B))) + const placed = bind(start, { + kind: 'split', + parentLeafId: B, + direction: 'horizontal', + ratio: 0.3 + }) + expect(rootOf(placed)).toEqual( + split('vertical', leaf(A), split('horizontal', leaf(B), leaf(NEW), 0.3)) + ) + expect(placed.terminalLayoutsByTabId.tab).toMatchObject({ + activeLeafId: NEW, + ptyIdsByLeafId: { [NEW]: 'pty-new' } + }) + }) + + it('keeps a nested before-split proposed tree verbatim', () => { + const start = session(split('vertical', split('horizontal', leaf(A), leaf(B), 0.4), leaf(C))) + const proposedRoot = split( + 'vertical', + split('horizontal', leaf(A), split('vertical', leaf(NEW), leaf(B)), 0.4), + leaf(C), + 0.6 + ) + const placed = bind(start, { + kind: 'split', + parentLeafId: B, + direction: 'vertical', + proposedRoot + }) + expect(rootOf(placed)).toEqual(proposedRoot) + }) + + it('keeps a proposed tree that splits a subtree', () => { + const start = session(split('vertical', leaf(A), leaf(B))) + const proposedRoot = split('horizontal', split('vertical', leaf(A), leaf(B)), leaf(NEW)) + expect( + rootOf(bind(start, { kind: 'split', parentLeafId: B, direction: 'horizontal', proposedRoot })) + ).toEqual(proposedRoot) + }) + + it.each([ + ['misses a current leaf', split('vertical', leaf(A), leaf(NEW))], + ['lacks the new leaf', split('vertical', leaf(A), leaf(B))], + [ + 'adds a foreign leaf', + split('vertical', split('vertical', leaf(A), leaf(B)), split('vertical', leaf(NEW), leaf(C))) + ], + [ + 'repeats a leaf', + split('vertical', split('vertical', leaf(A), leaf(B)), split('vertical', leaf(NEW), leaf(A))) + ] + ])('falls back to the parent split when the proposed tree %s', (_name, proposedRoot) => { + const start = session(split('vertical', leaf(A), leaf(B))) + const placed = bind(start, { + kind: 'split', + parentLeafId: A, + direction: 'horizontal', + proposedRoot + }) + expect(rootOf(placed)).toEqual( + split('vertical', split('horizontal', leaf(A), leaf(NEW)), leaf(B)) + ) + }) + + it('keeps today’s root graft when the parent is not in the tab', () => { + const start = session(split('vertical', leaf(A), leaf(B))) + const proposedRoot = split('vertical', leaf(NEW), split('vertical', leaf(A), leaf(B))) + expect( + bind(start, { kind: 'split', parentLeafId: C, direction: 'horizontal', proposedRoot }) + ).toEqual(bind(start)) + expect(rootOf(bind(start))).toEqual( + split('vertical', split('vertical', leaf(A), leaf(B)), leaf(NEW)) + ) + }) + + it('keeps today’s mint when the tab does not exist', () => { + expect(bind(session(), { kind: 'split', parentLeafId: A, direction: 'horizontal' })).toEqual( + bind(session()) + ) + }) + + it('is ignored for a leaf already present', () => { + const start = session(split('vertical', leaf(A), leaf(NEW))) + const proposedRoot = split('horizontal', leaf(NEW), leaf(A)) + expect( + bind(start, { kind: 'split', parentLeafId: A, direction: 'horizontal', proposedRoot }) + ).toEqual(bind(start)) + }) + + it('bumps the topology revision exactly as the graft does', () => { + const start = { ...session(leaf(A)), terminalTopologyRevisionByRepoId: { repo: 2 } } + const placed = bind(start, { kind: 'split', parentLeafId: A, direction: 'horizontal' }) + expect(placed.terminalTopologyRevisionByRepoId).toEqual( + bind(start).terminalTopologyRevisionByRepoId + ) + }) +}) + +describe('root and malformed placement', () => { + it('root placement writes what an empty layout gets today', () => { + const empty = session(null) + expect(bind(empty, { kind: 'root' })).toEqual(bind(empty)) + expect(rootOf(bind(empty, { kind: 'root' }))).toEqual(leaf(NEW)) + const occupied = session(leaf(A)) + expect(bind(occupied, { kind: 'root' })).toEqual(bind(occupied)) + }) + + it.each([ + { kind: 'split', parentLeafId: 'not-a-leaf', direction: 'vertical' }, + { kind: 'split', parentLeafId: A, direction: 'diagonal' }, + { kind: 'teleport' }, + 'new-tab' + ])('a malformed placement parses to none and writes today’s state: %j', (raw) => { + const placement = parseTerminalPanePlacement(raw) ?? undefined + expect(placement).toBeUndefined() + const start = session(leaf(A)) + expect(bind(start, placement)).toEqual(bind(start)) + }) + + it('drops only a malformed proposed tree, then splits at the parent', () => { + const placement = parseTerminalPanePlacement({ + kind: 'split', + parentLeafId: A, + direction: 'horizontal', + proposedRoot: { type: 'split', first: leaf(A) } + }) + expect(rootOf(bind(session(leaf(A)), placement ?? undefined))).toEqual( + split('horizontal', leaf(A), leaf(NEW)) + ) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts new file mode 100644 index 000000000000..ad9b95b0ad01 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts @@ -0,0 +1,68 @@ +import { isDeepStrictEqual } from 'node:util' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import type { + TerminalPaneLayoutNode, + TerminalPaneSplitDirection, + TerminalTab +} from '../../../shared/terminal-tab-types' +import { omitUndefinedValues } from '../../../shared/rpc-contract/ui-update-value-tolerance-params' +import { + cloneLayoutNode, + collectLayoutLeafIdsInOrder +} from '../restoring-sessions/terminal-layout-normalization' + +type SplitPlacement = Extract + +/** The minted row with the sender's creation fields over it; a field it omits keeps the minted value. */ +export function placedTerminalTab( + minted: TerminalTab, + placement: TerminalPanePlacement | undefined +): TerminalTab { + return placement?.kind === 'new-tab' && placement.row + ? { ...minted, ...omitUndefinedValues(placement.row) } + : minted +} + +/** + * The root once `leafId` joins it: the sender's tree when it holds exactly today's leaves plus + * this one (the only way to say before/after or split a subtree), else a split at the parent. + */ +export function placedSplitRoot( + root: TerminalPaneLayoutNode, + leafId: string, + { parentLeafId, direction, ratio, proposedRoot }: SplitPlacement +): TerminalPaneLayoutNode { + const holdsExpectedLeaves = isDeepStrictEqual( + collectLayoutLeafIdsInOrder(proposedRoot).sort(), + [...collectLayoutLeafIdsInOrder(root), leafId].sort() + ) + return proposedRoot && holdsExpectedLeaves + ? cloneLayoutNode(proposedRoot) + : splitLayoutLeaf(root, parentLeafId, leafId, direction, ratio) +} + +/** Replaces `parentLeafId` with a split whose second child is the new leaf. */ +export function splitLayoutLeaf( + node: TerminalPaneLayoutNode, + parentLeafId: string, + leafId: string, + direction: TerminalPaneSplitDirection, + ratio?: number +): TerminalPaneLayoutNode { + if (node.type === 'split') { + return { + ...node, + first: splitLayoutLeaf(node.first, parentLeafId, leafId, direction, ratio), + second: splitLayoutLeaf(node.second, parentLeafId, leafId, direction, ratio) + } + } + return node.leafId === parentLeafId + ? { + type: 'split', + direction, + first: node, + second: { type: 'leaf', leafId }, + ...(ratio !== undefined ? { ratio } : {}) + } + : node +} diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 681d70451e8a..ce1771d840b3 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -2,6 +2,7 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' +import { splitLayoutLeaf } from '../persistence/terminal-topology/terminal-pane-placement-apply' export function terminalLayoutContainsLeaf( node: TerminalPaneLayoutNode | null | undefined, @@ -47,31 +48,17 @@ export function buildHeadlessTerminalSplitLayout( } return { ...node, first, second } } - // Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit. + // Why: PTY admission durably binds the leaf (placed, or grafted at the root) before this commit. const currentRoot = existing?.root ? removeProvisionalLeaf(existing.root) : null const existingRoot: TerminalPaneLayoutNode = currentRoot ?? { type: 'leaf', leafId: args.splitFromLeafId } - const insertSplit = (node: TerminalPaneLayoutNode): TerminalPaneLayoutNode => { - if (node.type === 'leaf') { - if (node.leafId !== args.splitFromLeafId) { - return node - } - return { - type: 'split', - direction: args.direction, - first: node, - second: { type: 'leaf', leafId: args.leafId } - } - } - return { ...node, first: insertSplit(node.first), second: insertSplit(node.second) } - } const ptyIdsByLeafId = { ...existing?.ptyIdsByLeafId } delete ptyIdsByLeafId[args.leafId] return { ...existing, - root: insertSplit(existingRoot), + root: splitLayoutLeaf(existingRoot, args.splitFromLeafId, args.leafId, args.direction), activeLeafId: args.leafId, expandedLeafId: existing?.expandedLeafId ?? null, ptyIdsByLeafId: { From b3983198e8c85d22ae2da902251bfaf06d9f5e6c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 04:38:38 -0400 Subject: [PATCH 06/75] test(terminal): type the background-terminal tab patch precisely --- .../src/lib/launch-worktree-background-terminals.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index eb19560df833..88ab463e60d7 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -88,7 +88,7 @@ describe('launchWorktreeBackgroundTerminals', () => { return tab }) // Placement reads the row back from the store, so title and color writes must land there. - const patchTab = (tabId: string, patch: object): void => { + const patchTab = (tabId: string, patch: { customTitle?: string; color?: string }): void => { state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].map((tab) => tab.id === tabId ? { ...tab, ...patch } : tab ) From b7687b90c9ef5e82d7fd3c393596cdc16308a442 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 04:41:43 -0400 Subject: [PATCH 07/75] refactor(terminal): check the proposed tree's leaves only when there is one --- .../terminal-topology/terminal-pane-placement-apply.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts index ad9b95b0ad01..79217cc25471 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts @@ -32,11 +32,9 @@ export function placedSplitRoot( leafId: string, { parentLeafId, direction, ratio, proposedRoot }: SplitPlacement ): TerminalPaneLayoutNode { - const holdsExpectedLeaves = isDeepStrictEqual( - collectLayoutLeafIdsInOrder(proposedRoot).sort(), - [...collectLayoutLeafIdsInOrder(root), leafId].sort() - ) - return proposedRoot && holdsExpectedLeaves + const expectedLeaves = [...collectLayoutLeafIdsInOrder(root), leafId].sort() + return proposedRoot && + isDeepStrictEqual(collectLayoutLeafIdsInOrder(proposedRoot).sort(), expectedLeaves) ? cloneLayoutNode(proposedRoot) : splitLayoutLeaf(root, parentLeafId, leafId, direction, ratio) } From 36d21f852db93c99724aeffb140ac370ad1a226e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 04:54:13 -0400 Subject: [PATCH 08/75] refactor(terminal): publish main's terminal layout slices to the window (no reader yet) Core step C2: the runtime owns a TerminalTopologyPublisher fed by the store's session-write hook. Each worktree is projected from its one home partition (local or ssh:, never runtime:); an owner that can't be resolved, like an ambiguous folder, keeps its last slice instead of going empty. Slices go to the window on session:terminal-topology-changed, the window can pull them with session:get-terminal-topology-slices, and close-surface and leaf-move replies name the publishSeq that carries their write. --- src/main/ipc/pty/ipc/leaf-move.test.ts | 32 +++- src/main/ipc/pty/ipc/leaf-move.ts | 3 +- .../session-close-terminal-surface.test.ts | 20 ++- src/main/ipc/session.ts | 11 +- .../loading-store/pty-binding-refusals.ts | 2 +- .../workspace-session-write-listeners.test.ts | 10 +- .../terminal-owner-invariants.ts | 15 +- src/main/runtime/orca-runtime.ts | 18 ++ src/main/runtime/runtime-notifier-contract.ts | 2 + src/main/runtime/runtime-store-contract.ts | 1 + .../runtime-workspace-session-controller.ts | 38 ++++ .../runtime/terminal-topology-owners.test.ts | 114 ++++++++++++ .../terminal-topology-publisher.test.ts | 47 ++++- .../runtime/terminal-topology-publisher.ts | 30 ++-- ...rminal-topology-runtime-publishing.test.ts | 162 ++++++++++++++++++ src/main/window/runtime-window-lifecycle.ts | 4 +- 16 files changed, 467 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/terminal-topology-owners.test.ts create mode 100644 src/main/runtime/terminal-topology-runtime-publishing.test.ts diff --git a/src/main/ipc/pty/ipc/leaf-move.test.ts b/src/main/ipc/pty/ipc/leaf-move.test.ts index 724a706a56bb..19c4796d3622 100644 --- a/src/main/ipc/pty/ipc/leaf-move.test.ts +++ b/src/main/ipc/pty/ipc/leaf-move.test.ts @@ -3,7 +3,8 @@ import { agentHookServer } from '../../../agent-hooks/server' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' -import { commitLeafMoveAndRekey } from './leaf-move' +import { setPtyHostBindings } from '../../pty-host-bindings' +import { commitLeafMoveAndRekey, installPtyLeafMoveIpcHandler } from './leaf-move' const LEAF = '22222222-2222-4222-8222-222222222222' const request = { @@ -27,6 +28,7 @@ function deps(result: TerminalLeafMoveResult) { afterEach(() => { vi.restoreAllMocks() + setPtyHostBindings({}) }) describe('pty:moveLeafToNewTab', () => { @@ -67,4 +69,32 @@ describe('pty:moveLeafToNewTab', () => { expect(transfer).not.toHaveBeenCalled() expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() }) + + it('replies with the publishSeq of the push that carries the move', async () => { + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const handlers = new Map unknown>() + setPtyHostBindings({ + ipc: { + handle: (channel, listener) => handlers.set(channel, listener), + on: () => {}, + removeHandler: () => {}, + removeAllListeners: () => {} + } + }) + const { store, runtime } = deps({ status: 'moved', ptyId: 'pty-agent' }) + const settleTerminalTopology = vi.fn(() => 12) + installPtyLeafMoveIpcHandler({ + store, + runtime: Object.assign(runtime, { settleTerminalTopology }) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler never reads the IPC event. + const reply = handlers.get('pty:moveLeafToNewTab')!({} as never, request) + await expect(reply).resolves.toEqual({ + status: 'moved', + ptyId: 'pty-agent', + publishSeq: 12 + }) + expect(settleTerminalTopology).toHaveBeenCalledWith(request.worktreeId) + }) }) diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts index c8b0cb9f07a7..f28e68a8c8d6 100644 --- a/src/main/ipc/pty/ipc/leaf-move.ts +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -53,6 +53,7 @@ export function installPtyLeafMoveIpcHandler(deps: { if (!isTerminalLeafMoveRequest(args)) { return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafMoveResult } - return commitLeafMoveAndRekey(deps, args) + const result = await commitLeafMoveAndRekey(deps, args) + return { ...result, publishSeq: deps.runtime?.settleTerminalTopology(args.worktreeId) } }) } diff --git a/src/main/ipc/session-close-terminal-surface.test.ts b/src/main/ipc/session-close-terminal-surface.test.ts index 909c9c43c651..116d00f7c61a 100644 --- a/src/main/ipc/session-close-terminal-surface.test.ts +++ b/src/main/ipc/session-close-terminal-surface.test.ts @@ -22,7 +22,13 @@ const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' const OTHER_LEAF = '6fa459ea-ee8a-4ca4-894e-db77e160355e' -const closeTerminalSurfaceFromRenderer = vi.fn(async () => {}) +let committed = false +const closeTerminalSurfaceFromRenderer = vi.fn(async () => { + await Promise.resolve() + committed = true +}) +// Names the push only once the close has committed. +const settleTerminalTopology = vi.fn(() => (committed ? 7 : 0)) function closeSurface(args: unknown): unknown { return mocks.handlers.get('session:close-terminal-surface')?.({}, args) @@ -35,8 +41,9 @@ function launchIn(leafId: string) { beforeEach(() => { mocks.handlers.clear() closeTerminalSurfaceFromRenderer.mockClear() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler under test reaches only the runtime's renderer close. - const runtime = { closeTerminalSurfaceFromRenderer } as never + committed = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler under test reaches only the runtime's renderer close and topology settle. + const runtime = { closeTerminalSurfaceFromRenderer, settleTerminalTopology } as never // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close handler never touches the store. registerSessionHandlers({} as never, runtime) }) @@ -92,4 +99,11 @@ describe("the user's close of a launch's tab or pane", () => { expect(running.closedByUser()).toBe(false) }) + + it('replies with the publishSeq of the push that carries the close', async () => { + await expect( + closeSurface({ worktreeId: WT, target: { kind: 'tab', tabId: TAB }, reason: 'user' }) + ).resolves.toEqual({ publishSeq: 7 }) + expect(settleTerminalTopology).toHaveBeenLastCalledWith(WT) + }) }) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 6c26bff46703..128755c662ae 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -34,7 +34,10 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic // Why: a renderer save cannot shrink membership main owns, so each close commits it explicitly. ipcMain.handle( 'session:close-terminal-surface', - (_event, args: { worktreeId?: unknown; target?: unknown; reason?: unknown } | undefined) => { + async ( + _event, + args: { worktreeId?: unknown; target?: unknown; reason?: unknown } | undefined + ) => { const target = parseTerminalSurfaceCloseTarget(args?.target) if (typeof args?.worktreeId !== 'string' || !target) { throw new Error('invalid_terminal_surface') @@ -45,14 +48,18 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic // A launch still starting or delivering in what the user closed stops, and says so. markAgentLaunchesClosedByUser(args.worktreeId, target) } - return runtime.closeTerminalSurfaceFromRenderer({ + await runtime.closeTerminalSurfaceFromRenderer({ worktreeId: args.worktreeId, target, reason }) + return { publishSeq: runtime.settleTerminalTopology(args.worktreeId) } } ) + // Pull-after-listen: a window subscribes to pushes first, then reads what it missed. + ipcMain.handle('session:get-terminal-topology-slices', () => runtime.getTerminalTopologySlices()) + ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 6576ac3d0070..1b28d6adefaf 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -1,7 +1,7 @@ import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { TerminalSessionPartition } from '../terminal-topology/terminal-owner-invariants' +import type { TerminalSessionPartition } from '../terminal-topology/terminal-topology-membership' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import type { PtyBindingSourceExpectation } from './store' diff --git a/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts b/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts index 9359a2d8f96f..9e5027fa1fff 100644 --- a/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts +++ b/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts @@ -3,10 +3,8 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import { - TerminalTopologyPublisher, - type WorkspaceSessionOwner -} from '../../runtime/terminal-topology-publisher' +import { TerminalTopologyPublisher } from '../../runtime/terminal-topology-publisher' +import type { TerminalSessionPartition } from '../terminal-topology/terminal-topology-membership' import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' import { fixture } from './profile-state-delayed-authority-fixture' import type { Store } from './store' @@ -32,8 +30,8 @@ const binding = { type Mode = 'none' | 'publisher' | 'throwing' -function ownersOf(store: Store): Map { - const owners = new Map() +function ownersOf(store: Store): Map { + const owners = new Map() for (const hostId of store.getWorkspaceSessionHostIds()) { const session = store.getWorkspaceSession(hostId) for (const worktreeId of Object.keys(session.tabsByWorktree)) { diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts index a5311486a06f..492638e17cf5 100644 --- a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts @@ -1,15 +1,16 @@ -import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { + isTerminalOwnerPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' // The two terminal-layout invariants: a terminal is bound to at most one leaf, and a leaf id is in // at most one tab. The binding write reports breaches; nothing removes them yet. -export type TerminalSessionPartition = { hostId: ExecutionHostId; session: WorkspaceSessionState } - export type TerminalLeafOwner = { hostId: ExecutionHostId worktreeId: string @@ -27,12 +28,6 @@ export type TerminalOwnerConflictReason = // Kept apart: the relay reattach writes SSH panes into `local`, so this may be one moved surface. | 'leaf_in_other_tab_on_other_host' -/** `runtime:` partitions belong to a remote Orca server and are written only by its tab sync. */ -export function isTerminalOwnerPartition(hostId: ExecutionHostId): boolean { - const kind = parseExecutionHostId(hostId)?.kind - return kind === 'local' || kind === 'ssh' -} - /** Every leaf of every tab row; a layout left behind by a removed tab row owns nothing. */ export function collectTerminalLeafOwners({ hostId, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c4078cb29486..4dbd4a0690e8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,8 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' +import { TerminalTopologyPublisher } from './terminal-topology-publisher' class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { constructor(...args: ConstructorParameters) { @@ -23,6 +25,22 @@ class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { // module registers the generation bump at load; a headless host never loads it. registerDetectedWorktreeScanInvalidation() registerWorktreeChangeInvalidator((repoId) => this.invalidateWorktreeCatalog(repoId)) + this.store?.onWorkspaceSessionWritten?.(() => this.terminalTopology.markDirty()) + } + + private readonly terminalTopology = new TerminalTopologyPublisher( + () => this.workspaceSessions.getTerminalTopologyOwners(), + (slice) => this.notifier?.terminalTopologyChanged?.(slice) + ) + + /** Every worktree's current terminal layout, for a window that missed pushes while loading. */ + getTerminalTopologySlices(): TerminalTopologySlice[] { + return this.terminalTopology.snapshot() + } + + /** The publishSeq of the push holding every topology write made so far, for a reply to name. */ + settleTerminalTopology(worktreeId?: string): number { + return this.terminalTopology.settle(worktreeId) } /** Whether a window owns the layout and can show a launch's tab ahead of its process. */ diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 8a6b3d2bc467..f24f804f1a4a 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/agent-launch-tab-publication' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' @@ -169,4 +170,5 @@ export type RuntimeNotifier = { browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void + terminalTopologyChanged?(slice: TerminalTopologySlice): void } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index ef4e016b3925..aa55b729c89a 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -47,6 +47,7 @@ export type RuntimeStore = { getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] + onWorkspaceSessionWritten?: Store['onWorkspaceSessionWritten'] setWorkspaceSession?: Store['setWorkspaceSession'] runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index f6bb00141699..c75103f1b263 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -10,7 +10,9 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { workspaceSessionPartitionHostId } from '../../shared/workspace-session-partition-owner' import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { isTerminalOwnerPartition } from '../persistence/terminal-topology/terminal-topology-membership' import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalTopologyOwners } from './terminal-topology-publisher' type RuntimeWorkspaceSessionDependencies = { getStore: () => RuntimeStore | null @@ -53,6 +55,15 @@ export class RuntimeWorkspaceSessionController { : LOCAL_EXECUTION_HOST_ID } + /** An ambiguous folder throws; its owner is unverifiable, not absent. */ + private tryGetPreferredHostId(worktreeId: string, store: RuntimeStore): ExecutionHostId | null { + try { + return this.getPreferredHostId(worktreeId, store) + } catch { + return null + } + } + private resolveHostId( worktreeId: string, preferredHostId: ExecutionHostId, @@ -140,6 +151,33 @@ export class RuntimeWorkspaceSessionController { return worktreeIds } + /** + * Worktrees with terminal rows in their home partition. `runtime:` homes are another server's; + * a home that can't be resolved (a missing or ambiguous folder) maps to null. + */ + getTerminalTopologyOwners(): TerminalTopologyOwners { + const owners: TerminalTopologyOwners = new Map() + const store = this.deps.getStore() + if (!store?.getWorkspaceSession) { + return owners + } + for (const hostId of store.getWorkspaceSessionHostIds?.() ?? []) { + if (!isTerminalOwnerPartition(hostId)) { + continue + } + const session = store.getWorkspaceSession(hostId) + for (const worktreeId of Object.keys(session.tabsByWorktree ?? {})) { + const homeHostId = this.tryGetPreferredHostId(worktreeId, store) + if (!homeHostId) { + owners.set(worktreeId, null) + } else if (homeHostId === hostId) { + owners.set(worktreeId, { hostId, session }) + } + } + } + return owners + } + getHydrationTargets(includeAllPersistedWorktrees: boolean): Map { const store = this.deps.getStore() if (!store) { diff --git a/src/main/runtime/terminal-topology-owners.test.ts b/src/main/runtime/terminal-topology-owners.test.ts new file mode 100644 index 000000000000..81a096a2665f --- /dev/null +++ b/src/main/runtime/terminal-topology-owners.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' + +const LOCAL_WT = 'repo-local::/wt' +const SSH_WT = 'repo-ssh::/remote/wt' +const RUNTIME_WT = 'repo-runtime::/srv/wt' +const AMBIGUOUS_FOLDER = 'folder:mixed' +const SSH_FOLDER = 'folder:remote' + +function sessionWith(worktreeIds: string[]): WorkspaceSessionState { + const session = getDefaultWorkspaceSession() + for (const worktreeId of worktreeIds) { + session.tabsByWorktree[worktreeId] = [ + { + id: `tab-${worktreeId}`, + worktreeId, + ptyId: null, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + return session +} + +function ownersFor(sessions: Map) { + const repos = [ + { id: 'repo-local' }, + { id: 'repo-ssh', connectionId: 'c1' }, + { id: 'repo-runtime', executionHostId: 'runtime:e1' } + ] + const folders = [ + { id: 'mixed', folderPath: '/mixed' }, + { id: 'remote', folderPath: '/remote', executionHostId: 'ssh:c1' } + ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only repos, folder workspaces and workspace sessions. + const store = { + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getFolderWorkspaces: () => folders, + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId: ExecutionHostId) => + sessions.get(hostId) ?? getDefaultWorkspaceSession() + } as never + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: (workspace: FolderWorkspace) => { + if (workspace.id === 'mixed') { + throw new Error('folder_workspace_connection_ambiguous') + } + return null + }, + hasRuntimeOwnedPtyCandidate: () => false + }) + return controller.getTerminalTopologyOwners() +} + +describe('terminal topology owners', () => { + it('maps each worktree to its home partition only', () => { + const local = sessionWith([LOCAL_WT, FLOATING_TERMINAL_WORKTREE_ID, SSH_WT]) + const ssh = sessionWith([SSH_WT, SSH_FOLDER]) + const owners = ownersFor( + new Map([ + ['local', local], + ['ssh:c1', ssh] + ]) + ) + + expect(owners).toEqual( + new Map([ + [LOCAL_WT, { hostId: 'local', session: local }], + [FLOATING_TERMINAL_WORKTREE_ID, { hostId: 'local', session: local }], + [SSH_WT, { hostId: 'ssh:c1', session: ssh }], + [SSH_FOLDER, { hostId: 'ssh:c1', session: ssh }] + ]) + ) + }) + + it('leaves out runtime homes and runtime partitions', () => { + const owners = ownersFor( + new Map([ + ['local', sessionWith([RUNTIME_WT])], + ['runtime:e1', sessionWith([RUNTIME_WT, LOCAL_WT])] + ]) + ) + + expect(owners.size).toBe(0) + }) + + it('leaves out rows only in a partition that is not the home', () => { + const owners = ownersFor(new Map([['local', sessionWith([SSH_WT])]])) + + expect(owners.has(SSH_WT)).toBe(false) + }) + + it('marks an ambiguous folder unresolved without losing the other worktrees', () => { + const local = sessionWith([AMBIGUOUS_FOLDER, LOCAL_WT]) + const owners = ownersFor(new Map([['local', local]])) + + expect(owners).toEqual( + new Map([ + [AMBIGUOUS_FOLDER, null], + [LOCAL_WT, { hostId: 'local', session: local }] + ]) + ) + }) +}) diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index 54e7a0fa4ba1..7a048f3f92a2 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -3,10 +3,8 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { TEST_LEAF_1 } from '../persistence-session-fixtures' -import { - TerminalTopologyPublisher, - type WorkspaceSessionOwner -} from './terminal-topology-publisher' +import type { TerminalSessionPartition } from '../persistence/terminal-topology/terminal-topology-membership' +import { TerminalTopologyPublisher } from './terminal-topology-publisher' const WT = 'repo-1::/tmp/wt-a' const WT_B = 'repo-1::/tmp/wt-b' @@ -39,12 +37,13 @@ function sessionWith(worktreeIds: string[]): WorkspaceSessionState { function harness(initial: WorkspaceSessionState) { let session = initial + const unresolved = new Set() const readOwners = vi.fn( () => - new Map( + new Map( Object.keys(session.tabsByWorktree).map((worktreeId) => [ worktreeId, - { hostId: 'local', session } + unresolved.has(worktreeId) ? null : { hostId: 'local', session } ]) ) ) @@ -72,6 +71,9 @@ function harness(initial: WorkspaceSessionState) { }, failSink(error: Error) { sinkError = error + }, + unresolve(worktreeId: string) { + unresolved.add(worktreeId) } } } @@ -86,7 +88,7 @@ describe('TerminalTopologyPublisher', () => { const session = sessionWith([WT, WT_B]) const publisher = new TerminalTopologyPublisher( () => - new Map([ + new Map([ [WT, { hostId: 'local', session }], [WT_B, { hostId: 'local', session }] ]), @@ -201,4 +203,35 @@ describe('TerminalTopologyPublisher', () => { expect(h.readOwners).toHaveBeenCalledTimes(3) expect(warn).toHaveBeenCalledTimes(1) }) + + it('keeps an unresolved worktree on its last slice instead of sending it empty', async () => { + const h = harness(sessionWith([WT, WT_B])) + const before = h.publisher.snapshot() + h.unresolve(WT) + const next = structuredClone(h.session) + next.tabsByWorktree[WT] = [] + next.tabsByWorktree[WT_B]![0]!.ptyId = 'pty-b' + h.replace(next) + + h.publisher.markDirty() + await Promise.resolve() + + expect(h.pushes.map((slice) => slice.worktreeId)).toEqual([WT_B]) + expect(h.publisher.snapshot().find((slice) => slice.worktreeId === WT)).toEqual( + before.find((slice) => slice.worktreeId === WT) + ) + }) + + it('pulls the slices it pushed, including ones the window missed', async () => { + const h = harness(sessionWith([WT])) + h.replace(sessionWith([WT, WT_B])) + h.publisher.markDirty() + await Promise.resolve() + + expect(h.publisher.snapshot()).toEqual([ + expect.objectContaining({ worktreeId: WT, publishSeq: 1 }), + ...h.pushes + ]) + expect(h.pushes).toHaveLength(1) + }) }) diff --git a/src/main/runtime/terminal-topology-publisher.ts b/src/main/runtime/terminal-topology-publisher.ts index be712d295c17..edc9fcb0a676 100644 --- a/src/main/runtime/terminal-topology-publisher.ts +++ b/src/main/runtime/terminal-topology-publisher.ts @@ -1,15 +1,14 @@ import { isDeepStrictEqual } from 'node:util' -import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { TerminalSessionPartition } from '../persistence/terminal-topology/terminal-topology-membership' import { emptyTerminalTopologySlice, projectTerminalTopologySlice, type UnsequencedTerminalTopologySlice } from './terminal-topology-projection' -/** A persisted worktree and the host partition that owns its rows. */ -export type WorkspaceSessionOwner = { hostId: ExecutionHostId; session: WorkspaceSessionState } +/** Each worktree's owning partition; null when it can't be resolved now (unverifiable, not absent). */ +export type TerminalTopologyOwners = Map type PublishedSlice = { publishSeq: number; snapshot: UnsequencedTerminalTopologySlice } @@ -26,7 +25,7 @@ export class TerminalTopologyPublisher { private failureLogged = false constructor( - private readonly readOwners: () => Map, + private readonly readOwners: () => TerminalTopologyOwners, private readonly sink: TerminalTopologySink ) {} @@ -61,10 +60,21 @@ export class TerminalTopologyPublisher { return (worktreeId ? this.published.get(worktreeId)?.publishSeq : undefined) ?? this.lastSeq } + /** Every current slice, for a window that loaded after pushes it never saw. */ + snapshot(): TerminalTopologySlice[] { + // Owners also follow the repo catalog, which changes without a session write. + this.dirty = true + this.flush() + return [...this.published.values()].map(sequenced) + } + private reconcile(): void { const owners = this.readOwners() const changed: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, owner] of owners) { + if (!owner) { + continue // The last slice stands until the owner resolves. + } const next = projectTerminalTopologySlice(owner.session, owner.hostId, worktreeId) if (!isDeepStrictEqual(this.published.get(worktreeId)?.snapshot, next)) { changed.push(structuredClone(next)) @@ -78,16 +88,16 @@ export class TerminalTopologyPublisher { } for (const snapshot of removed) { this.published.delete(snapshot.worktreeId) - this.send({ publishSeq: ++this.lastSeq, snapshot }) + this.sink(sequenced({ publishSeq: ++this.lastSeq, snapshot })) } for (const snapshot of changed) { const entry = { publishSeq: ++this.lastSeq, snapshot } this.published.set(snapshot.worktreeId, entry) - this.send(entry) + this.sink(sequenced(entry)) } } +} - private send({ publishSeq, snapshot }: PublishedSlice): void { - this.sink({ ...snapshot, publishSeq }) - } +function sequenced({ publishSeq, snapshot }: PublishedSlice): TerminalTopologySlice { + return { ...snapshot, publishSeq } } diff --git a/src/main/runtime/terminal-topology-runtime-publishing.test.ts b/src/main/runtime/terminal-topology-runtime-publishing.test.ts new file mode 100644 index 000000000000..2448f9885d35 --- /dev/null +++ b/src/main/runtime/terminal-topology-runtime-publishing.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../persistence-session-fixtures' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimeNotifier } from './runtime-notifier-contract' + +const WT = 'repo1::/tmp/wt' +const TAB = 'tab-1' +const cleanups: (() => void)[] = [] + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } +}) + +function sessionWithPty(ptyId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WT]: [ + { + id: TAB, + worktreeId: WT, + ptyId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: ptyId } + } + } + } +} + +function notifierWith( + terminalTopologyChanged?: RuntimeNotifier['terminalTopologyChanged'] +): RuntimeNotifier { + const ignore = (): void => {} + return { + worktreesChanged: ignore, + reposChanged: ignore, + activateWorktree: ignore, + createTerminal: ignore, + splitTerminal: ignore, + renameTerminal: ignore, + focusTerminal: ignore, + closeTerminal: ignore, + sleepWorktree: ignore, + terminalFitOverrideChanged: ignore, + terminalDriverChanged: ignore, + ...(terminalTopologyChanged ? { terminalTopologyChanged } : {}) + } +} + +/** Store writes reach the runtime only through `onWorkspaceSessionWritten`, as with the real Store. */ +function setup() { + const listeners = new Set<() => void>() + let session = sessionWithPty('pty-1') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: publishing reads only repos, folder workspaces and workspace sessions. + const store = { + getRepos: () => [{ id: 'repo1' }], + getRepo: () => ({ id: 'repo1' }), + getFolderWorkspaces: () => [], + getWorkspaceSessionHostIds: () => ['local'], + getWorkspaceSession: () => session, + onWorkspaceSessionWritten: (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } as never + const runtime = new OrcaRuntimeService(store) + const pushes: TerminalTopologySlice[] = [] + runtime.setNotifier(notifierWith((slice) => pushes.push(slice))) + cleanups.push(() => runtime.setNotifier(null)) + // The first pull publishes the starting topology; tests observe what follows. + const initial = runtime.getTerminalTopologySlices() + pushes.length = 0 + const write = (next: WorkspaceSessionState): void => { + session = next + for (const listener of listeners) { + listener() + } + } + return { write, runtime, pushes, initial } +} + +describe('main publishes terminal topology to the window', () => { + it('pushes a committed change, and the pull and the reply name the same slice', async () => { + const { write, runtime, pushes, initial } = setup() + expect(initial).toEqual([expect.objectContaining({ hostId: 'local', worktreeId: WT })]) + + write(sessionWithPty('pty-2')) + await Promise.resolve() + + expect(pushes).toHaveLength(1) + expect(pushes[0]!.publishSeq).toBeGreaterThan(initial[0]!.publishSeq) + expect(pushes[0]!.layouts[TAB]!.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-2' }) + expect(runtime.getTerminalTopologySlices()).toEqual(pushes) + expect(runtime.settleTerminalTopology(WT)).toBe(pushes[0]!.publishSeq) + }) + + it('pushes nothing for an identical or presentation-only write', async () => { + const { write, pushes } = setup() + + write(sessionWithPty('pty-1')) + const renamed = sessionWithPty('pty-1') + renamed.tabsByWorktree[WT]![0]!.customTitle = 'renamed' + write(renamed) + await Promise.resolve() + + expect(pushes).toEqual([]) + }) + + it('a reply settles a write made in the same task before its push goes out', () => { + const { write, runtime, pushes } = setup() + + write(sessionWithPty('pty-2')) + const seq = runtime.settleTerminalTopology(WT) + + expect(pushes.map((slice) => slice.publishSeq)).toEqual([seq]) + }) + + it('covers a push the window missed while reloading with the pull', async () => { + const { write, runtime, pushes } = setup() + runtime.setNotifier(notifierWith()) + + const split = sessionWithPty('pty-1') + split.terminalLayoutsByTabId[TAB] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1', [TEST_LEAF_2]: 'pty-split' } + } + write(split) + await Promise.resolve() + runtime.setNotifier(notifierWith((slice) => pushes.push(slice))) + + expect(pushes).toEqual([]) + const [pulled] = runtime.getTerminalTopologySlices() + expect(pulled!.layouts[TAB]!.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'pty-1', + [TEST_LEAF_2]: 'pty-split' + }) + }) +}) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 857ed79d787e..f4207ea0fec9 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -220,7 +220,9 @@ export function registerRuntimeWindowLifecycle( send('runtime:browserDriverChanged', { browserPageId, driver }), browserRemoteViewersChanged: (browserPageId, hasRemoteViewers) => send('runtime:browserRemoteViewersChanged', { browserPageId, hasRemoteViewers }), - clientHostedBrowserRowsChanged: (event) => send('runtime:clientHostedBrowserRowsChanged', event) + clientHostedBrowserRowsChanged: (event) => + send('runtime:clientHostedBrowserRowsChanged', event), + terminalTopologyChanged: (slice) => send('session:terminal-topology-changed', slice) }) registerRendererDocumentNavigation(mainWebContents, () => { rendererNotifications.onMainFrameReloadStarted() From 606e50e8c5e8703bc8c744671700b52949e0d187 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 13:38:30 -0400 Subject: [PATCH 09/75] refactor(terminal): name the topology push on pty:spawn replies too The spawn reply now carries the publishSeq of the push holding its binding, like close-surface and leaf-move. No reader yet. --- src/main/ipc/pty-spawn-placement-threading.test.ts | 13 ++++++++++--- src/main/ipc/pty/ipc/spawn.ts | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts index 76b6dd9ce87b..77d5763a8ee7 100644 --- a/src/main/ipc/pty-spawn-placement-threading.test.ts +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -81,7 +81,9 @@ describe('pty spawn placement threading', () => { noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + // Names a push only once the binding is written. + settleTerminalTopology: vi.fn(() => (store.persistPtyBinding.mock.calls.length > 0 ? 9 : 0)) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn reads only the window, runtime and store members these fakes define. const args = [ @@ -104,8 +106,8 @@ describe('pty spawn placement threading', () => { } } - async function ipcSpawn(extra: Record): Promise { - await handlers.get('pty:spawn')!(null, { + async function ipcSpawn(extra: Record): Promise { + return await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp', @@ -142,6 +144,11 @@ describe('pty spawn placement threading', () => { ]) }) + it('replies with the publishSeq of the push that carries the binding', async () => { + register() + await expect(ipcSpawn({ placement: SPLIT })).resolves.toMatchObject({ publishSeq: 9 }) + }) + it('drops a malformed or future-kind placement instead of failing the spawn', async () => { const { store } = register() await ipcSpawn({ placement: { kind: 'floating-window', anchor: 'x' } }) diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index 6fd5183a36d8..37d43ba37b01 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -11,6 +11,7 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } - return runPtyIpcSpawn(deps, args) + const result = await runPtyIpcSpawn(deps, args) + return { ...result, publishSeq: deps.runtime?.settleTerminalTopology?.(args.worktreeId) } }) } From c502ff819d54f3685b83862243606369c5a0033d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 14:05:21 -0400 Subject: [PATCH 10/75] feat(terminal): window starts from and follows main's terminal layout Core step C3 (plan B2-4). After hydration the window subscribes to main's topology push, pulls every slice, and applies them before reconnect. The apply replaces only main-owned topology; presentation, pendingActivationSpawn, the live ptyId and ptyIdsByTabId stay. Unchanged rows and layouts keep their identity and an apply never echoes back as a session save. Flips the STA-9417 gate tests. --- src/preload/api/pty-api.ts | 57 +-- src/preload/api/pty-bridge-session-control.ts | 55 +-- src/preload/api/session-bridge.ts | 8 + src/preload/api/workspace-session-api.ts | 9 +- .../app-shell/use-app-startup-hydration.ts | 25 +- .../src/lib/session-write-subscriber.ts | 8 + ...etup-split-activation-single-owner.test.ts | 41 +- .../src/lib/terminal-topology-follow.test.ts | 117 ++++++ .../src/lib/terminal-topology-follow.ts | 29 ++ .../startup/startup-runtime-session-hosts.ts | 12 + src/renderer/src/store/slices/terminals.ts | 3 + .../src/store/terminals/terminal-actions.ts | 3 + .../src/store/terminals/terminal-state.ts | 2 + .../terminal-topology-mirror-unified-tabs.ts | 119 ++++++ .../terminal-topology-mirror.test.ts | 380 ++++++++++++++++++ .../terminals/terminal-topology-mirror.ts | 172 ++++++++ .../preload-api/web-workspace-session-api.ts | 5 +- src/shared/terminal-topology-slice.ts | 3 + 18 files changed, 975 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/lib/terminal-topology-follow.test.ts create mode 100644 src/renderer/src/lib/terminal-topology-follow.ts create mode 100644 src/renderer/src/startup/startup-runtime-session-hosts.ts create mode 100644 src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts create mode 100644 src/renderer/src/store/terminals/terminal-topology-mirror.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-topology-mirror.ts diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 229ef1a5414c..a95e34c7744d 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -8,6 +8,7 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../shared/terminal-leaf-move' +import type { TerminalTopologyReply } from '../../shared/terminal-topology-slice' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -59,31 +60,33 @@ export type PtyApi = { placement?: TerminalPanePlacement // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }) => Promise<{ - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchAgent?: TuiAgent - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ - shellReadyArmed?: boolean - }> + }) => Promise< + { + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ + shellReadyArmed?: boolean + } & TerminalTopologyReply + > write: (id: string, data: string, inputKind: TerminalInputKind) => void /** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */ writeAccepted: ( @@ -152,7 +155,9 @@ export type PtyApi = { ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> hasPty: (id: string) => Promise - moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise + moveLeafToNewTab: ( + request: TerminalLeafMoveRequest + ) => Promise getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 3b996d61ea23..9eeec423c8bc 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -20,6 +20,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { PreloadApi } from '../api-types' +import type { TerminalTopologyReply } from '../../shared/terminal-topology-slice' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' export const ptySessionControlApi = { @@ -55,30 +56,32 @@ export const ptySessionControlApi = { placement?: TerminalPanePlacement // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }): Promise<{ - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ - shellReadyArmed?: boolean - }> => ipcRenderer.invoke('pty:spawn', opts), + }): Promise< + { + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ + shellReadyArmed?: boolean + } & TerminalTopologyReply + > => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string, inputKind: TerminalInputKind): void => { ipcRenderer.send('pty:write', { id, data, inputKind }) }, @@ -167,7 +170,9 @@ export const ptySessionControlApi = { ): Promise<{ id: string; authoritative: boolean | null }[]> => ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), - moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise => + moveLeafToNewTab: ( + request: TerminalLeafMoveRequest + ): Promise => ipcRenderer.invoke('pty:moveLeafToNewTab', request), getMainBufferSnapshot: ( id: string, diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 537e15179ddd..8516ba992e88 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' export const sessionApi = { // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. @@ -8,6 +9,13 @@ export const sessionApi = { set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), + getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), + onTerminalTopologyChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => + callback(slice) + ipcRenderer.on('session:terminal-topology-changed', listener) + return () => ipcRenderer.removeListener('session:terminal-topology-changed', listener) + }, flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index d621e5dc7db6..97e97d3f27f9 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -5,6 +5,10 @@ import type { } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../shared/terminal-topology-slice' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -25,7 +29,10 @@ export type WorkspaceSessionApi = { worktreeId: string target: TerminalSurfaceCloseTarget reason?: 'user' | 'cleanup' - }) => Promise + }) => Promise + /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ + getTerminalTopologySlices: () => Promise + onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 0c0ab0dd5436..0acc4f993d3e 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -9,6 +9,7 @@ import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' +import { followTerminalTopology } from '../lib/terminal-topology-follow' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -22,6 +23,7 @@ import { import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' +import { listRuntimeSessionHostIdsForStartup } from '../startup/startup-runtime-session-hosts' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -31,25 +33,12 @@ import { import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId + parseExecutionHostId } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' -async function listRuntimeSessionHostIdsForStartup(): Promise { - try { - return (await window.api.runtimeEnvironments.list()).map((environment) => - toRuntimeExecutionHostId(environment.id) - ) - } catch (err) { - console.warn('Failed to list runtime session hosts for startup:', err) - return [] - } -} - /** * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, * the workspace session, SSH reconnect, and terminal restoration — then unlocks the session @@ -214,6 +203,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta useAppStore.getState().reconcileWorktreeTabModels ) }) + // Why after hydration: hydrating from the earlier session read would roll back a newer slice. + await timeRendererStartupStep('follow-terminal-topology', () => + followTerminalTopology( + window.api.session, + useAppStore.getState().applyTerminalTopologySlice, + abortController.signal + ) + ) await timeRendererStartupStep('prepare-terminal-startup-restoration', () => window.api.app.prepareTerminalStartupRestoration() ) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 581d9e875c64..c0a9948a9488 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -135,6 +135,8 @@ export function createSessionWriteSubscriber({ // identity is the only thing the pre-allocation scan below can compare them against. let prevTabsSource: TabsByWorktree | null = null let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null + // Only a mirror apply moves this map, so a new identity marks the update as main's own state. + let prevTopologySeq: AppState['terminalTopologySeqByWorktree'] | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can // never be re-detected. In-flight fields remain owned by that write until acknowledgment; @@ -243,6 +245,8 @@ export function createSessionWriteSubscriber({ if (!shouldPersistWorkspaceSession(state)) { return } + const mirrored = prev !== null && state.terminalTopologySeqByWorktree !== prevTopologySeq + prevTopologySeq = state.terminalTopologySeqByWorktree // Why: this fires on every store write and almost none of them touch a session field. Scan // identities first so the common case never allocates the 35-field snapshot or the changed // list; only a real identity change pays for them. @@ -281,6 +285,10 @@ export function createSessionWriteSubscriber({ return } prev = next + // Why: main already holds what a mirror apply wrote; saving it back would echo (D8). + if (mirrored) { + return + } for (const field of changedFields) { pendingChangedFields.add(field) } diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index 0ac2a9d5f647..263fed966ec9 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -13,8 +13,9 @@ import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation- /** * STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a * split of the (unmounted) primary tab, then the first activation of that worktree. When main's - * split reveal does not reach the window, the activation sweep mints a second tab for the setup - * PTY, so two saved panes own one terminal and one of them freezes. + * split reveal did not reach the window, the activation sweep minted a second tab for the setup + * PTY, so two saved panes owned one terminal and one of them froze. Main's topology push now + * carries the split whether or not the reveal arrives. */ const initialState = useAppStore.getState() @@ -179,6 +180,34 @@ function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] { : [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)] } +/** Main's push after it binds the setup split: the window mirrors it whether or not the reveal lands. */ +function pushSetupSplitSlice( + worktreeId: string, + primaryPtyId: string, + setupPtyId: string, + publishSeq: number +): void { + useAppStore.getState().applyTerminalTopologySlice({ + hostId: 'local', + worktreeId, + publishSeq, + revision: 1, + tabs: [{ id: TAB_B, ptyId: primaryPtyId, worktreeId, createdAt: 1 }], + layouts: { + [TAB_B]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: PRIMARY_LEAF }, + second: { type: 'leaf', leafId: SETUP_LEAF } + }, + ptyIdsByLeafId: { [PRIMARY_LEAF]: primaryPtyId, [SETUP_LEAF]: setupPtyId } + } + }, + sleeping: {} + }) +} + function setupSplitReveal( requestId: string, worktreeId: string, @@ -238,6 +267,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () if (revealSplit) { host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId)) } + pushSetupSplitSlice(worktree.id, primaryPtyId, setupPtyId, 1) await activateFirstTime(worktree.id) const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] @@ -252,13 +282,11 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () await runScenario(true) }) - // STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs. - it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { + it('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { await runScenario(false) }) - // STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate. - it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { + it('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { let duplicates = 0 for (let run = 0; run < 8; run++) { const worktree = makeWorktree() @@ -273,6 +301,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () if (run % 2 === 0) { host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId)) } + pushSetupSplitSlice(worktree.id, primaryPtyId, setupPtyId, run + 1) await activateFirstTime(worktree.id) duplicates += [...ownersByPty(worktree.id).values()].filter( (owners) => owners.length > 1 diff --git a/src/renderer/src/lib/terminal-topology-follow.test.ts b/src/renderer/src/lib/terminal-topology-follow.test.ts new file mode 100644 index 000000000000..b365534850a7 --- /dev/null +++ b/src/renderer/src/lib/terminal-topology-follow.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' +import { followTerminalTopology } from './terminal-topology-follow' + +const WT = 'repo::/wt' +const initial = useAppStore.getState() + +function slice(publishSeq: number, tabIds: string[]): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: tabIds.map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + layouts: {}, + sleeping: {} + } +} + +function fakeMain(pulled: () => TerminalTopologySlice[]) { + const calls: string[] = [] + let listener: ((slice: TerminalTopologySlice) => void) | undefined + let releasePull!: () => void + const pullGate = new Promise((resolve) => { + releasePull = resolve + }) + const unsubscribe = vi.fn() + return { + calls, + unsubscribe, + releasePull, + push: (next: TerminalTopologySlice) => listener?.(next), + source: { + onTerminalTopologyChanged: (callback: (next: TerminalTopologySlice) => void) => { + calls.push('subscribe') + listener = callback + return unsubscribe + }, + getTerminalTopologySlices: async () => { + calls.push('pull') + await pullGate + return pulled() + } + } + } +} + +const tabIds = () => (useAppStore.getState().tabsByWorktree[WT] ?? []).map((tab) => tab.id) +const apply = (next: TerminalTopologySlice) => + useAppStore.getState().applyTerminalTopologySlice(next) + +afterEach(() => { + useAppStore.setState(initial, true) +}) + +describe('followTerminalTopology', () => { + it('subscribes before pulling and resolves only once the pull is applied', async () => { + const main = fakeMain(() => [slice(2, ['a'])]) + let settled = false + const following = followTerminalTopology(main.source, apply, new AbortController().signal).then( + () => { + settled = true + } + ) + await Promise.resolve() + expect(main.calls).toEqual(['subscribe', 'pull']) + expect(settled).toBe(false) + + main.releasePull() + await following + expect(tabIds()).toEqual(['a']) + }) + + it('keeps a push that raced ahead of an older pull', async () => { + const main = fakeMain(() => [slice(2, ['a'])]) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.push(slice(3, ['a', 'b'])) + main.releasePull() + await following + expect(tabIds()).toEqual(['a', 'b']) + }) + + it('ignores a push delivered after a pull that already held a newer slice', async () => { + const main = fakeMain(() => [slice(5, ['a', 'b'])]) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.releasePull() + await following + main.push(slice(4, ['a'])) + expect(tabIds()).toEqual(['a', 'b']) + main.push(slice(6, ['b'])) + expect(tabIds()).toEqual(['b']) + }) + + it('stops following when the boot is aborted', async () => { + const main = fakeMain(() => []) + const abort = new AbortController() + const following = followTerminalTopology(main.source, apply, abort.signal) + main.releasePull() + await following + abort.abort() + expect(main.unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('keeps following pushes when the startup pull fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const main = fakeMain(() => { + throw new Error('ipc down') + }) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.releasePull() + await following + main.push(slice(1, ['a'])) + expect(tabIds()).toEqual(['a']) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/terminal-topology-follow.ts b/src/renderer/src/lib/terminal-topology-follow.ts new file mode 100644 index 000000000000..ed44cdaf9d78 --- /dev/null +++ b/src/renderer/src/lib/terminal-topology-follow.ts @@ -0,0 +1,29 @@ +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' + +type TerminalTopologySource = { + onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void + getTerminalTopologySlices: () => Promise +} + +/** + * Subscribes, then pulls: a push sent before the subscription is in the pull, and one racing the + * pull is ordered by publishSeq. Follows pushes until `signal` aborts. + */ +export async function followTerminalTopology( + source: TerminalTopologySource, + apply: (slice: TerminalTopologySlice) => void, + signal: AbortSignal +): Promise { + if (signal.aborted) { + return + } + signal.addEventListener('abort', source.onTerminalTopologyChanged(apply), { once: true }) + try { + for (const slice of await source.getTerminalTopologySlices()) { + apply(slice) + } + } catch (error) { + // The hydrated session came from the same store, so the window stays usable; pushes still apply. + console.warn('[terminal-topology] Startup pull failed:', error) + } +} diff --git a/src/renderer/src/startup/startup-runtime-session-hosts.ts b/src/renderer/src/startup/startup-runtime-session-hosts.ts new file mode 100644 index 000000000000..dc071166e050 --- /dev/null +++ b/src/renderer/src/startup/startup-runtime-session-hosts.ts @@ -0,0 +1,12 @@ +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' + +export async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5b2137120342..ec118bb7bc88 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -22,6 +22,7 @@ import { createTerminalRestartActions } from '../terminals/terminal-restart-stat import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' +import { createTerminalTopologyMirrorActions } from '../terminals/terminal-topology-mirror' import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ @@ -62,6 +63,7 @@ export const createTerminalSlice: StateCreator contestedHostWorkspaceSessions: {}, contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, + terminalTopologySeqByWorktree: {}, closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, pendingReconnectWorktreeIds: [], @@ -96,5 +98,6 @@ export const createTerminalSlice: StateCreator ...createTerminalLayoutActions(set, get), ...createTerminalStartupQueueActions(set, get), ...createWorkspaceTerminalHydrationActions(set, get), + ...createTerminalTopologyMirrorActions(set), ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 4a30b52a2d79..ba07d1ac950c 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -3,6 +3,7 @@ import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -285,6 +286,8 @@ export type TerminalActions = { setDeferredSshReconnectTargets: (targetIds: string[]) => void removeDeferredSshReconnectTarget: (targetId: string) => void removeDeferredSshSessionId: (tabId: string) => void + /** Mirrors main's terminal topology for one worktree; a slice older than the last applied is ignored. */ + applyTerminalTopologySlice: (slice: TerminalTopologySlice) => void /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ hydrateWorkspaceSession: ( session: WorkspaceSessionState, diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index d0595b8721f8..b2ee0542c5da 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -113,6 +113,8 @@ export type TerminalState = { /** Partition each restored session key was read from, so a write returns its rows there. */ contestedPrimaryHostBySessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record + /** The publishSeq of main's last applied terminal topology slice per worktree. */ + terminalTopologySeqByWorktree: Record closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean pendingReconnectWorktreeIds: string[] diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts b/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts new file mode 100644 index 000000000000..b065e8643f9f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts @@ -0,0 +1,119 @@ +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { AppState } from '../types' +import { + dedupeTabOrder, + ensureGroup, + pickNextActiveTab, + sanitizeRecentTabIds +} from '../slices/tab-group-state' + +type UnifiedTabsPatch = Partial< + Pick< + AppState, + 'unifiedTabsByWorktree' | 'groupsByWorktree' | 'activeGroupIdByWorktree' | 'layoutByWorktree' + > +> + +function isTerminalEntryFor(tab: Tab, terminalIds: ReadonlySet): boolean { + return ( + tab.contentType === 'terminal' && (terminalIds.has(tab.entityId) || terminalIds.has(tab.id)) + ) +} + +function withoutTabs(group: TabGroup, removed: ReadonlySet): TabGroup { + const tabOrder = group.tabOrder.filter((id) => !removed.has(id)) + if (tabOrder.length === group.tabOrder.length) { + return group + } + const activeTabId = + group.activeTabId && removed.has(group.activeTabId) + ? pickNextActiveTab(tabOrder, group.recentTabIds, group.activeTabId) + : group.activeTabId + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: sanitizeRecentTabIds(group.recentTabIds, tabOrder) + } +} + +function unifiedTerminalTab(tab: TerminalTab, groupId: string, sortOrder: number): Tab { + return { + id: tab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + contentType: 'terminal', + label: tab.title, + ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder, + createdAt: tab.createdAt + } +} + +/** Joins terminal rows to unified tabs on `entityId` or `id`; a new row joins the active group. */ +export function mirrorTerminalUnifiedTabs( + state: AppState, + worktreeId: string, + added: readonly TerminalTab[], + removedIds: readonly string[] +): UnifiedTabsPatch { + const current = state.unifiedTabsByWorktree[worktreeId] ?? [] + const removedSet = new Set(removedIds) + const represented = new Set( + current.flatMap((tab) => (tab.contentType === 'terminal' ? [tab.id, tab.entityId] : [])) + ) + const joining = added.filter((tab) => !represented.has(tab.id)) + const removedEntryIds = new Set( + current.filter((tab) => isTerminalEntryFor(tab, removedSet)).map((tab) => tab.id) + ) + if (joining.length === 0 && removedEntryIds.size === 0) { + return {} + } + const ensured = + joining.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const groups = (ensured?.groupsByWorktree ?? state.groupsByWorktree)[worktreeId] ?? [] + const target = ensured?.group + const joined = target + ? joining.map((tab, index) => + unifiedTerminalTab(tab, target.id, target.tabOrder.length + index) + ) + : [] + const nextGroups = groups.map((group) => { + const pruned = withoutTabs(group, removedEntryIds) + if (group.id !== target?.id) { + return pruned + } + const tabOrder = dedupeTabOrder([...pruned.tabOrder, ...joined.map((tab) => tab.id)]) + return { ...pruned, tabOrder, activeTabId: pruned.activeTabId ?? tabOrder[0] ?? null } + }) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...current.filter((tab) => !removedEntryIds.has(tab.id)), ...joined] + }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + ...(ensured && ensured.activeGroupIdByWorktree !== state.activeGroupIdByWorktree + ? { + activeGroupIdByWorktree: ensured.activeGroupIdByWorktree, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: state.layoutByWorktree[worktreeId] ?? { + type: 'leaf', + groupId: ensured.group.id + } + } + } + : {}) + } +} diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts new file mode 100644 index 000000000000..adf8c23af094 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -0,0 +1,380 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { createSessionWriteSubscriber } from '@/lib/session-write-subscriber' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologySlice, + TerminalTopologyTabRow +} from '../../../../shared/terminal-topology-slice' + +const WT = 'repo::/wt' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +const initial = useAppStore.getState() + +function windowTab(id: string, overrides: Partial = {}): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WT, + title: `Window ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function unified(id: string, overrides: Partial = {}): Tab { + return { + id, + entityId: id, + groupId: 'group', + worktreeId: WT, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function leafLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId }, + buffersByLeafId: { [leafId]: 'scrollback' } + } +} + +function row(id: string, overrides: Partial = {}): TerminalTopologyTabRow { + return { id, ptyId: `pty-${id}`, worktreeId: WT, createdAt: 1, ...overrides } +} + +function slice(publishSeq: number, overrides: Partial = {}) { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: [row('a'), row('b')], + layouts: { + a: { root: { type: 'leaf', leafId: LEAF_A }, ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } }, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } + }, + sleeping: {}, + ...overrides + } satisfies TerminalTopologySlice +} + +const group: TabGroup = { id: 'group', worktreeId: WT, activeTabId: 'a', tabOrder: ['a', 'b'] } + +function seed(): void { + useAppStore.setState({ + tabsByWorktree: { + [WT]: [ + windowTab('a', { + customTitle: 'Mine', + color: '#f00', + isPinned: true, + generation: 3, + pendingActivationSpawn: 2, + agentLaunchPane: { leafId: LEAF_A, operationId: 'op' } + }), + windowTab('b', { sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { a: leafLayout(LEAF_A, 'pty-a'), b: leafLayout(LEAF_B, 'pty-b') }, + ptyIdsByTabId: { a: ['pty-a'], b: ['pty-b'] }, + unifiedTabsByWorktree: { + [WT]: [ + unified('a'), + unified('b', { sortOrder: 1 }), + unified('/wt/readme.md', { contentType: 'editor' }) + ] + }, + groupsByWorktree: { [WT]: [{ ...group, tabOrder: ['a', 'b', '/wt/readme.md'] }] }, + activeGroupIdByWorktree: { [WT]: 'group' } + }) + useAppStore.getState().applyTerminalTopologySlice(slice(1)) +} + +const apply = (next: TerminalTopologySlice) => + useAppStore.getState().applyTerminalTopologySlice(next) +const state = () => useAppStore.getState() + +afterEach(() => { + vi.useRealTimers() + useAppStore.setState(initial, true) +}) + +describe('applyTerminalTopologySlice', () => { + beforeEach(seed) + + it('keeps presentation, liveness and activation state while replacing topology', () => { + const ptyIdsByTabId = state().ptyIdsByTabId + apply( + slice(2, { + tabs: [row('a', { ptyId: 'pty-main', launchAgent: 'codex' }), row('b')], + layouts: { + ...slice(2).layouts, + a: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' } + } + } + }) + ) + + const tab = state().tabsByWorktree[WT][0] + expect(tab).toMatchObject({ + launchAgent: 'codex', + customTitle: 'Mine', + color: '#f00', + isPinned: true, + generation: 3, + pendingActivationSpawn: 2, + agentLaunchPane: { leafId: LEAF_A, operationId: 'op' }, + // The row's ptyId is the window's live attachment, not main's last binding. + ptyId: 'pty-a' + }) + expect(state().terminalLayoutsByTabId.a).toEqual({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + activeLeafId: LEAF_A, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' }, + buffersByLeafId: { [LEAF_A]: 'scrollback' } + }) + expect(state().ptyIdsByTabId).toBe(ptyIdsByTabId) + }) + + it('clears an optional row field main dropped', () => { + apply(slice(2, { tabs: [row('a', { launchAgent: 'codex' }), row('b')] })) + apply(slice(3)) + expect(state().tabsByWorktree[WT][0]).not.toHaveProperty('launchAgent') + }) + + it('leaves the store untouched by an identical slice and schedules no save', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + const before = state() + + apply(slice(2)) + + for (const key of [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'sleepingAgentSessionsByPaneKey', + 'ptyIdsByTabId' + ] as const) { + expect(state()[key]).toBe(before[key]) + } + vi.advanceTimersByTime(1_000) + expect(persist).not.toHaveBeenCalled() + dispose() + }) + + it('never echoes a changed slice back as a session save', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + vi.advanceTimersByTime(1_000) + expect(persist).not.toHaveBeenCalled() + + // A later local edit still saves. + state().setTabCustomTitle('a', 'renamed') + vi.advanceTimersByTime(1_000) + expect(persist).toHaveBeenCalledTimes(1) + dispose() + }) + + it('keeps every other tab and layout reference when one tab changes', () => { + const before = state() + apply( + slice(2, { + layouts: { + ...slice(2).layouts, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b2' } } + } + }) + ) + expect(state().terminalLayoutsByTabId.a).toBe(before.terminalLayoutsByTabId.a) + expect(state().terminalLayoutsByTabId.b.ptyIdsByLeafId).toEqual({ [LEAF_B]: 'pty-b2' }) + expect(state().tabsByWorktree).toBe(before.tabsByWorktree) + }) + + it('removes a tab and its unified entry joined on entityId, leaving other tabs alone', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [ + unified('a'), + unified('unified-b', { entityId: 'b' }), + unified('/wt/readme.md', { contentType: 'editor' }) + ] + }, + groupsByWorktree: { + [WT]: [ + { + ...group, + activeTabId: 'unified-b', + tabOrder: ['a', 'unified-b', '/wt/readme.md'], + recentTabIds: ['a', 'unified-b'] + } + ] + } + }) + + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a']) + expect(state().terminalLayoutsByTabId).not.toHaveProperty('b') + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', '/wt/readme.md']) + expect(state().groupsByWorktree[WT][0]).toMatchObject({ + tabOrder: ['a', '/wt/readme.md'], + activeTabId: 'a', + recentTabIds: ['a'] + }) + }) + + it('appends a new tab to the active group without activating it', () => { + apply( + slice(2, { + tabs: [...slice(2).tabs, row('c', { defaultTitle: 'Terminal 3', launchAgent: 'claude' })], + layouts: { + ...slice(2).layouts, + c: { root: { type: 'leaf', leafId: LEAF_C }, ptyIdsByLeafId: { [LEAF_C]: 'pty-c' } } + } + }) + ) + + expect(state().tabsByWorktree[WT][2]).toEqual({ + id: 'c', + ptyId: 'pty-c', + worktreeId: WT, + createdAt: 1, + defaultTitle: 'Terminal 3', + launchAgent: 'claude', + title: 'Terminal 3', + customTitle: null, + color: null, + sortOrder: 2 + }) + expect(state().terminalLayoutsByTabId.c).toMatchObject({ + root: { type: 'leaf', leafId: LEAF_C }, + activeLeafId: LEAF_C + }) + expect(state().unifiedTabsByWorktree[WT].at(-1)).toMatchObject({ + id: 'c', + entityId: 'c', + groupId: 'group', + contentType: 'terminal' + }) + expect(state().groupsByWorktree[WT][0]).toMatchObject({ + activeTabId: 'a', + tabOrder: ['a', 'b', '/wt/readme.md', 'c'] + }) + expect(state().ptyIdsByTabId).not.toHaveProperty('c') + }) + + it('does not add a unified entry a tab already has under another id', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [unified('a'), unified('b'), unified('u-c', { entityId: 'c' })] + } + }) + apply(slice(2, { tabs: [...slice(2).tabs, row('c')] })) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b', 'u-c']) + }) + + it('leaves a runtime-hosted tab under the same worktree id untouched', () => { + const runtimeTab = windowTab('remote-tab', { ptyId: 'remote:env@@handle' }) + useAppStore.setState({ + tabsByWorktree: { [WT]: [...state().tabsByWorktree[WT], runtimeTab] }, + unifiedTabsByWorktree: { + [WT]: [ + ...state().unifiedTabsByWorktree[WT], + unified('remote-tab', { executionHostId: 'runtime:env' }) + ] + } + }) + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'remote-tab']) + expect(state().tabsByWorktree[WT][1]).toBe(runtimeTab) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toContain('remote-tab') + }) + + it('replaces only this worktree’s sleeping records', () => { + const record = (paneKey: string, worktreeId: string): SleepingAgentSessionRecord => ({ + paneKey, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: paneKey }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + }) + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [`a:${LEAF_A}`]: record(`a:${LEAF_A}`, WT), + 'other:leaf': record('other:leaf', 'repo::/other') + } + }) + apply(slice(2, { sleeping: { [`b:${LEAF_B}`]: record(`b:${LEAF_B}`, WT) } })) + expect(Object.keys(state().sleepingAgentSessionsByPaneKey).sort()).toEqual([ + `b:${LEAF_B}`, + 'other:leaf' + ]) + }) + + it('ignores a slice older than the last one applied', () => { + apply(slice(5, { tabs: [row('a')], layouts: { a: slice(5).layouts.a } })) + const before = state() + apply(slice(4)) + expect(state()).toBe(before) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a']) + }) + + it('leaves other worktrees and non-terminal tabs untouched', () => { + const other = windowTab('x', { worktreeId: 'repo::/other' }) + useAppStore.setState({ + tabsByWorktree: { ...state().tabsByWorktree, 'repo::/other': [other] } + }) + apply(slice(2, { tabs: [], layouts: {} })) + expect(state().tabsByWorktree['repo::/other'][0]).toBe(other) + expect(state().tabsByWorktree[WT]).toEqual([]) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['/wt/readme.md']) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts new file mode 100644 index 000000000000..f77e3d70285f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -0,0 +1,172 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologyLayout, + TerminalTopologySlice, + TerminalTopologyTabRow +} from '../../../../shared/terminal-topology-slice' +import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { sameStringRecord, terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' +import type { AppState } from '../types' +import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' +import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' + +const OPTIONAL_ROW_FIELDS = [ + 'launchAgent', + 'defaultTitle', + 'shellOverride', + 'startupCwd', + 'forceHostRuntime', + 'quickCommandLabel' +] as const satisfies readonly (keyof TerminalTopologyTabRow)[] + +type _UnmirroredRowField = Exclude< + keyof TerminalTopologyTabRow, + 'id' | 'ptyId' | 'worktreeId' | 'createdAt' | (typeof OPTIONAL_ROW_FIELDS)[number] +> +void (true satisfies [_UnmirroredRowField] extends [never] ? true : never) + +/** Main's row fields replace the window's; presentation stays, and so does `ptyId`, the live attachment (D1). */ +function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { + const next: TerminalTab = { ...current, ...row, ptyId: current.ptyId } + for (const field of OPTIONAL_ROW_FIELDS) { + if (!(field in row)) { + delete next[field] + } + } + return structuralValuesEqual(next, current) ? current : next +} + +/** A tab main created; its ptyId seeds the window's attachment, as a revealed tab's would. */ +function newTabRow(row: TerminalTopologyTabRow, sortOrder: number): TerminalTab { + return { + ...row, + title: row.defaultTitle ?? 'Terminal', + customTitle: null, + color: null, + sortOrder + } +} + +function mirrorLayout( + current: TerminalLayoutSnapshot | undefined, + layout: TerminalTopologyLayout +): TerminalLayoutSnapshot { + if ( + current && + terminalLayoutNodeEqual(current.root, layout.root) && + sameStringRecord(current.ptyIdsByLeafId, layout.ptyIdsByLeafId) && + sameStringRecord(current.titlesByLeafId, layout.titlesByLeafId) + ) { + return current + } + const { + ptyIdsByLeafId: _ptyIds, + titlesByLeafId: _titles, + ...presentation + } = current ?? emptyLayoutSnapshot() + const activeLeafId = current + ? presentation.activeLeafId + : resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: null, + ptyIdsByLeafId: layout.ptyIdsByLeafId + }) + return { ...presentation, ...layout, activeLeafId } +} + +function mirrorSleepingRecords( + current: Record, + slice: TerminalTopologySlice +): Record { + const others = Object.entries(current).filter( + ([, record]) => record.worktreeId !== slice.worktreeId + ) + const next = { ...Object.fromEntries(others), ...slice.sleeping } + return structuralValuesEqual(next, current) ? current : next +} + +/** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ +function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { + const entry = state.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) + ) + return parseExecutionHostId(entry?.executionHostId)?.kind === 'runtime' +} + +/** + * Main's slice replaces the worktree's terminal topology; everything else is the window's. + * Unchanged rows and layouts keep their identity, so an identical slice changes only the seq. + */ +export function mirrorTerminalTopologySlice( + state: AppState, + slice: TerminalTopologySlice +): Partial { + const { worktreeId } = slice + const currentTabs = state.tabsByWorktree[worktreeId] ?? [] + const rowById = new Map(slice.tabs.map((row) => [row.id, row])) + const kept = currentTabs.flatMap((tab) => { + const row = rowById.get(tab.id) + if (row) { + return [mirrorTabRow(tab, row)] + } + return isRuntimeHostedTab(state, worktreeId, tab.id) ? [tab] : [] + }) + const currentIds = new Set(currentTabs.map((tab) => tab.id)) + const added = slice.tabs + .filter((row) => !currentIds.has(row.id)) + .map((row, index) => newTabRow(row, kept.length + index)) + const tabs = [...kept, ...added] + const tabsChanged = + tabs.length !== currentTabs.length || tabs.some((tab, index) => tab !== currentTabs[index]) + const keptIds = new Set(tabs.map((tab) => tab.id)) + const removedIds = currentTabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id) + + const layouts = { ...state.terminalLayoutsByTabId } + for (const [tabId, layout] of Object.entries(slice.layouts)) { + layouts[tabId] = mirrorLayout(layouts[tabId], layout) + } + for (const tab of added) { + layouts[tab.id] ??= emptyLayoutSnapshot() + } + for (const tabId of removedIds) { + delete layouts[tabId] + } + const layoutsChanged = + added.length + removedIds.length > 0 || + Object.keys(slice.layouts).some( + (tabId) => layouts[tabId] !== state.terminalLayoutsByTabId[tabId] + ) + + const sleeping = mirrorSleepingRecords(state.sleepingAgentSessionsByPaneKey, slice) + return { + ...(tabsChanged ? { tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: tabs } } : {}), + ...(layoutsChanged ? { terminalLayoutsByTabId: layouts } : {}), + ...(sleeping !== state.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: sleeping } + : {}), + ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), + terminalTopologySeqByWorktree: { + ...state.terminalTopologySeqByWorktree, + [worktreeId]: slice.publishSeq + } + } +} + +export function createTerminalTopologyMirrorActions( + set: TerminalStoreSet +): Pick { + return { + applyTerminalTopologySlice: (slice) => { + set((s) => + // Pushes and the startup pull race; the higher publishSeq is the newer slice. + slice.publishSeq > (s.terminalTopologySeqByWorktree[slice.worktreeId] ?? 0) + ? mirrorTerminalTopologySlice(s, slice) + : s + ) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 553b065c9923..f8108c75d610 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -82,7 +82,10 @@ export function createWebWorkspaceSessionApi(): Partial { ) }, // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. - closeTerminalSurface: async () => {}, + closeTerminalSurface: async () => ({}), + // Why empty: web clients follow the host's session tabs, not main's topology push. + getTerminalTopologySlices: async () => [], + onTerminalTopologyChanged: () => () => {}, // localStorage writes synchronously, so there is no deferred web flush. flush: async () => {}, readTerminalScrollback: () => null, diff --git a/src/shared/terminal-topology-slice.ts b/src/shared/terminal-topology-slice.ts index 79c0cc0b26a6..cc475ad0c458 100644 --- a/src/shared/terminal-topology-slice.ts +++ b/src/shared/terminal-topology-slice.ts @@ -33,3 +33,6 @@ export type TerminalTopologySlice = { layouts: Record sleeping: Record } + +/** On a reply whose write main publishes: the publishSeq of the push holding it. */ +export type TerminalTopologyReply = { publishSeq?: number } From d96b58703d2abc0adef62ff4966fc66271c3a37b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 14:20:16 -0400 Subject: [PATCH 11/75] refactor(terminal): declare spawn's publishSeq inline --- src/preload/api/pty-api.ts | 54 +++++++++---------- src/preload/api/pty-bridge-session-control.ts | 52 +++++++++--------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index a95e34c7744d..152c31be825a 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -60,33 +60,33 @@ export type PtyApi = { placement?: TerminalPanePlacement // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }) => Promise< - { - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchAgent?: TuiAgent - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ - shellReadyArmed?: boolean - } & TerminalTopologyReply - > + }) => Promise<{ + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ + shellReadyArmed?: boolean + /** The topology push holding this spawn's binding. */ + publishSeq?: number + }> write: (id: string, data: string, inputKind: TerminalInputKind) => void /** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */ writeAccepted: ( diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 9eeec423c8bc..9a2e4735a466 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -56,32 +56,32 @@ export const ptySessionControlApi = { placement?: TerminalPanePlacement // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }): Promise< - { - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ - shellReadyArmed?: boolean - } & TerminalTopologyReply - > => ipcRenderer.invoke('pty:spawn', opts), + }): Promise<{ + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ + shellReadyArmed?: boolean + /** The topology push holding this spawn's binding. */ + publishSeq?: number + }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string, inputKind: TerminalInputKind): void => { ipcRenderer.send('pty:write', { id, data, inputKind }) }, From b1b55902d2e38704b4e5f046344a3567f44d95a3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 14:23:35 -0400 Subject: [PATCH 12/75] fix(terminal): a placed new tab takes startupCwd only from the window's row Main stamped the spawn's cwd on every tab it built from placement. Once main's copy is authoritative that would add startupCwd to rows the window never gave one. --- .../terminal-pane-placement-apply.test.ts | 11 +++++++++-- .../terminal-pane-placement-apply.ts | 13 +++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts index 95200c94d08d..0b0f7b01ab6c 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts @@ -114,15 +114,22 @@ describe('new-tab placement', () => { it('keeps a minted field the row leaves undefined', () => { const placed = bind(session(), { kind: 'new-tab', - row: { title: 'build', startupCwd: undefined, customTitle: undefined } + row: { title: 'build', customTitle: undefined } }) expect(placed.tabsByWorktree[WORKTREE]?.[0]).toMatchObject({ title: 'build', - startupCwd: '/fixture/sub', customTitle: null }) }) + it("takes startupCwd only from the row, never from the spawn's cwd", () => { + const placed = bind(session(), { kind: 'new-tab', row: { title: 'build' } }) + expect(placed.tabsByWorktree[WORKTREE]?.[0]).not.toHaveProperty('startupCwd') + const withCwd = bind(session(), { kind: 'new-tab', row: { startupCwd: '/fixture/build' } }) + expect(withCwd.tabsByWorktree[WORKTREE]?.[0]?.startupCwd).toBe('/fixture/build') + expect(bind(session()).tabsByWorktree[WORKTREE]?.[0]?.startupCwd).toBe('/fixture/sub') + }) + it('is ignored when the tab already exists, and for a leaf already present', () => { const row = { title: 'other', color: '#000000' } const existing = session(leaf(A)) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts index 79217cc25471..51691bec2737 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts @@ -13,14 +13,19 @@ import { type SplitPlacement = Extract -/** The minted row with the sender's creation fields over it; a field it omits keeps the minted value. */ +/** + * The minted row with the sender's creation fields over it; a field it omits keeps the minted + * value, except `startupCwd`, which is the window's to set and never the spawn's cwd. + */ export function placedTerminalTab( minted: TerminalTab, placement: TerminalPanePlacement | undefined ): TerminalTab { - return placement?.kind === 'new-tab' && placement.row - ? { ...minted, ...omitUndefinedValues(placement.row) } - : minted + if (placement?.kind !== 'new-tab' || !placement.row) { + return minted + } + const { startupCwd: _spawnCwd, ...mintedWithoutCwd } = minted + return { ...mintedWithoutCwd, ...omitUndefinedValues(placement.row) } } /** From 7f7f2e3f35ee721e10f3a0a147d7ad6ae593831f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 14:44:49 -0400 Subject: [PATCH 13/75] refactor(terminal): mounted panes follow the layout main publishes, for every tab The pane reconciler ran only for web clients and remote-server tabs. It now runs for every tab, so a desktop window follows main's layout: - a leaf main dropped is detached, never killed, so a PTY main moved to another tab keeps one output owner; a pane still starting its PTY is kept until main binds it; - removals apply before insertions, and an inserted leaf with no PTY spawns through the normal mount; - divider ratios and orientation apply in place, so web and remote clients now follow desktop pane sizes; a divider mid-drag is left alone; - the write-back after insertions is gone. --- ...erminal-live-layout-reconciliation.test.ts | 84 ++---- .../terminal-live-layout-reconciliation.ts | 113 +++++---- .../terminal-mounted-layout-reconcile.test.ts | 239 ++++++++++++++++++ .../use-terminal-pane-reconciliation.ts | 108 ++------ .../pane-layout-geometry-apply.ts | 4 + ...ssion-split-pane-retirement-fences.test.ts | 31 ++- 6 files changed, 362 insertions(+), 217 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts index 814ca8d974f7..8bcbbe5543cf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - isHostAuthoritativeLayout, planTerminalLiveLayoutInsertions, planTerminalLiveLayoutRemovals, selectRetiredPaneIds, @@ -8,38 +7,6 @@ import { } from './terminal-live-layout-reconciliation' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' -const LOCAL_PTY = 'pty-local-123' -const REMOTE_PTY = 'remote:env-1@@term_abc' - -describe('isHostAuthoritativeLayout', () => { - it('is true for any web client regardless of pty ids', () => { - expect(isHostAuthoritativeLayout({ isWebClient: true, ptyIdsByLeafId: { a: LOCAL_PTY } })).toBe( - true - ) - }) - - it('is true for a desktop client when a leaf has a remote-runtime pty (remote server tab)', () => { - // Why: the split-render bug — desktop viewing a remote server got the host - // layout but skipped reconciliation, so the split never rendered. - expect( - isHostAuthoritativeLayout({ - isWebClient: false, - ptyIdsByLeafId: { a: LOCAL_PTY, b: REMOTE_PTY } - }) - ).toBe(true) - }) - - it('is false for a desktop client with only local ptys (local tab splits directly)', () => { - expect( - isHostAuthoritativeLayout({ isWebClient: false, ptyIdsByLeafId: { a: LOCAL_PTY } }) - ).toBe(false) - }) - - it('is false for a desktop client with no pty ids', () => { - expect(isHostAuthoritativeLayout({ isWebClient: false, ptyIdsByLeafId: undefined })).toBe(false) - }) -}) - describe('planTerminalLiveLayoutInsertions', () => { it('plans a host-added split leaf from an already-mounted source leaf', () => { const layout: TerminalPaneLayoutNode = { @@ -282,32 +249,23 @@ describe('planTerminalLiveLayoutRemovals', () => { }) describe('selectRetiredPaneIds', () => { - const view = (ptyIdsByPane: Record) => ({ - paneCount: Object.keys(ptyIdsByPane).length, + const view = (startingPaneIds: number[] = []) => ({ paneIdForLeaf: (leafId: string) => (leafId === 'leaf-b' ? 2 : leafId === 'leaf-c' ? 3 : null), - ptyIdForPane: (paneId: number) => ptyIdsByPane[paneId] - }) - - it('closes the pane whose transport lost its PTY', () => { - // Why: the host retired the leaf because its PTY ended, so a pane that no - // longer has one is exactly the blank ghost the layout stopped naming. - expect(selectRetiredPaneIds(['leaf-b'], view({ 1: 'pty-a', 2: null }))).toEqual([2]) + isPaneStarting: (paneId: number) => startingPaneIds.includes(paneId) }) - it('keeps a pane still bound to a PTY or not yet attached to a transport', () => { - // A stale snapshot may simply not name a live pane yet; a pane with no - // transport is still mounting. Neither is evidence of a retired leaf. - expect(selectRetiredPaneIds(['leaf-b'], view({ 1: 'pty-a', 2: 'pty-b' }))).toEqual([]) - expect(selectRetiredPaneIds(['leaf-b'], view({ 1: 'pty-a', 2: undefined }))).toEqual([]) + it('detaches a retired pane whether or not it still holds its PTY', () => { + // Why: detaching never kills, so a live PTY main moved elsewhere needs no wait. + expect(selectRetiredPaneIds(['leaf-b', 'leaf-c'], view())).toEqual([2, 3]) }) - it('never removes the last pane on the tab', () => { - expect(selectRetiredPaneIds(['leaf-b'], view({ 2: null }))).toEqual([]) - expect(selectRetiredPaneIds(['leaf-b', 'leaf-c'], view({ 2: null, 3: null }))).toEqual([2]) + it('keeps a pane that is still starting its PTY', () => { + // Main has not bound it yet, so a layout without it says nothing about it. + expect(selectRetiredPaneIds(['leaf-b', 'leaf-c'], view([2]))).toEqual([3]) }) it('skips a leaf that has no mounted pane', () => { - expect(selectRetiredPaneIds(['leaf-x'], view({ 1: 'pty-a', 2: null }))).toEqual([]) + expect(selectRetiredPaneIds(['leaf-x'], view())).toEqual([]) }) }) @@ -360,12 +318,8 @@ describe('trackRetiredLeafIds', () => { }) }) -describe('host retirement that lands before the transport teardown', () => { - it('removes the pane on the reconciliation after its PTY clears', () => { - // Why: the host drops the leaf and ends its PTY in one step, but the two - // reach the client separately. If the layout arrives first the pane still - // holds its PTY and must not be closed yet; once the exit lands and rewrites - // the layout bindings, the effect runs again and must close it then. +describe('a retirement that lands while the pane is still starting', () => { + it('detaches the pane on a later reconciliation once it has started', () => { const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } const mounted = ['leaf-a', 'leaf-b'] const paneIdForLeaf = (leafId: string) => @@ -380,11 +334,7 @@ describe('host retirement that lands before the transport teardown', () => { let removals = planTerminalLiveLayoutRemovals(layout, mounted, retired) expect(removals).toEqual(['leaf-b']) expect( - selectRetiredPaneIds(removals, { - paneCount: 2, - paneIdForLeaf, - ptyIdForPane: (paneId) => (paneId === 2 ? 'pty-b' : 'pty-a') - }) + selectRetiredPaneIds(removals, { paneIdForLeaf, isPaneStarting: (paneId) => paneId === 2 }) ).toEqual([]) retired = trackRetiredLeafIds({ @@ -394,12 +344,8 @@ describe('host retirement that lands before the transport teardown', () => { mountedLeafIds: mounted }) removals = planTerminalLiveLayoutRemovals(layout, mounted, retired) - expect( - selectRetiredPaneIds(removals, { - paneCount: 2, - paneIdForLeaf, - ptyIdForPane: (paneId) => (paneId === 2 ? null : 'pty-a') - }) - ).toEqual([2]) + expect(selectRetiredPaneIds(removals, { paneIdForLeaf, isPaneStarting: () => false })).toEqual([ + 2 + ]) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index 859f6c971688..08ffe4b20f06 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -1,29 +1,11 @@ import type { + TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/terminal-tab-types' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { collectLeafIds } from './terminal-pane-layout-tree' -/** - * Whether a tab's split layout is owned by a host (web/mobile clients, or a - * desktop client viewing a remote "Orca server" worktree) rather than built - * locally. Such layouts arrive via the host snapshot, so the live-layout - * reconciler must materialize their panes. A desktop client only needs this for - * remote-runtime tabs — local tabs split their panes directly. - */ -export function isHostAuthoritativeLayout(args: { - isWebClient: boolean - ptyIdsByLeafId: Record | undefined -}): boolean { - if (args.isWebClient) { - return true - } - return Object.values(args.ptyIdsByLeafId ?? {}).some( - (ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) - ) -} - export type TerminalLiveLayoutInsertion = { sourceLeafId: string sourceLeafIds: string[] @@ -87,10 +69,9 @@ function mountedLeafIdsIn( } /** - * Mounted leaves the host layout no longer names. The host retires a leaf when - * its PTY ends, so a pane still mounted for it is a ghost: it renders nothing - * and, once it is the only pane left, absorbs the tab's next close. An empty - * layout plans nothing — absence of a tree is not evidence about any pane. + * Mounted leaves the layout no longer names: main closed, moved or retired + * them, so a pane still mounted for one is a ghost. An empty layout plans + * nothing — absence of a tree is not evidence about any pane. */ export function planTerminalLiveLayoutRemovals( root: TerminalPaneLayoutNode | null | undefined, @@ -102,7 +83,7 @@ export function planTerminalLiveLayoutRemovals( } const layoutLeafIds = new Set(collectLeafIds(root)) // Why: a mounted leaf the layout stopped naming is a removal only once the - // host is known to have retired it (trackRetiredLeafIds). A snapshot landing + // layout is known to have retired it (trackRetiredLeafIds). A snapshot landing // while the client is still starting a pane must not read as a retirement. return [...currentLeafIds].filter( (leafId) => !layoutLeafIds.has(leafId) && retiredLeafIds.has(leafId) @@ -181,38 +162,82 @@ export function planTerminalLiveLayoutInsertions( return insertions } -/** Panes to close for leaves the host retired. Only a pane whose transport has - * no PTY any more is a ghost; a pane with no transport yet, or still bound to - * a PTY, may simply not be named by a stale snapshot. The last pane on the tab - * is never removed. */ +/** Panes to detach for leaves the layout retired. A pane still starting its PTY (no transport + * yet, or a spawn awaiting its id) is not in main's layout yet, so its absence retires nothing. */ export function selectRetiredPaneIds( retiredLeafIds: readonly string[], view: { - paneCount: number paneIdForLeaf: (leafId: string) => number | null - ptyIdForPane: (paneId: number) => string | null | undefined + isPaneStarting: (paneId: number) => boolean } ): number[] { - const paneIds: number[] = [] - for (const leafId of retiredLeafIds) { - if (view.paneCount - paneIds.length <= 1) { - break - } + return retiredLeafIds.flatMap((leafId) => { const paneId = view.paneIdForLeaf(leafId) - if (paneId === null || view.ptyIdForPane(paneId) !== null) { + return paneId === null || view.isPaneStarting(paneId) ? [] : [paneId] + }) +} + +/** + * Makes the mounted panes follow the store's layout: retired leaves detach (no kill; the PTY may + * live on in another tab), then missing leaves mount and attach or spawn, then geometry applies + * in place. It writes no layout back: the store already holds this one. Returns whether panes + * were added or removed. + */ +export function reconcileMountedTerminalLayout( + manager: Pick< + PaneManager, + | 'getPanes' + | 'getNumericIdForLeaf' + | 'splitPaneAroundLeafIds' + | 'detachPaneForExternalMove' + | 'applyLayoutGeometry' + >, + layout: Pick & { root: TerminalPaneLayoutNode }, + retiredLeafIds: ReadonlySet, + isPaneStarting: (paneId: number) => boolean +): boolean { + const mountedLeafIds = (): string[] => manager.getPanes().map((pane) => pane.leafId) + const removals = planTerminalLiveLayoutRemovals(layout.root, mountedLeafIds(), retiredLeafIds) + // Removals first, so insertions anchor on the panes that stay. + const detached = selectRetiredPaneIds(removals, { + paneIdForLeaf: (leafId) => manager.getNumericIdForLeaf(leafId), + isPaneStarting + }).filter((paneId) => manager.detachPaneForExternalMove(paneId)) + let inserted = false + for (const insertion of planTerminalLiveLayoutInsertions(layout.root, mountedLeafIds())) { + const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId) + if (sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId) !== null) { continue } - paneIds.push(paneId) + // An unbound leaf spawns through the pane's normal mount, like a local split. + const ptyId = layout.ptyIdsByLeafId?.[insertion.newLeafId] + const ratio = + insertion.ratio === undefined || insertion.placement === 'after' + ? insertion.ratio + : 1 - insertion.ratio + const created = manager.splitPaneAroundLeafIds( + insertion.sourceLeafIds, + sourcePaneId, + insertion.direction, + { + ...(ratio !== undefined && { ratio }), + ...(ptyId && { ptyId }), + leafId: insertion.newLeafId, + placement: insertion.placement + } + ) + inserted ||= created !== null } - return paneIds + manager.applyLayoutGeometry(layout.root) + return detached.length > 0 || inserted } /** - * Leaves the host dropped from its layout whose panes are still mounted. Only a - * leaf the host named before can be retired: a leaf it has never named belongs - * to a pane the client is still starting. A retired leaf stays retired until - * its pane is gone or the host names it again, so a removal skipped while the - * transport still held its PTY is planned again once that PTY clears. + * Leaves the layout dropped whose panes are still mounted. Only a leaf the + * layout named before can be retired: a leaf it has never named belongs to a + * pane the client is still starting. A retired leaf stays retired until its + * pane is gone or the layout names it again, so a removal skipped while the + * pane was still starting is planned again on a later reconciliation. */ export function trackRetiredLeafIds(args: { retiredLeafIds: ReadonlySet diff --git a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts new file mode 100644 index 000000000000..02c80898c0ed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { PaneManagerOptions } from '@/lib/pane-manager/pane-manager-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { PtyTransport } from './pty-transport-types' +import { serializePaneTree } from './layout-serialization' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' + +// Why: happy-dom has no canvas, so xterm cannot open; reconciliation never touches the terminal. +vi.mock('@/lib/pane-manager/pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('@/lib/pane-manager/pane-manager') +const { reconcileMountedTerminalLayout } = await import('./terminal-live-layout-reconciliation') + +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const NEW = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction: 'vertical', + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) + +type Mounted = { + manager: InstanceType + root: HTMLDivElement + onLayoutChanged: Mock> + onPaneCreated: Mock> + onPaneClosed: Mock> +} + +const mounted: Mounted[] = [] + +function mount( + leafIds: string[], + wiring: Partial> = {} +): Mounted { + const root = document.createElement('div') + document.body.appendChild(root) + const onLayoutChanged = vi.fn() + const onPaneCreated = vi.fn(wiring.onPaneCreated) + const onPaneClosed = vi.fn(wiring.onPaneClosed) + const manager = new PaneManager(root, { + linkOpenHint: () => '', + onLayoutChanged, + onPaneCreated, + onPaneClosed + }) + const [first, ...rest] = leafIds + let previous = manager.createInitialPane({ leafId: first }) + for (const leafId of rest) { + previous = manager.splitPane(previous.id, 'vertical', { leafId })! + } + onLayoutChanged.mockClear() + onPaneCreated.mockClear() + onPaneClosed.mockClear() + const view = { manager, root, onLayoutChanged, onPaneCreated, onPaneClosed } + mounted.push(view) + return view +} + +function reconcile( + view: Mounted, + root: TerminalPaneLayoutNode, + options: { ptyIdsByLeafId?: Record; retired?: string[]; starting?: string[] } = {} +): boolean { + const startingPaneIds = new Set( + (options.starting ?? []).map((leafId) => view.manager.getNumericIdForLeaf(leafId)) + ) + return reconcileMountedTerminalLayout( + view.manager, + { root, ptyIdsByLeafId: options.ptyIdsByLeafId ?? {} }, + new Set(options.retired ?? []), + (paneId) => startingPaneIds.has(paneId) + ) +} + +const tree = (view: Mounted): TerminalPaneLayoutNode | null => + view.root.firstElementChild instanceof HTMLElement + ? serializePaneTree(view.root.firstElementChild) + : null + +beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', () => {}) +}) + +afterEach(() => { + for (const view of mounted.splice(0)) { + view.manager.destroy() + view.root.remove() + } + vi.unstubAllGlobals() +}) + +describe('reconcileMountedTerminalLayout', () => { + it('spawns an inserted unbound leaf once, through the normal split placement', () => { + const view = mount([A]) + const layout = split(leaf(A), leaf(NEW)) + + expect(reconcile(view, layout)).toBe(true) + expect(reconcile(view, layout)).toBe(false) + + expect(view.onPaneCreated).toHaveBeenCalledTimes(1) + const [pane, hints] = view.onPaneCreated.mock.calls[0] + expect(pane.leafId).toBe(NEW) + expect(hints).not.toHaveProperty('ptyId') + expect(hints?.placement).toMatchObject({ kind: 'split', parentLeafId: A }) + expect(tree(view)).toEqual(layout) + }) + + it('attaches an inserted bound leaf to its PTY', () => { + const view = mount([A]) + reconcile(view, split(leaf(A), leaf(NEW)), { ptyIdsByLeafId: { [NEW]: 'pty-new' } }) + expect(view.onPaneCreated.mock.calls[0]?.[1]).toMatchObject({ ptyId: 'pty-new' }) + }) + + it('persists nothing itself: only the split reports its own layout change', () => { + const view = mount([A]) + reconcile(view, split(leaf(A), leaf(NEW), 0.3)) + expect(view.onLayoutChanged).toHaveBeenCalledTimes(1) + expect(view.onLayoutChanged).toHaveBeenCalledWith() + }) + + it('detaches a retired leaf instead of closing it', () => { + const view = mount([A, B]) + expect(reconcile(view, leaf(A), { retired: [B] })).toBe(true) + expect(view.onPaneClosed).toHaveBeenCalledTimes(1) + expect(view.onPaneClosed.mock.calls[0]?.[1]).toMatchObject({ leafId: B, reason: 'detach' }) + expect(tree(view)).toEqual(leaf(A)) + }) + + it('keeps a pane that is still starting, and one the layout never named', () => { + const view = mount([A, B]) + expect(reconcile(view, leaf(A), { retired: [B], starting: [B] })).toBe(false) + expect(reconcile(view, leaf(A))).toBe(false) + expect(view.onPaneClosed).not.toHaveBeenCalled() + expect(view.manager.getPanes().map((pane) => pane.leafId)).toEqual([A, B]) + }) + + it('removes before inserting, so the new leaf anchors on a pane that stays', () => { + const view = mount([A, B]) + const layout = split(leaf(A), leaf(NEW)) + reconcile(view, layout, { retired: [B] }) + expect(tree(view)).toEqual(layout) + }) + + it('applies a ratio change in place without remounting', () => { + const view = mount([A, B]) + const before = view.manager.getPanes().map((pane) => [pane.id, pane.container]) + expect(reconcile(view, split(leaf(A), leaf(B), 0.25))).toBe(false) + expect(tree(view)).toEqual(split(leaf(A), leaf(B), 0.25)) + expect(view.manager.getPanes().map((pane) => [pane.id, pane.container])).toEqual(before) + expect(view.onPaneCreated).not.toHaveBeenCalled() + expect(view.onLayoutChanged).not.toHaveBeenCalled() + }) + + it('leaves a divider the user is dragging alone', () => { + const view = mount([A, B]) + view.root.querySelector('.pane-divider')?.classList.add('is-dragging') + reconcile(view, split(leaf(A), leaf(B), 0.25)) + expect(tree(view)).toEqual(split(leaf(A), leaf(B))) + }) +}) + +describe('a leaf main moved to another tab', () => { + const originalWindow = globalThis.window + let deliver: ((payload: { id: string; data: string }) => void) | null = null + + beforeEach(() => { + vi.resetModules() + deliver = null + installIpcPtyWindow(originalWindow, { + data: (callback) => { + deliver = callback + } + }) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + it.each(['source first', 'destination first'] as const)( + 'is never killed and has one output owner (%s)', + async (order) => { + const { createIpcPtyTransport } = await import('./pty-transport') + const received: string[] = [] + // TerminalPane's wiring: a hinted PTY attaches, and a detached pane releases without a kill. + const wiring = ( + tab: string, + transports = new Map() + ): Parameters[1] => ({ + onPaneCreated: (pane, hints) => { + const transport = createIpcPtyTransport({}) + transport.attach({ + existingPtyId: hints?.ptyId ?? `pty-${pane.leafId}`, + callbacks: { onData: (data) => received.push(`${tab}:${data}`) } + }) + transports.set(pane.id, transport) + }, + onPaneClosed: (paneId, closed) => { + const transport = transports.get(paneId) + return closed?.reason === 'detach' + ? transport?.detach?.({ preserveExitObserver: false }) + : transport?.destroy?.() + } + }) + const source = mount([A, B], wiring('source')) + const destination = mount([A], wiring('destination')) + const moveOut = (): boolean => reconcile(source, leaf(A), { retired: [B] }) + const moveIn = (): boolean => + reconcile(destination, split(leaf(A), leaf(B)), { ptyIdsByLeafId: { [B]: `pty-${B}` } }) + + if (order === 'source first') { + moveOut() + moveIn() + } else { + moveIn() + moveOut() + } + deliver?.({ id: `pty-${B}`, data: 'live' }) + + expect(window.api.pty.kill).not.toHaveBeenCalled() + expect(received).toEqual(['destination:live']) + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts index 879b9509e3f3..e05eb868a902 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts @@ -7,10 +7,7 @@ import { import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { - isHostAuthoritativeLayout, - planTerminalLiveLayoutInsertions, - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, + reconcileMountedTerminalLayout, trackRetiredLeafIds } from './terminal-live-layout-reconciliation' import { collectLeafIds } from './terminal-pane-layout-tree' @@ -22,7 +19,6 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr activityIsolationSnapshotRef, closeTerminalLinkActions, containerRef, - executeClosePane, isActive, isRendererVisible, isolatedPaneKey, @@ -31,15 +27,12 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr paneLayoutRevision, paneTransportsRef, pendingPaneSizeRefreshFrameIdsRef, - persistLayoutSnapshot, - ptyRecoveryStatesByPaneId, restoredLayout, tabId } = controller - // Leaves the last host-authoritative layout named, and the ones it has since - // dropped whose panes are still mounted; a removal needs the host to have - // named the leaf before it dropped it, and may have to wait for the PTY exit. - const hostLayoutLeafIdsRef = useRef>(new Set()) + // Leaves the last layout named, and the ones it has since dropped whose panes + // are still mounted; a removal needs the layout to have named the leaf first. + const layoutLeafIdsRef = useRef>(new Set()) const retiredLeafIdsRef = useRef>(new Set()) useEffect(() => { @@ -48,82 +41,31 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr useEffect(() => { const manager = managerRef.current - if (!manager || !restoredLayout.root) { + const root = restoredLayout.root + if (!manager || !root) { return } - if ( - !isHostAuthoritativeLayout({ - isWebClient: !!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__, - ptyIdsByLeafId: restoredLayout.ptyIdsByLeafId - }) - ) { - return - } - const layoutLeafIds = new Set(collectLeafIds(restoredLayout.root)) - const mountedLeafIds = manager.getPanes().map((pane) => pane.leafId) + const layoutLeafIds = new Set(collectLeafIds(root)) const retiredLeafIds = trackRetiredLeafIds({ retiredLeafIds: retiredLeafIdsRef.current, - previousLayoutLeafIds: hostLayoutLeafIdsRef.current, + previousLayoutLeafIds: layoutLeafIdsRef.current, layoutLeafIds, - mountedLeafIds + mountedLeafIds: manager.getPanes().map((pane) => pane.leafId) }) - hostLayoutLeafIdsRef.current = layoutLeafIds + layoutLeafIdsRef.current = layoutLeafIds retiredLeafIdsRef.current = retiredLeafIds - const insertions = planTerminalLiveLayoutInsertions(restoredLayout.root, mountedLeafIds) - const removals = planTerminalLiveLayoutRemovals( - restoredLayout.root, - mountedLeafIds, - retiredLeafIds + const panesChanged = reconcileMountedTerminalLayout( + manager, + { root, ptyIdsByLeafId: restoredLayout.ptyIdsByLeafId }, + retiredLeafIds, + (paneId) => { + const transport = paneTransportsRef.current.get(paneId) + return !transport || transport.isConnectPending?.() === true + } ) - if (insertions.length === 0 && removals.length === 0) { + if (!panesChanged) { return } - let appliedInsertion = false - for (const insertion of insertions) { - const ptyId = restoredLayout.ptyIdsByLeafId?.[insertion.newLeafId] - const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId) - if (!ptyId || sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId)) { - continue - } - const splitRatio = - insertion.ratio === undefined - ? undefined - : insertion.placement === 'before' - ? 1 - insertion.ratio - : insertion.ratio - const createdPane = manager.splitPaneAroundLeafIds( - insertion.sourceLeafIds, - sourcePaneId, - insertion.direction, - { - ...(splitRatio !== undefined && { ratio: splitRatio }), - leafId: insertion.newLeafId, - ptyId, - placement: insertion.placement - } - ) - if (createdPane) { - appliedInsertion = true - } - } - // Why: the host retired these leaves (its PTY for them ended), so their panes - // would otherwise outlive the layout as blank ghosts and take the tab's next - // close for themselves. selectRetiredPaneIds closes only a pane whose PTY has - // already cleared, so this never kills a still-live remote terminal; a leaf - // whose PTY is still ending is kept retired and removed on the re-run the - // transport's recovery-state change (ptyRecoveryStatesByPaneId) triggers. - // executeClosePane runs the same cleanup a user close does. - const retiredPaneIds = selectRetiredPaneIds(removals, { - paneCount: manager.getPanes().length, - paneIdForLeaf: (leafId) => manager.getNumericIdForLeaf(leafId), - ptyIdForPane: (paneId) => paneTransportsRef.current.get(paneId)?.getPtyId() - }) - for (const paneId of retiredPaneIds) { - executeClosePane(paneId) - } - if (appliedInsertion) { - persistLayoutSnapshot() - } const activePaneId = restoredLayout.activeLeafId ? manager.getNumericIdForLeaf(restoredLayout.activeLeafId) : null @@ -132,18 +74,8 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr if (nextActivePaneId !== null) { manager.setActivePane(nextActivePaneId, { focus: isActive }) } - // Why ptyRecoveryStatesByPaneId: a host-retired pane whose PTY has not yet - // finished ending is kept until this re-run, when its transport reports a new - // recovery state and its PTY has cleared. // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [ - executeClosePane, - isActive, - paneCount, - persistLayoutSnapshot, - ptyRecoveryStatesByPaneId, - restoredLayout - ]) + }, [isActive, paneCount, restoredLayout]) useLayoutEffect(() => { const snapshots = activityIsolationSnapshotRef.current diff --git a/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts index 8fe8e23de6fb..8139cf71e288 100644 --- a/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts +++ b/src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.ts @@ -42,6 +42,10 @@ function planGeometryWrites( if (!first || !second || extra || !divider || extraDivider) { return false } + // Why: a divider mid-drag is the user's; its drag end persists what wins. + if (divider.classList.contains('is-dragging')) { + return false + } // Why: a zoomed pane hides its siblings and owns their flex; collapse restores saved styles over any write. if (first.style.display === 'none' || second.style.display === 'none') { return false diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts index 345a0db60d46..42fe578beb79 100644 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts @@ -68,7 +68,7 @@ function createReconciliation() { throw new Error('execution host unavailable') }) - function plan(secondPtyId: string | null): number[] { + function plan(secondStarting = false): number[] { const root = state.terminalLayoutsByTabId[TAB_ID]?.root expect(root).toBeDefined() const layoutLeafIds = new Set(root ? collectLeafIds(root) : []) @@ -80,9 +80,8 @@ function createReconciliation() { }) previousLayoutLeafIds = layoutLeafIds return selectRetiredPaneIds(planTerminalLiveLayoutRemovals(root, mounted, retiredLeafIds), { - paneCount: mounted.size, paneIdForLeaf: (leaf) => (leaf === LEAF_ID ? 1 : 2), - ptyIdForPane: (pane) => (pane === 1 ? 'remote:first' : secondPtyId) + isPaneStarting: (pane) => pane === 2 && secondStarting }) } @@ -109,46 +108,46 @@ describe('host snapshot fences before split-pane retirement', () => { clearWebSessionTerminalOrphanRecoveryForTests() }) - it('keeps a null-transport pane when an older layout arrives', async () => { + it('keeps the pane when an older layout arrives', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - expect(view.plan('remote:second')).toEqual([]) + expect(view.plan()).toEqual([]) await view.receive(retiredSnapshotWithVersion(1)) expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) it('retains a missing pane when a newer layout cannot verify the host inventory', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - view.plan('remote:second') + view.plan() await view.receive(snapshot(3, [LEAF_ID])) expect(view.call).toHaveBeenCalled() expect(view.leaves()).toContain(SECOND_LEAF_ID) - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) - it('defers proven retirement until the transport clears and does not close twice', async () => { + it('defers proven retirement while the pane is starting and does not detach twice', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - view.plan('remote:second') + view.plan() await view.receive(retiredSnapshot()) expect(view.leaves()).toEqual([LEAF_ID]) - expect(view.plan('remote:second')).toEqual([]) - expect(view.plan(null)).toEqual([2]) + expect(view.plan(true)).toEqual([]) + expect(view.plan()).toEqual([2]) view.removeSecond() - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) it('clears deferred retirement when the host reintroduces the leaf before detach', async () => { const view = createReconciliation() await view.receive(snapshot(2)) - view.plan('remote:second') + view.plan() await view.receive(retiredSnapshot()) - expect(view.plan('remote:second')).toEqual([]) + expect(view.plan(true)).toEqual([]) await view.receive(snapshot(4)) expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) + expect(view.plan()).toEqual([]) }) }) From c0fe6429a5c41d4c7df8c0f3b9b478eceab3ca3d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 15:07:09 -0400 Subject: [PATCH 14/75] refactor(terminal): a layout gesture commits to main and holds until main publishes it A divider drag, divider reset, equalize or pane reorder now sends the tab's tree to main (`session:terminal-set-layout` -> commit-module `setLayout`). Main takes it only when it names exactly the tab's current panes, in every owner partition holding the tab, and refuses any other tree. The reply carries the publishSeq of the push holding the commit. Until the window has applied that push, a push with the same panes keeps the gesture's tree, so an older push no longer snaps a divider back between drag end and main's commit. A push whose tab has other panes wins at once. The layout save no longer merges live transport PTY ids into the layout's bindings; main's push owns them. --- .../ipc/session-terminal-set-layout.test.ts | 55 ++++++ src/main/ipc/session.ts | 14 ++ .../loading-store/pty-binding-persistence.ts | 35 ++-- .../terminal-layout-set.test.ts | 165 +++++++++++++++++ .../terminal-topology/terminal-layout-set.ts | 60 +++++++ .../terminal-topology-commit.ts | 61 +++++-- src/preload/api/session-bridge.ts | 1 + src/preload/api/workspace-session-api.ts | 8 + .../terminal-layout-gesture-persist.test.tsx | 69 +++++++- .../terminal-mounted-layout-reconcile.test.ts | 17 ++ .../use-terminal-pane-layout-persistence.ts | 32 ++-- src/renderer/src/store/slices/terminals.ts | 3 + .../src/store/terminals/terminal-actions.ts | 12 +- .../terminal-layout-gestures.test.ts | 166 ++++++++++++++++++ .../terminals/terminal-layout-gestures.ts | 88 ++++++++++ .../src/store/terminals/terminal-state.ts | 3 + .../terminals/terminal-topology-mirror.ts | 15 +- .../preload-api/web-workspace-session-api.ts | 2 + src/shared/terminal-layout-set.ts | 21 +++ 19 files changed, 779 insertions(+), 48 deletions(-) create mode 100644 src/main/ipc/session-terminal-set-layout.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-layout-set.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-layout-set.ts create mode 100644 src/renderer/src/store/terminals/terminal-layout-gestures.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-layout-gestures.ts create mode 100644 src/shared/terminal-layout-set.ts diff --git a/src/main/ipc/session-terminal-set-layout.test.ts b/src/main/ipc/session-terminal-set-layout.test.ts new file mode 100644 index 000000000000..a31a1efc3a52 --- /dev/null +++ b/src/main/ipc/session-terminal-set-layout.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, callback: (...args: unknown[]) => unknown) => + mocks.handlers.set(name, callback), + on: vi.fn() + } +})) +import { registerSessionHandlers } from './session' + +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const REQUEST = { worktreeId: 'repo::/wt', tabId: 'tab', root: { type: 'leaf', leafId: LEAF } } + +let committed = false +const setTerminalTabLayout = vi.fn(async () => { + await Promise.resolve() + committed = true + return { status: 'committed' as const } +}) +// Names the push only once the commit has landed. +const settleTerminalTopology = vi.fn(() => (committed ? 9 : 0)) + +function setLayout(args: unknown): unknown { + return mocks.handlers.get('session:terminal-set-layout')?.({}, args) +} + +beforeEach(() => { + mocks.handlers.clear() + setTerminalTabLayout.mockClear() + committed = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the store's layout commit. + const store = { setTerminalTabLayout } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the topology settle. + registerSessionHandlers(store, { settleTerminalTopology } as never) +}) + +describe('session:terminal-set-layout', () => { + it('replies with the publishSeq of the push holding the commit', async () => { + await expect(setLayout(REQUEST)).resolves.toEqual({ status: 'committed', publishSeq: 9 }) + expect(setTerminalTabLayout).toHaveBeenCalledWith(REQUEST) + expect(settleTerminalTopology).toHaveBeenCalledWith(REQUEST.worktreeId) + }) + + it('refuses a malformed request without touching the store', async () => { + await expect(setLayout({ ...REQUEST, root: { type: 'leaf' } })).resolves.toEqual({ + status: 'refused', + reason: 'invalid_request' + }) + expect(setTerminalTabLayout).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 1925de7ac8bd..90056e6d071c 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -2,6 +2,10 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import { + parseTerminalLayoutSetRequest, + type TerminalLayoutSetResult +} from '../../shared/terminal-layout-set' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { @@ -66,6 +70,16 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic } ) + // A gesture's geometry; the reply's publishSeq tells the window when main's push holds it. + ipcMain.handle('session:terminal-set-layout', async (_event, args: unknown) => { + const request = parseTerminalLayoutSetRequest(args) + if (!request) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLayoutSetResult + } + const result = await store.setTerminalTabLayout(request) + return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } + }) + // Pull-after-listen: a window subscribes to pushes first, then reads what it missed. ipcMain.handle('session:get-terminal-topology-slices', () => runtime.getTerminalTopologySlices()) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 359c55dbed90..271515c8a4a1 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -22,7 +22,11 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' -import { moveLeaf } from '../terminal-topology/terminal-topology-commit' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../../shared/terminal-layout-set' +import { moveLeaf, setLayout } from '../terminal-topology/terminal-topology-commit' import { findTerminalBindingConflict } from '../terminal-topology/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< @@ -212,15 +216,26 @@ export class PtyBindingPersistenceOperations { * the binding domain only for its runtime and partition access; the commit module owns the write. */ moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { - const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] - return runtime.runDurableMutation( - moveLeaf(request, { - state: runtime.state, - hostIds: () => sessions.getWorkspaceSessionHostIds(), - getSession: (hostId) => sessions.getWorkspaceSession(hostId), - markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) - }) - ) + const { runtime } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation(moveLeaf(request, topologyCommitContext(this))) + } + + /** A user's divider, direction or pane-order edit; here for the same reason as the move. */ + setTerminalTabLayout(request: TerminalLayoutSetRequest): Promise { + const { runtime } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation(setLayout(request, topologyCommitContext(this))) + } +} + +function topologyCommitContext( + owner: PtyBindingPersistenceOperations +): Parameters[1] { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] + return { + state: runtime.state, + hostIds: () => sessions.getWorkspaceSessionHostIds(), + getSession: (hostId) => sessions.getWorkspaceSession(hostId), + markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) } } diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.test.ts b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts new file mode 100644 index 000000000000..1eb56d99e059 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultUIState } from '../../../shared/constants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { setLayout } from './terminal-topology-commit' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' + +const WT = 'repo::/wt' +const TAB = 'tab-1' +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const C = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction: 'vertical', + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) + +function session(root: TerminalPaneLayoutNode): WorkspaceSessionState { + return { + activeRepoId: 'repo', + activeWorktreeId: WT, + activeTabId: TAB, + tabsByWorktree: { + [WT]: [ + { + id: TAB, + ptyId: 'pty-a', + worktreeId: WT, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { + root, + activeLeafId: A, + expandedLeafId: null, + ptyIdsByLeafId: { [A]: 'pty-a', [B]: 'pty-b' } + } + } + } +} + +function profile(partitions: [ExecutionHostId, WorkspaceSessionState][]) { + const state: Parameters[1]['state'] = { + workspaceSession: session(leaf(C)), + workspaceSessionsByHostId: {}, + ui: getDefaultUIState(), + sshRemotePtyLeases: [] + } + const dirty = new Set() + const getSession = (hostId: ExecutionHostId): WorkspaceSessionState => + (hostId === 'local' ? state.workspaceSession : state.workspaceSessionsByHostId?.[hostId]) ?? + session(leaf(C)) + const context: Parameters[1] = { + state, + hostIds: () => partitions.map(([hostId]) => hostId), + getSession, + markDirty: (domain) => dirty.add(domain) + } + for (const [hostId, value] of partitions) { + assignWorkspaceSessionPartition(state, hostId, value) + } + const rootOf = (hostId: ExecutionHostId) => getSession(hostId).terminalLayoutsByTabId?.[TAB]?.root + return { state, dirty, context, rootOf } +} + +describe('setLayout', () => { + it('commits a same-pane tree and keeps everything else in the layout', () => { + const { context, dirty, rootOf, state } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const dragged = split(leaf(A), leaf(B), 0.3) + + const result = setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, context)() + + expect(result.value).toEqual({ status: 'committed' }) + expect(result.persist).not.toBe(false) + expect(rootOf('local')).toEqual(dragged) + expect(state.workspaceSession.terminalLayoutsByTabId?.[TAB]?.ptyIdsByLeafId).toEqual({ + [A]: 'pty-a', + [B]: 'pty-b' + }) + expect([...dirty]).toEqual(['workspaceSession']) + }) + + it('takes a reordered tree, which keeps the same panes', () => { + const { context, rootOf } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const swapped = split(leaf(B), leaf(A)) + expect(setLayout({ worktreeId: WT, tabId: TAB, root: swapped }, context)().value).toEqual({ + status: 'committed' + }) + expect(rootOf('local')).toEqual(swapped) + }) + + it.each([ + ['an extra pane', split(split(leaf(A), leaf(B)), leaf(C))], + ['a missing pane', leaf(A)], + ['another pane in place of one', split(leaf(A), leaf(C))], + ['a pane twice', split(split(leaf(A), leaf(B)), leaf(B))] + ])('refuses a tree with %s and writes nothing', (_name, root) => { + const before = split(leaf(A), leaf(B)) + const { context, dirty, rootOf } = profile([['local', session(before)]]) + + const result = setLayout({ worktreeId: WT, tabId: TAB, root }, context)() + + expect(result.value).toEqual({ status: 'refused', reason: 'leaves_differ' }) + expect(result.persist).toBe(false) + expect(rootOf('local')).toEqual(before) + expect(dirty.size).toBe(0) + }) + + it('refuses a tab main does not hold', () => { + const { context } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const result = setLayout({ worktreeId: WT, tabId: 'other', root: leaf(A) }, context)() + expect(result.value).toEqual({ status: 'refused', reason: 'tab_not_held' }) + }) + + it('writes nothing for the tree main already holds', () => { + const root = split(leaf(A), leaf(B), 0.4) + const { context, dirty } = profile([['local', session(root)]]) + const result = setLayout({ worktreeId: WT, tabId: TAB, root: structuredClone(root) }, context)() + expect(result).toEqual({ value: { status: 'committed' }, persist: false }) + expect(dirty.size).toBe(0) + }) + + it('updates every owner partition holding the tab, never a runtime: mirror', () => { + const before = split(leaf(A), leaf(B)) + const { context, rootOf } = profile([ + ['local', session(before)], + ['ssh:target', session(before)], + ['runtime:env', session(before)] + ]) + const dragged = split(leaf(A), leaf(B), 0.7) + + setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, context)() + + expect(rootOf('local')).toEqual(dragged) + expect(rootOf('ssh:target')).toEqual(dragged) + expect(rootOf('runtime:env')).toEqual(before) + }) + + it('rolls back to the prior tree', () => { + const before = split(leaf(A), leaf(B)) + const { context, rootOf } = profile([['local', session(before)]]) + const result = setLayout( + { worktreeId: WT, tabId: TAB, root: split(leaf(A), leaf(B), 0.2) }, + context + )() + result.rollback?.() + expect(rootOf('local')).toEqual(before) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.ts b/src/main/persistence/terminal-topology/terminal-layout-set.ts new file mode 100644 index 000000000000..c70d349df5e5 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-set.ts @@ -0,0 +1,60 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../../shared/terminal-layout-set' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { + isTerminalOwnerPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' + +type PlannedTerminalLayoutSet = { + result: TerminalLayoutSetResult + /** Every partition the plan rewrote; empty when nothing changes. */ + sessions: TerminalSessionPartition[] +} + +const sortedLeafIds = (root: TerminalPaneLayoutNode | null | undefined): string[] => + collectLayoutLeafIdsInOrder(root).sort() + +/** + * Replaces a tab's tree with the window's when it holds exactly the same panes. A geometry edit + * never adds, removes or moves a pane between tabs; a tree that would is refused, not merged. + * Every owner partition holding the tab takes it, as a move does. + */ +export function planTerminalLayoutSet( + partitions: readonly TerminalSessionPartition[], + { worktreeId, tabId, root }: TerminalLayoutSetRequest +): PlannedTerminalLayoutSet { + const holders = partitions.flatMap((partition) => { + const layout = partition.session.terminalLayoutsByTabId?.[tabId] + const holdsTab = + isTerminalOwnerPartition(partition.hostId) && + partition.session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) + return holdsTab && layout?.root ? [{ ...partition, layout }] : [] + }) + if (holders.length === 0) { + return { result: { status: 'refused', reason: 'tab_not_held' }, sessions: [] } + } + const leafIds = sortedLeafIds(root) + if (holders.some(({ layout }) => !isDeepStrictEqual(sortedLeafIds(layout.root), leafIds))) { + return { result: { status: 'refused', reason: 'leaves_differ' }, sessions: [] } + } + return { + result: { status: 'committed' }, + sessions: holders + .filter(({ layout }) => !isDeepStrictEqual(layout.root, root)) + .map(({ hostId, session, layout }) => ({ + hostId, + session: { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [tabId]: { ...layout, root } + } + } + })) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dc1b562a51da..8503da9eb5ae 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -4,6 +4,10 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../../shared/terminal-layout-set' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { startSpan } from '../../observability/tracer' import { @@ -12,13 +16,17 @@ import { } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' -import { assignWorkspaceSessionPartition } from './terminal-topology-membership' +import { planTerminalLayoutSet } from './terminal-layout-set' +import { + assignWorkspaceSessionPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and // the pane move; the close transform still lives in runtime/ and other writers move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' +type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' | 'set_layout' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -43,6 +51,23 @@ export function moveLeaf( ) } +/** Replaces a tab's tree with the user's same-pane geometry edit; any other tree is refused. */ +export function setLayout( + request: TerminalLayoutSetRequest, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'set_layout', + () => { + const planned = planTerminalLayoutSet(partitionsOf(context), request) + return planned.sessions.length === 0 + ? { value: planned.result, persist: false } + : { value: planned.result, rollback: writePartitions(planned.sessions, context) } + }, + (result) => (result.status === 'refused' ? result.reason : undefined) + ) +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. @@ -102,25 +127,35 @@ function writeRestorable(read: () => V, write: (value: V) => void, next: V): } } +function partitionsOf(context: TerminalTopologyCommitContext): TerminalSessionPartition[] { + return context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })) +} + +/** Puts each planned partition in place; the returned rollback restores them. */ +function writePartitions( + sessions: readonly TerminalSessionPartition[], + context: TerminalTopologyCommitContext +): () => void { + const restores = sessions.map(({ hostId, session }) => + writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + session + ) + ) + return () => restores.forEach((restore) => restore()) +} + function commitLeafMove( request: TerminalLeafMoveRequest, context: TerminalTopologyCommitContext ): DurableProfileStateMutation { const { state } = context - const planned = planTerminalLeafMove( - context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), - request - ) + const planned = planTerminalLeafMove(partitionsOf(context), request) if (planned.sessions.length === 0) { return { value: planned.result, persist: false } } - const restores = planned.sessions.map(({ hostId, session }) => - writeRestorable( - () => context.getSession(hostId), - (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), - session - ) - ) + const restores = [writePartitions(planned.sessions, context)] const rekeyed = rekeyMovedLeafProfileRecords(state, request) if (rekeyed.ui) { restores.push( diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 8516ba992e88..3176040f7ca8 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -9,6 +9,7 @@ export const sessionApi = { set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), + setTerminalLayout: (args) => ipcRenderer.invoke('session:terminal-set-layout', args), getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), onTerminalTopologyChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 97e97d3f27f9..cc8cf97049e0 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -5,6 +5,10 @@ import type { } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import type { + TerminalLayoutSetRequest, + TerminalLayoutSetResult +} from '../../shared/terminal-layout-set' import type { TerminalTopologyReply, TerminalTopologySlice @@ -30,6 +34,10 @@ export type WorkspaceSessionApi = { target: TerminalSurfaceCloseTarget reason?: 'user' | 'cleanup' }) => Promise + /** Commits a user's same-pane geometry edit (dividers, directions, pane order) into main. */ + setTerminalLayout: ( + args: TerminalLayoutSetRequest + ) => Promise /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ getTerminalTopologySlices: () => Promise onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx index c7a2c58c7197..e2e34999de8e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx @@ -6,15 +6,17 @@ import { useTerminalPaneLayoutPersistence } from './use-terminal-pane-layout-per const LEFT = '11111111-1111-4111-8111-111111111111' const RIGHT = '22222222-2222-4222-8222-222222222222' -vi.mock('../../store', () => ({ - useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } -})) +const store = vi.hoisted(() => { + const terminalLayoutsByTabId: Record = {} + return { terminalLayoutsByTabId, commitTerminalLayoutGesture: vi.fn() } +}) +vi.mock('../../store', () => ({ useAppStore: { getState: () => store } })) vi.mock('@/runtime/web-runtime-session', () => ({ clearWebRuntimeTerminalBuffer: () => true })) vi.mock('@/lib/pane-manager/terminal-scrollback-clear', () => ({ clearTerminalScrollbackAndFollowOutput: vi.fn() })) -function renderPersistence() { +function renderPersistence(ptyIdForPane = (paneId: number) => `remote:host:${paneId}`) { const container = document.createElement('div') const split = document.createElement('div') split.className = 'pane-split' @@ -46,7 +48,7 @@ function renderPersistence() { paneTitles: { 1: 'build' }, paneTitlesRef: { current: { 1: 'build' } }, paneTransportsRef: { - current: new Map(panes.map((pane) => [pane.id, { getPtyId: () => `remote:host:${pane.id}` }])) + current: new Map(panes.map((pane) => [pane.id, { getPtyId: () => ptyIdForPane(pane.id) }])) }, remotePaneLayoutPusherRef: { current: { push } }, removedTitleLeafIdsRef: { current: new Set() }, @@ -67,7 +69,11 @@ function renderPersistence() { return { hook, push, setTabLayout, pushedIntents, panes } } -afterEach(cleanup) +afterEach(() => { + cleanup() + store.terminalLayoutsByTabId = {} + store.commitTerminalLayoutGesture.mockClear() +}) describe('terminal layout gesture persist', () => { it('saves the same layout for a gesture and an automatic persist and marks only the push', () => { @@ -107,4 +113,55 @@ describe('terminal layout gesture persist', () => { expect(pushedIntents()).toEqual(['gesture', 'gesture']) }) + + it('never commits a remote tab to main; its host takes the push', () => { + const { hook } = renderPersistence() + hook.result.current.persistLayoutSnapshot('gesture') + expect(store.commitTerminalLayoutGesture).not.toHaveBeenCalled() + }) +}) + +describe('local terminal layout gestures', () => { + const local = (paneId: number): string => `pty-live-${paneId}` + + it('commits a gesture that moved the tree to main, once', () => { + store.terminalLayoutsByTabId = { + tab: { root: { type: 'leaf', leafId: LEFT }, activeLeafId: LEFT, expandedLeafId: null } + } + const { hook, setTabLayout } = renderPersistence(local) + store.commitTerminalLayoutGesture.mockClear() + + hook.result.current.persistLayoutSnapshot('gesture') + + const saved = setTabLayout.mock.calls.at(-1)?.[1] + expect(store.commitTerminalLayoutGesture).toHaveBeenCalledExactlyOnceWith( + 'wt', + 'tab', + saved.root + ) + }) + + it('commits nothing for an automatic persist or a gesture that left the tree as it was', () => { + const { hook, setTabLayout } = renderPersistence(local) + store.terminalLayoutsByTabId = { tab: setTabLayout.mock.calls.at(-1)?.[1] } + + hook.result.current.persistLayoutSnapshot() + hook.result.current.removePaneTitle(1) + + expect(store.commitTerminalLayoutGesture).not.toHaveBeenCalled() + }) + + it("saves main's bindings, not the live transports' PTY ids", () => { + store.terminalLayoutsByTabId = { + tab: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-main' } + } + } + const { hook, setTabLayout } = renderPersistence(local) + hook.result.current.persistLayoutSnapshot() + expect(setTabLayout.mock.calls.at(-1)?.[1].ptyIdsByLeafId).toEqual({ [LEFT]: 'pty-main' }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts index 02c80898c0ed..bfeba1ce0542 100644 --- a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts @@ -166,6 +166,23 @@ describe('reconcileMountedTerminalLayout', () => { expect(view.onLayoutChanged).not.toHaveBeenCalled() }) + it('rebuilds nothing when main publishes the tree a local split proposed', () => { + const view = mount([A, B]) + view.manager.splitPane(view.manager.getNumericIdForLeaf(B)!, 'horizontal', { leafId: NEW }) + // Main keeps the split's proposedRoot verbatim (C1), so its push names the DOM's own tree. + const published = tree(view)! + const panes = view.manager.getPanes().map((pane) => [pane.id, pane.container]) + const observer = new MutationObserver(() => {}) + observer.observe(view.root, { attributes: true, childList: true, subtree: true }) + + const ptyIdsByLeafId = { [A]: 'pty-a', [B]: 'pty-b', [NEW]: 'pty-new' } + expect(reconcile(view, published, { ptyIdsByLeafId })).toBe(false) + + expect(observer.takeRecords()).toEqual([]) + observer.disconnect() + expect(view.manager.getPanes().map((pane) => [pane.id, pane.container])).toEqual(panes) + }) + it('leaves a divider the user is dragging alone', () => { const view = mount([A, B]) view.root.querySelector('.pane-divider')?.classList.add('is-dragging') diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index bbeedc2e524d..ff2b96cbf6ff 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -4,6 +4,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' +import { terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { clearWebRuntimeTerminalBuffer } from '@/runtime/web-runtime-session' @@ -82,27 +83,19 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if (Object.keys(mergedScrollbackRefs).length > 0) { layout.scrollbackRefsByLeafId = mergedScrollbackRefs } - const livePtyEntries = currentPanes - .map( - (pane) => - [pane.leafId, paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null] as const - ) - .filter( - (entry): entry is readonly [(typeof currentPanes)[number]['leafId'], string] => - entry[1] !== null - ) - const mergedPtyIds = mergeCapturedLeafState({ + // Bindings are main's (the mirror writes them); this save only drops closed leaves. + const ptyIdsByLeafId = mergeCapturedLeafState({ prior: existing?.ptyIdsByLeafId, - fresh: Object.fromEntries(livePtyEntries), + fresh: {}, currentLeafIds }) - if (Object.keys(mergedPtyIds).length > 0) { - layout.ptyIdsByLeafId = mergedPtyIds + if (Object.keys(ptyIdsByLeafId).length > 0) { + layout.ptyIdsByLeafId = ptyIdsByLeafId } layout.activeLeafId = resolveTerminalLayoutActiveLeafId({ root: layout.root, activeLeafId: layout.activeLeafId, - ptyIdsByLeafId: mergedPtyIds + ptyIdsByLeafId }) const titlesByLeafId: Record = {} const removedTitleLeafIds = removedTitleLeafIdsRef.current @@ -123,12 +116,17 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if (Object.keys(titlesByLeafId).length > 0) { layout.titlesByLeafId = titlesByLeafId } + const geometryEdit = + intent === 'gesture' && !terminalLayoutNodeEqual(existing?.root, layout.root) setTabLayout(tabId, layout) - const hasRemotePane = Object.values(mergedPtyIds).some( - (ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) - ) + const hasRemotePane = [ + ...Object.values(ptyIdsByLeafId), + ...currentPanes.map((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId()) + ].some((ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId)) if (hasRemotePane) { remotePaneLayoutPusherRef.current?.push({ worktreeId, tabId, layout, intent }) + } else if (geometryEdit && layout.root) { + useAppStore.getState().commitTerminalLayoutGesture(worktreeId, tabId, layout.root) } for (const leafId of currentLeafIds) { clearedScrollbackLeafIds.delete(leafId) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index ec118bb7bc88..759a124533b4 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -23,6 +23,7 @@ import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' import { createTerminalTopologyMirrorActions } from '../terminals/terminal-topology-mirror' +import { createTerminalLayoutGestureActions } from '../terminals/terminal-layout-gestures' import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ @@ -64,6 +65,7 @@ export const createTerminalSlice: StateCreator contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, terminalTopologySeqByWorktree: {}, + terminalLayoutGesturesByWorktree: {}, closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, pendingReconnectWorktreeIds: [], @@ -99,5 +101,6 @@ export const createTerminalSlice: StateCreator ...createTerminalStartupQueueActions(set, get), ...createWorkspaceTerminalHydrationActions(set, get), ...createTerminalTopologyMirrorActions(set), + ...createTerminalLayoutGestureActions(set), ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index ba07d1ac950c..4b89f6b26dff 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,7 +1,11 @@ import type { AgentAttentionUnreadReason } from '@/attention/agent-attention-contract' import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TerminalTab +} from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' @@ -288,6 +292,12 @@ export type TerminalActions = { removeDeferredSshSessionId: (tabId: string) => void /** Mirrors main's terminal topology for one worktree; a slice older than the last applied is ignored. */ applyTerminalTopologySlice: (slice: TerminalTopologySlice) => void + /** Sends a user's geometry edit to main and keeps it over older pushes until main publishes it. */ + commitTerminalLayoutGesture: ( + worktreeId: string, + tabId: string, + root: TerminalPaneLayoutNode + ) => void /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ hydrateWorkspaceSession: ( session: WorkspaceSessionState, diff --git a/src/renderer/src/store/terminals/terminal-layout-gestures.test.ts b/src/renderer/src/store/terminals/terminal-layout-gestures.test.ts new file mode 100644 index 000000000000..8196b11ac293 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-layout-gestures.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TerminalLayoutSetResult } from '../../../../shared/terminal-layout-set' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' + +const WT = 'repo::/wt' +const TAB = 'tab' +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const C = '33333333-3333-4333-8333-333333333333' + +const leaf = (leafId: string): TerminalPaneLayoutNode => ({ type: 'leaf', leafId }) +const split = ( + first: TerminalPaneLayoutNode, + second: TerminalPaneLayoutNode, + ratio?: number +): TerminalPaneLayoutNode => ({ + type: 'split', + direction: 'vertical', + first, + second, + ...(ratio !== undefined ? { ratio } : {}) +}) +const BEFORE = split(leaf(A), leaf(B)) +const DRAGGED = split(leaf(A), leaf(B), 0.3) + +type Reply = TerminalLayoutSetResult & { publishSeq?: number } + +const initial = useAppStore.getState() +const originalWindow = globalThis.window +let replies: { resolve: (reply: Reply) => void; reject: (error: Error) => void }[] = [] +const setTerminalLayout = vi.fn( + () => + new Promise((resolve, reject) => { + replies.push({ resolve, reject }) + }) +) + +function slice(publishSeq: number, root: TerminalPaneLayoutNode): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: [{ id: TAB, ptyId: 'pty-a', worktreeId: WT, createdAt: 1 }], + layouts: { [TAB]: { root, ptyIdsByLeafId: { [A]: 'pty-a', [B]: 'pty-b' } } }, + sleeping: {} + } +} + +const state = () => useAppStore.getState() +const rootInStore = () => state().terminalLayoutsByTabId[TAB]?.root +const apply = (publishSeq: number, root: TerminalPaneLayoutNode) => + state().applyTerminalTopologySlice(slice(publishSeq, root)) +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** What the drag end does: the window takes its own tree, then commits it to main. */ +function dragEnd(root: TerminalPaneLayoutNode): void { + const layout = state().terminalLayoutsByTabId[TAB] + state().setTabLayout(TAB, { ...layout!, root }) + state().commitTerminalLayoutGesture(WT, TAB, root) +} + +beforeEach(() => { + replies = [] + setTerminalLayout.mockClear() + vi.stubGlobal('window', { api: { session: { setTerminalLayout } } }) + useAppStore.setState({ + tabsByWorktree: { + [WT]: [ + { + id: TAB, + ptyId: 'pty-a', + worktreeId: WT, + title: 'T', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + apply(1, BEFORE) +}) + +afterEach(() => { + vi.unstubAllGlobals() + globalThis.window = originalWindow + useAppStore.setState(initial, true) +}) + +describe('a divider drag committed to main', () => { + it('sends the tree once, and a mirror apply never sends one', () => { + apply(2, split(leaf(A), leaf(B), 0.6)) + expect(setTerminalLayout).not.toHaveBeenCalled() + + dragEnd(DRAGGED) + + expect(setTerminalLayout).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WT, + tabId: TAB, + root: DRAGGED + }) + }) + + it('is not snapped back by an older push, before or after the reply', async () => { + dragEnd(DRAGGED) + const kept = state().terminalLayoutsByTabId[TAB] + + apply(2, BEFORE) + expect(rootInStore()).toEqual(DRAGGED) + expect(state().terminalLayoutsByTabId[TAB]).toBe(kept) + + replies[0]!.resolve({ status: 'committed', publishSeq: 4 }) + await flush() + apply(3, BEFORE) + expect(rootInStore()).toEqual(DRAGGED) + + apply(4, DRAGGED) + // Settled: a later host edit (another client's drag) applies. + apply(5, split(leaf(A), leaf(B), 0.8)) + expect(rootInStore()).toEqual(split(leaf(A), leaf(B), 0.8)) + }) + + it('settles on the reply when main published it first', async () => { + dragEnd(DRAGGED) + apply(2, DRAGGED) + replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) + await flush() + expect(state().terminalLayoutGesturesByWorktree[WT]).toEqual({}) + + apply(3, BEFORE) + expect(rootInStore()).toEqual(BEFORE) + }) + + it("yields at once to main's tree when main's tab has other panes", () => { + dragEnd(DRAGGED) + const closed = split(leaf(A), leaf(C)) + apply(2, closed) + expect(rootInStore()).toEqual(closed) + expect(state().terminalLayoutGesturesByWorktree[WT]).toEqual({}) + }) + + it('keeps the latest of two drags when the first reply lands', async () => { + dragEnd(DRAGGED) + const second = split(leaf(A), leaf(B), 0.2) + dragEnd(second) + replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) + await flush() + + apply(2, DRAGGED) + expect(rootInStore()).toEqual(second) + }) + + it('stops holding the tree when main could not be reached', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + dragEnd(DRAGGED) + replies[0]!.reject(new Error('gone')) + await flush() + + apply(2, BEFORE) + expect(rootInStore()).toEqual(BEFORE) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-layout-gestures.ts b/src/renderer/src/store/terminals/terminal-layout-gestures.ts new file mode 100644 index 000000000000..fe283788b1ae --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-layout-gestures.ts @@ -0,0 +1,88 @@ +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologyLayout, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' +import { collectLeafIds } from '@/components/terminal-pane/terminal-pane-layout-tree' +import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' + +/** A user's geometry edit main has not published yet; `publishSeq` arrives with main's reply. */ +export type TerminalLayoutGesture = { root: TerminalPaneLayoutNode; publishSeq?: number } + +const leafSet = (root: TerminalPaneLayoutNode | null): string => + collectLeafIds(root).sort().join('\n') + +/** + * Main's layouts with each pending gesture's root kept, so an older push can't snap a divider + * back. A gesture ends once the slice holds its publishSeq, or once main's tab has other panes + * (main refuses a gesture that would change them). + */ +export function holdTerminalLayoutGestures( + gestures: Record, + slice: TerminalTopologySlice +): { + layouts: Record + held: Record +} { + const layouts = { ...slice.layouts } + const held: Record = {} + for (const [tabId, gesture] of Object.entries(gestures)) { + const layout = slice.layouts[tabId] + if ( + layout && + !(gesture.publishSeq !== undefined && slice.publishSeq >= gesture.publishSeq) && + leafSet(layout.root) === leafSet(gesture.root) + ) { + layouts[tabId] = { ...layout, root: gesture.root } + held[tabId] = gesture + } + } + return { layouts, held } +} + +export function createTerminalLayoutGestureActions( + set: TerminalStoreSet +): Pick { + return { + commitTerminalLayoutGesture: (worktreeId, tabId, root) => { + // Why optional: an older preload can linger through an in-place renderer reload. + const send = globalThis.window?.api?.session?.setTerminalLayout + if (!send) { + return + } + set((s) => ({ + terminalLayoutGesturesByWorktree: { + ...s.terminalLayoutGesturesByWorktree, + [worktreeId]: { ...s.terminalLayoutGesturesByWorktree[worktreeId], [tabId]: { root } } + } + })) + const settle = (publishSeq: number | undefined): void => + set((s) => { + const gestures = s.terminalLayoutGesturesByWorktree[worktreeId] ?? {} + // A later gesture on the tab owns it now. + if (gestures[tabId]?.root !== root) { + return s + } + const unpublished = + publishSeq !== undefined && + (s.terminalTopologySeqByWorktree[worktreeId] ?? 0) < publishSeq + return { + terminalLayoutGesturesByWorktree: { + ...s.terminalLayoutGesturesByWorktree, + [worktreeId]: unpublished + ? { ...gestures, [tabId]: { root, publishSeq } } + : omitRecordKeys(gestures, [tabId]) + } + } + }) + void send({ worktreeId, tabId, root }).then( + (reply) => settle(reply.publishSeq), + (error: unknown) => { + console.warn('[terminal-layout] main did not commit the layout', error) + settle(undefined) + } + ) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index b2ee0542c5da..e75203a78a33 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -22,6 +22,7 @@ import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-c import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' +import type { TerminalLayoutGesture } from './terminal-layout-gestures' export type DirectSshLayoutEdit = { targetId: string @@ -115,6 +116,8 @@ export type TerminalState = { defaultTerminalTabsAppliedByWorktreeId: Record /** The publishSeq of main's last applied terminal topology slice per worktree. */ terminalTopologySeqByWorktree: Record + /** Per worktree and tab, the user's geometry edits main has not published yet. */ + terminalLayoutGesturesByWorktree: Record> closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean pendingReconnectWorktreeIds: string[] diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index f77e3d70285f..3bc78756e587 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -13,6 +13,7 @@ import type { AppState } from '../types' import { emptyLayoutSnapshot } from '../slices/terminal-helpers' import type { TerminalSlice, TerminalStoreSet } from './terminal-state' import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' +import { holdTerminalLayoutGestures } from './terminal-layout-gestures' const OPTIONAL_ROW_FIELDS = [ 'launchAgent', @@ -125,8 +126,12 @@ export function mirrorTerminalTopologySlice( const keptIds = new Set(tabs.map((tab) => tab.id)) const removedIds = currentTabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id) + const gestures = holdTerminalLayoutGestures( + state.terminalLayoutGesturesByWorktree[worktreeId] ?? {}, + slice + ) const layouts = { ...state.terminalLayoutsByTabId } - for (const [tabId, layout] of Object.entries(slice.layouts)) { + for (const [tabId, layout] of Object.entries(gestures.layouts)) { layouts[tabId] = mirrorLayout(layouts[tabId], layout) } for (const tab of added) { @@ -149,6 +154,14 @@ export function mirrorTerminalTopologySlice( ? { sleepingAgentSessionsByPaneKey: sleeping } : {}), ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), + ...(worktreeId in state.terminalLayoutGesturesByWorktree + ? { + terminalLayoutGesturesByWorktree: { + ...state.terminalLayoutGesturesByWorktree, + [worktreeId]: gestures.held + } + } + : {}), terminalTopologySeqByWorktree: { ...state.terminalTopologySeqByWorktree, [worktreeId]: slice.publishSeq diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index f8108c75d610..4b54ff19415f 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -83,6 +83,8 @@ export function createWebWorkspaceSessionApi(): Partial { }, // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. closeTerminalSurface: async () => ({}), + // Why refused: web layout edits reach the host through `session.tabs.updatePaneLayout`. + setTerminalLayout: async () => ({ status: 'refused', reason: 'tab_not_held' }), // Why empty: web clients follow the host's session tabs, not main's topology push. getTerminalTopologySlices: async () => [], onTerminalTopologyChanged: () => () => {}, diff --git a/src/shared/terminal-layout-set.ts b/src/shared/terminal-layout-set.ts new file mode 100644 index 000000000000..d44c698681af --- /dev/null +++ b/src/shared/terminal-layout-set.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { terminalPaneLayoutNodeSchema } from './workspace-session-schema' + +const TerminalLayoutSetRequestSchema = z.object({ + worktreeId: z.string().min(1).max(512), + tabId: z.string().min(1).max(512), + root: terminalPaneLayoutNodeSchema +}) + +/** A user's geometry edit to one tab: divider positions, split directions or pane order, same panes. */ +export type TerminalLayoutSetRequest = z.infer + +export type TerminalLayoutSetResult = + | { status: 'committed' } + | { status: 'refused'; reason: 'invalid_request' | 'tab_not_held' | 'leaves_differ' } + +/** Null for anything malformed. */ +export function parseTerminalLayoutSetRequest(value: unknown): TerminalLayoutSetRequest | null { + const parsed = TerminalLayoutSetRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} From cb6c2794dc422d9d949eea9a081431fb64104a81 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 15:17:29 -0400 Subject: [PATCH 15/75] refactor(terminal): pending panes; drag-out and closes follow main's topology Core step C5a (plan B2-6a). A tab or pane this window creates or closes is pending until main's topology catches up, so the mirror and the pane reconciler show it ahead of main without letting an older push undo it: - createTab marks the tab pending; a pane the window makes that main's layout doesn't name yet is pending. An add ends when a slice names it. - Every tab or pane close goes through commitTerminalSurfaceClose, which hides it until the mirror holds main's reply publishSeq; a refused close shows main's copy again. - The reconciler keeps pending panes and never re-inserts a closed one; this replaces the "spawn still pending" check and trackRetiredLeafIds. - Drag-out to a new tab waits for main's move and its push, then only places, activates and moves the live attachment. It no longer creates the tab or writes layouts itself, so a push that beats the reply no longer closes the new tab. A pane main doesn't hold keeps the local move. - replaceTerminalLayoutPanePtyId is deleted: leaf bindings come from main. --- .../codex-detached-pane-restart.ts | 3 +- .../pending-split-close-test-fixture.ts | 4 +- .../terminal-layout-leaf-detach.ts | 2 +- ...erminal-live-layout-reconciliation.test.ts | 130 +----- .../terminal-live-layout-reconciliation.ts | 88 ++-- .../terminal-mounted-layout-reconcile.test.ts | 28 +- .../terminal-pane-pane-created.ts | 6 + .../terminal-pane-tab-detach-commit.test.ts | 394 +++++++++--------- .../terminal-pane-tab-detach.test.ts | 8 + .../terminal-pane/terminal-pane-tab-detach.ts | 104 ++++- .../use-terminal-pane-close-actions.ts | 7 +- .../use-terminal-pane-reconciliation.ts | 33 +- ...al-provider-snapshot-bound-pty-ids.test.ts | 13 +- .../worktree-agent-activation-gate.test.ts | 24 +- .../worktree-agent-live-surface-adoption.ts | 2 - ...ssion-split-pane-retirement-fences.test.ts | 31 +- .../codex-restart-notice-lifecycle.test.ts | 31 -- src/renderer/src/store/slices/terminals.ts | 3 + .../src/store/terminals/terminal-actions.ts | 5 +- .../store/terminals/terminal-layout-state.ts | 18 - .../terminal-no-op-subscriber.test.ts | 2 - .../terminals/terminal-pending-panes.test.ts | 158 +++++++ .../store/terminals/terminal-pending-panes.ts | 132 ++++++ .../src/store/terminals/terminal-state.ts | 3 + .../terminal-surface-close-intent.ts | 28 +- .../src/store/terminals/terminal-tab-close.ts | 4 +- .../store/terminals/terminal-tab-creation.ts | 7 + .../terminals/terminal-topology-mirror.ts | 53 ++- 28 files changed, 761 insertions(+), 560 deletions(-) create mode 100644 src/renderer/src/store/terminals/terminal-pending-panes.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-pending-panes.ts diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 027e6ee156ab..b68cdd9a4a62 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -324,6 +324,7 @@ function restartReplacesLayoutRoot( ) } +// Main binds the replacement, so its push brings the leaf's new PTY id. function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: string): void { const store = useAppStore.getState() const layout = store.terminalLayoutsByTabId[tabId] @@ -332,9 +333,7 @@ function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: stri tabId, singlePaneLayoutSnapshot(leafId, newPtyId, layout?.titlesByLeafId?.[leafId] ?? null) ) - return } - store.replaceTerminalLayoutPanePtyId(tabId, leafId, newPtyId) } function reapUnboundCodexPty(ptyId: string, reason: string): void { diff --git a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts index 18bd95c0fea3..14b5e18a508b 100644 --- a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts +++ b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts @@ -108,7 +108,9 @@ export async function preparePendingSplitClose( setCacheTimerStartedAt: vi.fn(), dropAgentStatus: vi.fn(), retireAgentPaneAuthority: vi.fn(), - suppressPtyExit: vi.fn() + suppressPtyExit: vi.fn(), + markPendingTerminalPane: vi.fn(), + settlePendingTerminalPaneRemoval: vi.fn() }) as unknown as AppState const spawn = Promise.withResolvers() vi.mocked(window.api.pty.spawn).mockReturnValueOnce(spawn.promise) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts index ffa88d60783d..fdca140c95df 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -14,7 +14,7 @@ export type DetachedTerminalLayoutLeaf = { ptyId: string | null } -function removeLeafFromTree( +export function removeLeafFromTree( node: TerminalPaneLayoutNode, leafId: string ): { node: TerminalPaneLayoutNode | null; removed: boolean } { diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts index 8bcbbe5543cf..c9a32cc64b5f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import { planTerminalLiveLayoutInsertions, - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds + planTerminalLiveLayoutRemovals } from './terminal-live-layout-reconciliation' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' @@ -204,8 +202,7 @@ describe('planTerminalLiveLayoutInsertions', () => { }) describe('planTerminalLiveLayoutRemovals', () => { - // Every mounted leaf counted as retired: the layout alone must veto removals. - const BOTH = new Set(['leaf-a', 'leaf-b']) + const NONE = new Set() it('plans the mounted leaf a host-retired layout no longer names', () => { // Why: closing one pane of a remote-server split kills its PTY on the host, @@ -213,9 +210,7 @@ describe('planTerminalLiveLayoutRemovals', () => { // for the retired leaf must go too, or it lingers as a blank ghost. const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } - expect( - planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], new Set(['leaf-b'])) - ).toEqual(['leaf-b']) + expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], NONE)).toEqual(['leaf-b']) }) it('plans nothing when every mounted leaf is still in the layout', () => { @@ -226,126 +221,21 @@ describe('planTerminalLiveLayoutRemovals', () => { second: { type: 'leaf', leafId: 'leaf-b' } } - expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], BOTH)).toEqual([]) - expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a'], BOTH)).toEqual([]) + expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-b'], NONE)).toEqual([]) + expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a'], NONE)).toEqual([]) }) it('plans nothing for an empty layout', () => { - expect(planTerminalLiveLayoutRemovals(null, ['leaf-a'], BOTH)).toEqual([]) - expect(planTerminalLiveLayoutRemovals(undefined, ['leaf-a'], BOTH)).toEqual([]) - }) - - it('leaves a mounted leaf the host has never named alone', () => { - // Why: a pane the client just split is still spawning, so its transport has - // no PTY yet, and a host snapshot that lands mid-spawn does not name it. - // Only a leaf the host named before can be one the host retired. - const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } - - expect( - planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-new'], new Set(['leaf-a'])) - ).toEqual([]) - expect(planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-new'], new Set())).toEqual([]) - }) -}) - -describe('selectRetiredPaneIds', () => { - const view = (startingPaneIds: number[] = []) => ({ - paneIdForLeaf: (leafId: string) => (leafId === 'leaf-b' ? 2 : leafId === 'leaf-c' ? 3 : null), - isPaneStarting: (paneId: number) => startingPaneIds.includes(paneId) - }) - - it('detaches a retired pane whether or not it still holds its PTY', () => { - // Why: detaching never kills, so a live PTY main moved elsewhere needs no wait. - expect(selectRetiredPaneIds(['leaf-b', 'leaf-c'], view())).toEqual([2, 3]) - }) - - it('keeps a pane that is still starting its PTY', () => { - // Main has not bound it yet, so a layout without it says nothing about it. - expect(selectRetiredPaneIds(['leaf-b', 'leaf-c'], view([2]))).toEqual([3]) - }) - - it('skips a leaf that has no mounted pane', () => { - expect(selectRetiredPaneIds(['leaf-x'], view())).toEqual([]) + expect(planTerminalLiveLayoutRemovals(null, ['leaf-a'], NONE)).toEqual([]) + expect(planTerminalLiveLayoutRemovals(undefined, ['leaf-a'], NONE)).toEqual([]) }) -}) - -describe('trackRetiredLeafIds', () => { - it('retires a mounted leaf the host dropped from its layout', () => { - expect( - trackRetiredLeafIds({ - retiredLeafIds: new Set(), - previousLayoutLeafIds: new Set(['leaf-a', 'leaf-b']), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: ['leaf-a', 'leaf-b'] - }) - ).toEqual(new Set(['leaf-b'])) - }) - - it('keeps a retired leaf until its pane is gone', () => { - // Why: the removal may have been skipped while the transport still held its - // PTY; the next reconciliation must still see the leaf as retired. - const args = { - retiredLeafIds: new Set(['leaf-b']), - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a']) - } - expect(trackRetiredLeafIds({ ...args, mountedLeafIds: ['leaf-a', 'leaf-b'] })).toEqual( - new Set(['leaf-b']) - ) - expect(trackRetiredLeafIds({ ...args, mountedLeafIds: ['leaf-a'] })).toEqual(new Set()) - }) - - it('forgets a retired leaf the host names again', () => { - expect( - trackRetiredLeafIds({ - retiredLeafIds: new Set(['leaf-b']), - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a', 'leaf-b']), - mountedLeafIds: ['leaf-a', 'leaf-b'] - }) - ).toEqual(new Set()) - }) - - it('never retires a leaf the host has not named', () => { - expect( - trackRetiredLeafIds({ - retiredLeafIds: new Set(), - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: ['leaf-a', 'leaf-new'] - }) - ).toEqual(new Set()) - }) -}) -describe('a retirement that lands while the pane is still starting', () => { - it('detaches the pane on a later reconciliation once it has started', () => { + it('keeps a pending pane the layout does not name yet', () => { + // Why: a pane this window just split is not in main's layout until main binds it. const layout: TerminalPaneLayoutNode = { type: 'leaf', leafId: 'leaf-a' } - const mounted = ['leaf-a', 'leaf-b'] - const paneIdForLeaf = (leafId: string) => - leafId === 'leaf-a' ? 1 : leafId === 'leaf-b' ? 2 : null - let retired = trackRetiredLeafIds({ - retiredLeafIds: new Set(), - previousLayoutLeafIds: new Set(mounted), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: mounted - }) - let removals = planTerminalLiveLayoutRemovals(layout, mounted, retired) - expect(removals).toEqual(['leaf-b']) expect( - selectRetiredPaneIds(removals, { paneIdForLeaf, isPaneStarting: (paneId) => paneId === 2 }) + planTerminalLiveLayoutRemovals(layout, ['leaf-a', 'leaf-new'], new Set(['leaf-new'])) ).toEqual([]) - - retired = trackRetiredLeafIds({ - retiredLeafIds: retired, - previousLayoutLeafIds: new Set(['leaf-a']), - layoutLeafIds: new Set(['leaf-a']), - mountedLeafIds: mounted - }) - removals = planTerminalLiveLayoutRemovals(layout, mounted, retired) - expect(selectRetiredPaneIds(removals, { paneIdForLeaf, isPaneStarting: () => false })).toEqual([ - 2 - ]) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index 08ffe4b20f06..b2e817d606bc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -5,6 +5,7 @@ import type { } from '../../../../shared/terminal-tab-types' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { collectLeafIds } from './terminal-pane-layout-tree' +import { removeLeafFromTree } from './terminal-layout-leaf-detach' export type TerminalLiveLayoutInsertion = { sourceLeafId: string @@ -69,24 +70,21 @@ function mountedLeafIdsIn( } /** - * Mounted leaves the layout no longer names: main closed, moved or retired - * them, so a pane still mounted for one is a ghost. An empty layout plans - * nothing — absence of a tree is not evidence about any pane. + * Mounted leaves the layout no longer names: main closed, moved or retired them, so a pane still + * mounted for one is a ghost. A pane this window made that main has not named yet is pending, not + * gone. An empty layout plans nothing — absence of a tree is not evidence about any pane. */ export function planTerminalLiveLayoutRemovals( root: TerminalPaneLayoutNode | null | undefined, currentLeafIds: Iterable, - retiredLeafIds: ReadonlySet + pendingLeafIds: ReadonlySet ): string[] { if (!root) { return [] } const layoutLeafIds = new Set(collectLeafIds(root)) - // Why: a mounted leaf the layout stopped naming is a removal only once the - // layout is known to have retired it (trackRetiredLeafIds). A snapshot landing - // while the client is still starting a pane must not read as a retirement. return [...currentLeafIds].filter( - (leafId) => !layoutLeafIds.has(leafId) && retiredLeafIds.has(leafId) + (leafId) => !layoutLeafIds.has(leafId) && !pendingLeafIds.has(leafId) ) } @@ -162,26 +160,25 @@ export function planTerminalLiveLayoutInsertions( return insertions } -/** Panes to detach for leaves the layout retired. A pane still starting its PTY (no transport - * yet, or a spawn awaiting its id) is not in main's layout yet, so its absence retires nothing. */ -export function selectRetiredPaneIds( - retiredLeafIds: readonly string[], - view: { - paneIdForLeaf: (leafId: string) => number | null - isPaneStarting: (paneId: number) => boolean +/** This tab's panes the window added or closed ahead of main's layout. */ +export type TerminalPendingLeaves = { added: ReadonlySet; removed: ReadonlySet } + +function withoutLeaves( + root: TerminalPaneLayoutNode, + leafIds: ReadonlySet +): TerminalPaneLayoutNode | null { + let next: TerminalPaneLayoutNode | null = root + for (const leafId of leafIds) { + next = next && removeLeafFromTree(next, leafId).node } -): number[] { - return retiredLeafIds.flatMap((leafId) => { - const paneId = view.paneIdForLeaf(leafId) - return paneId === null || view.isPaneStarting(paneId) ? [] : [paneId] - }) + return next } /** - * Makes the mounted panes follow the store's layout: retired leaves detach (no kill; the PTY may + * Makes the mounted panes follow the store's layout: removed leaves detach (no kill; the PTY may * live on in another tab), then missing leaves mount and attach or spawn, then geometry applies - * in place. It writes no layout back: the store already holds this one. Returns whether panes - * were added or removed. + * in place. Pending leaves win over the layout until main has them. It writes no layout back: the + * store already holds this one. Returns whether panes were added or removed. */ export function reconcileMountedTerminalLayout( manager: Pick< @@ -193,18 +190,22 @@ export function reconcileMountedTerminalLayout( | 'applyLayoutGeometry' >, layout: Pick & { root: TerminalPaneLayoutNode }, - retiredLeafIds: ReadonlySet, - isPaneStarting: (paneId: number) => boolean + pending: TerminalPendingLeaves ): boolean { + const root = withoutLeaves(layout.root, pending.removed) + if (!root) { + return false + } const mountedLeafIds = (): string[] => manager.getPanes().map((pane) => pane.leafId) - const removals = planTerminalLiveLayoutRemovals(layout.root, mountedLeafIds(), retiredLeafIds) // Removals first, so insertions anchor on the panes that stay. - const detached = selectRetiredPaneIds(removals, { - paneIdForLeaf: (leafId) => manager.getNumericIdForLeaf(leafId), - isPaneStarting - }).filter((paneId) => manager.detachPaneForExternalMove(paneId)) + const detached = planTerminalLiveLayoutRemovals(root, mountedLeafIds(), pending.added).filter( + (leafId) => { + const paneId = manager.getNumericIdForLeaf(leafId) + return paneId !== null && manager.detachPaneForExternalMove(paneId) + } + ) let inserted = false - for (const insertion of planTerminalLiveLayoutInsertions(layout.root, mountedLeafIds())) { + for (const insertion of planTerminalLiveLayoutInsertions(root, mountedLeafIds())) { const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId) if (sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId) !== null) { continue @@ -228,29 +229,6 @@ export function reconcileMountedTerminalLayout( ) inserted ||= created !== null } - manager.applyLayoutGeometry(layout.root) + manager.applyLayoutGeometry(root) return detached.length > 0 || inserted } - -/** - * Leaves the layout dropped whose panes are still mounted. Only a leaf the - * layout named before can be retired: a leaf it has never named belongs to a - * pane the client is still starting. A retired leaf stays retired until its - * pane is gone or the layout names it again, so a removal skipped while the - * pane was still starting is planned again on a later reconciliation. - */ -export function trackRetiredLeafIds(args: { - retiredLeafIds: ReadonlySet - previousLayoutLeafIds: ReadonlySet - layoutLeafIds: ReadonlySet - mountedLeafIds: Iterable -}): ReadonlySet { - const mounted = new Set(args.mountedLeafIds) - const next = new Set() - for (const leafId of [...args.retiredLeafIds, ...args.previousLayoutLeafIds]) { - if (mounted.has(leafId) && !args.layoutLeafIds.has(leafId)) { - next.add(leafId) - } - } - return next -} diff --git a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts index 02c80898c0ed..9c3f8935fc4c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts @@ -73,16 +73,12 @@ function mount( function reconcile( view: Mounted, root: TerminalPaneLayoutNode, - options: { ptyIdsByLeafId?: Record; retired?: string[]; starting?: string[] } = {} + options: { ptyIdsByLeafId?: Record; added?: string[]; removed?: string[] } = {} ): boolean { - const startingPaneIds = new Set( - (options.starting ?? []).map((leafId) => view.manager.getNumericIdForLeaf(leafId)) - ) return reconcileMountedTerminalLayout( view.manager, { root, ptyIdsByLeafId: options.ptyIdsByLeafId ?? {} }, - new Set(options.retired ?? []), - (paneId) => startingPaneIds.has(paneId) + { added: new Set(options.added ?? []), removed: new Set(options.removed ?? []) } ) } @@ -133,26 +129,32 @@ describe('reconcileMountedTerminalLayout', () => { expect(view.onLayoutChanged).toHaveBeenCalledWith() }) - it('detaches a retired leaf instead of closing it', () => { + it('detaches a removed leaf instead of closing it', () => { const view = mount([A, B]) - expect(reconcile(view, leaf(A), { retired: [B] })).toBe(true) + expect(reconcile(view, leaf(A))).toBe(true) expect(view.onPaneClosed).toHaveBeenCalledTimes(1) expect(view.onPaneClosed.mock.calls[0]?.[1]).toMatchObject({ leafId: B, reason: 'detach' }) expect(tree(view)).toEqual(leaf(A)) }) - it('keeps a pane that is still starting, and one the layout never named', () => { + it('keeps a pending pane main has not named yet', () => { const view = mount([A, B]) - expect(reconcile(view, leaf(A), { retired: [B], starting: [B] })).toBe(false) - expect(reconcile(view, leaf(A))).toBe(false) + expect(reconcile(view, leaf(A), { added: [B] })).toBe(false) expect(view.onPaneClosed).not.toHaveBeenCalled() expect(view.manager.getPanes().map((pane) => pane.leafId)).toEqual([A, B]) }) + it('does not bring back a pane closed here that an older layout still names', () => { + const view = mount([A]) + expect(reconcile(view, split(leaf(A), leaf(B)), { removed: [B] })).toBe(false) + expect(view.onPaneCreated).not.toHaveBeenCalled() + expect(tree(view)).toEqual(leaf(A)) + }) + it('removes before inserting, so the new leaf anchors on a pane that stays', () => { const view = mount([A, B]) const layout = split(leaf(A), leaf(NEW)) - reconcile(view, layout, { retired: [B] }) + reconcile(view, layout) expect(tree(view)).toEqual(layout) }) @@ -219,7 +221,7 @@ describe('a leaf main moved to another tab', () => { }) const source = mount([A, B], wiring('source')) const destination = mount([A], wiring('destination')) - const moveOut = (): boolean => reconcile(source, leaf(A), { retired: [B] }) + const moveOut = (): boolean => reconcile(source, leaf(A)) const moveIn = (): boolean => reconcile(destination, split(leaf(A), leaf(B)), { ptyIdsByLeafId: { [B]: `pty-${B}` } }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index 4e0d7bd960cd..d2b7b0b8554a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -1,6 +1,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types' import { useAppStore } from '@/store' +import { markTerminalPaneIfAheadOfMain } from '@/store/terminals/terminal-pending-panes' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createOsc52OscHandler } from './osc52-clipboard' import { @@ -49,6 +50,11 @@ export function createTerminalPaneCreatedHandler( deps const { deferredSplitHandoffs } = context const paneKey = makePaneKey(deps.tabId, pane.leafId) + markTerminalPaneIfAheadOfMain(useAppStore.getState(), { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + leafId: pane.leafId + }) const restoredPtyId = ptyDeps.restoredPtyIdByLeafId?.[pane.leafId] const hasAuthoritativeSpawnHint = Boolean(spawnHints?.cwd || spawnHints?.ptyId || restoredPtyId) let effectiveSpawnHints = spawnHints diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts index 8b83383572d5..f7a33ca7a75f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts @@ -1,36 +1,34 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' import { detachTerminalPaneToTab } from './terminal-pane-tab-detach' import { - createStore, + createTerminalTab, LEAF_1, LEAF_2, SOURCE_TAB_ID, splitLayout, TARGET_GROUP_ID, - unboundSplitLayout, WORKTREE_ID } from './terminal-pane-tab-detach-fixture' const toastErrorMock = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) -const closeTerminalSurface = vi.fn(async () => {}) -beforeEach(() => { - toastErrorMock.mockClear() - closeTerminalSurface.mockClear() - vi.spyOn(console, 'warn').mockImplementation(() => {}) -}) -/** Answers each call with the next result; an Error throws. */ -function mainAnswering(results: (TerminalLeafMoveResult | Error)[]) { - return vi.fn((_request: TerminalLeafMoveRequest): Promise => { - const next = results.shift() ?? { status: 'not_held' } - return next instanceof Error ? Promise.reject(next) : Promise.resolve(next) - }) -} +const OTHER_GROUP_ID = 'group-other' +const PTY = 'pty-right' +const initial = useAppStore.getState() +const state = () => useAppStore.getState() +const closeTerminalSurface = vi.fn(async (): Promise => ({})) + +type MoveAnswer = TerminalLeafMoveResult & TerminalTopologyReply function managerWithPanes(paneIds: () => number[] = () => [1, 2]) { return { @@ -40,233 +38,217 @@ function managerWithPanes(paneIds: () => number[] = () => [1, 2]) { } } -type CommitMove = (request: TerminalLeafMoveRequest) => Promise +/** Main's slice after the move: the source keeps LEAF_1, the new tab holds LEAF_2. */ +function movedSlice(publishSeq: number, targetTabId: string): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WORKTREE_ID, + publishSeq, + revision: 2, + tabs: [ + { id: SOURCE_TAB_ID, ptyId: 'pty-left', worktreeId: WORKTREE_ID, createdAt: 1 }, + { id: targetTabId, ptyId: PTY, worktreeId: WORKTREE_ID, createdAt: 2 } + ], + layouts: { + [SOURCE_TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_1 }, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + [targetTabId]: { + root: { type: 'leaf', leafId: LEAF_2 }, + ptyIdsByLeafId: { [LEAF_2]: PTY } + } + }, + sleeping: {} + } +} + +function seed(): void { + const layout = splitLayout() + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [createTerminalTab(SOURCE_TAB_ID, 'pty-left')] }, + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: { ...layout, ptyIdsByLeafId: { [LEAF_1]: 'pty-left', [LEAF_2]: PTY } } + }, + ptyIdsByTabId: { [SOURCE_TAB_ID]: ['pty-left', PTY] }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: SOURCE_TAB_ID, + entityId: SOURCE_TAB_ID, + groupId: OTHER_GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal 2', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: OTHER_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: SOURCE_TAB_ID, + tabOrder: [SOURCE_TAB_ID] + }, + { id: TARGET_GROUP_ID, worktreeId: WORKTREE_ID, activeTabId: null, tabOrder: [] } + ] + }, + activeGroupIdByWorktree: { [WORKTREE_ID]: OTHER_GROUP_ID }, + activeWorktreeId: WORKTREE_ID, + terminalTopologySeqByWorktree: { [WORKTREE_ID]: 1 } + }) +} function detach( - overrides: Partial[0]> & { - commitMove?: CommitMove - store?: ReturnType - } + answer: (request: TerminalLeafMoveRequest) => Promise, + manager = managerWithPanes() ) { - const { commitMove = mainAnswering([]), store = createStore(), ...rest } = overrides - vi.stubGlobal('window', { - api: { pty: { moveLeafToNewTab: commitMove }, session: { closeTerminalSurface } } - }) - return detachTerminalPaneToTab({ - getStore: () => store, - manager: managerWithPanes(), + const moveLeafToNewTab = vi.fn(answer) + vi.stubGlobal('window', { api: { pty: { moveLeafToNewTab }, session: { closeTerminalSurface } } }) + const result = detachTerminalPaneToTab({ + getStore: state, + subscribe: useAppStore.subscribe, + manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 2, sourceTabId: SOURCE_TAB_ID, targetGroupId: TARGET_GROUP_ID, - worktreeId: WORKTREE_ID, - ...rest + worktreeId: WORKTREE_ID }) + return { result, moveLeafToNewTab } } -const moved: TerminalLeafMoveResult = { status: 'moved', ptyId: 'remote:env-1@@terminal-1' } - -describe('detachTerminalPaneToTab rolls a committed move forward', () => { - it('refuses, silently, a pane whose PTY spawn is still in flight', async () => { - const commitMove = mainAnswering([{ status: 'moved', ptyId: null }]) - const store = createStore(unboundSplitLayout()) - - await expect(detach({ commitMove, store, sourceConnectPending: true })).resolves.toBeNull() - - expect(commitMove).not.toHaveBeenCalled() - expect(store.createTab).not.toHaveBeenCalled() - expect(toastErrorMock).not.toHaveBeenCalled() - }) - - it('shows the failure toast and keeps the pane when main refuses or the commit throws', async () => { - for (const results of [ - [{ status: 'refused', reason: 'pty_mismatch' } as const], - [new Error('write failed')] - ]) { - toastErrorMock.mockClear() - const store = createStore() - const manager = managerWithPanes() - - await expect( - detach({ commitMove: mainAnswering(results), store, manager }) - ).resolves.toBeNull() - - expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() - expect(store.createTab).not.toHaveBeenCalled() - expect(toastErrorMock).toHaveBeenCalledOnce() - } - }) +const targetTabIdOf = (move: { mock: { calls: [TerminalLeafMoveRequest][] } }): string => + move.mock.calls[0]?.[0].targetTabId ?? '' + +function expectTargetTabShownOnce(targetTabId: string): void { + const terminalIds = state().tabsByWorktree[WORKTREE_ID].map((tab) => tab.id) + expect(terminalIds).toEqual([SOURCE_TAB_ID, targetTabId]) + const target = state().groupsByWorktree[WORKTREE_ID].find((g) => g.id === TARGET_GROUP_ID) + expect(target?.tabOrder).toEqual([targetTabId]) + expect(state().activeTabId).toBe(targetTabId) + expect(state().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual(['pty-left']) + expect(state().ptyIdsByTabId[targetTabId]).toEqual([PTY]) + expect(closeTerminalSurface).not.toHaveBeenCalled() +} - it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => { - let paneIds = [1, 2] - const manager = managerWithPanes(() => paneIds) - const commitMove = vi.fn( - async (_request: TerminalLeafMoveRequest): Promise => { - paneIds = [1] - manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null)) - return moved - } - ) - const store = createStore() +beforeEach(() => { + toastErrorMock.mockClear() + closeTerminalSurface.mockReset() + closeTerminalSurface.mockResolvedValue({}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seed() +}) - await expect(detach({ commitMove, manager, store })).resolves.toBeNull() +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + useAppStore.setState(initial, true) +}) - const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId - expect(closeTerminalSurface).toHaveBeenCalledWith({ - worktreeId: WORKTREE_ID, - target: { kind: 'tab', tabId: targetTabId }, - reason: 'cleanup' +describe('dragging a pane out to a new tab follows main’s move', () => { + it('shows the new tab once when main’s push arrives before the reply', async () => { + const { result, moveLeafToNewTab } = detach(async (request) => { + // The reconciler detaches the source pane as soon as this push lands. + state().applyTerminalTopologySlice(movedSlice(2, request.targetTabId)) + return { status: 'moved', ptyId: PTY, publishSeq: 2 } }) - expect(store.createTab).not.toHaveBeenCalled() - expect(toastErrorMock).not.toHaveBeenCalled() - }) - it('closes main’s new tab when the user closed the source tab meanwhile', async () => { - const store = createStore() - const commitMove = vi.fn( - async (_request: TerminalLeafMoveRequest): Promise => { - // A tab close drops its row and its layout. - store.tabsByWorktree[WORKTREE_ID] = [] - delete store.terminalLayoutsByTabId[SOURCE_TAB_ID] - return moved - } - ) - - await expect(detach({ commitMove, store })).resolves.toBeNull() - - expect(closeTerminalSurface).toHaveBeenCalledOnce() - expect(store.createTab).not.toHaveBeenCalled() + await expect(result).resolves.toMatchObject({ leafId: LEAF_2, ptyId: PTY }) + expect(moveLeafToNewTab).toHaveBeenCalledOnce() + expectTargetTabShownOnce(targetTabIdOf(moveLeafToNewTab)) }) - it('finds the pane by its leaf when its pane id changed meanwhile', async () => { - const manager = managerWithPanes(() => [1, 7]) - manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : LEAF_2)) - const commitMove = vi.fn( - async (_request: TerminalLeafMoveRequest): Promise => { - manager.getLeafId.mockImplementation((paneId) => - paneId === 1 ? LEAF_1 : paneId === 7 ? LEAF_2 : null - ) - return moved - } - ) + it('waits for main’s push when the reply arrives first', async () => { + const { result, moveLeafToNewTab } = detach(async () => ({ + status: 'moved', + ptyId: PTY, + publishSeq: 2 + })) + await vi.waitFor(() => expect(moveLeafToNewTab).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(state().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - await expect(detach({ commitMove, manager })).resolves.toMatchObject({ leafId: LEAF_2 }) + state().applyTerminalTopologySlice(movedSlice(2, targetTabIdOf(moveLeafToNewTab))) - expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7) + await expect(result).resolves.toMatchObject({ leafId: LEAF_2 }) + expectTargetTabShownOnce(targetTabIdOf(moveLeafToNewTab)) }) - it('closes main’s new tab when the pane manager refuses to detach the pane', async () => { - const manager = managerWithPanes() - manager.detachPaneForExternalMove.mockReturnValue(false) - const commitMove = mainAnswering([moved]) - const store = createStore() - - await expect(detach({ commitMove, manager, store })).resolves.toBeNull() + it('closes main’s new tab when the user closed the pane meanwhile', async () => { + closeTerminalSurface.mockResolvedValue({ publishSeq: 3 }) + const { result, moveLeafToNewTab } = detach(async () => { + state().markPendingTerminalPane({ + worktreeId: WORKTREE_ID, + tabId: SOURCE_TAB_ID, + leafId: LEAF_2, + change: 'remove' + }) + return { status: 'moved', ptyId: PTY, publishSeq: 2 } + }) + await expect(result).resolves.toBeNull() + const targetTabId = targetTabIdOf(moveLeafToNewTab) expect(closeTerminalSurface).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID, - target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId }, + target: { kind: 'tab', tabId: targetTabId }, reason: 'cleanup' }) - expect(store.createTab).not.toHaveBeenCalled() - expect(store.setTabLayout).not.toHaveBeenCalled() + // Main's push with the new tab can't show it meanwhile. + state().applyTerminalTopologySlice(movedSlice(2, targetTabId)) + expect(state().tabsByWorktree[WORKTREE_ID].map((tab) => tab.id)).toEqual([SOURCE_TAB_ID]) }) - it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => { - const store = createStore() + it('closes the source tab, keeping the PTY, when a sibling closed meanwhile', async () => { + const closeTab = vi.spyOn(state(), 'closeTab') let paneIds = [1, 2] - const manager = managerWithPanes(() => paneIds) - const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => { - // Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does. - paneIds = [2] - store.terminalLayoutsByTabId[SOURCE_TAB_ID] = { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_2]: 'pty-right' } - } - return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult - }) - - await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({ - leafId: LEAF_2, - ptyId: 'pty-right' - }) - - expect(closeTerminalSurface).not.toHaveBeenCalled() - expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() - expect(store.createTab).toHaveBeenCalledOnce() - expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith( - expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' }) + const { result } = detach( + async (request) => { + paneIds = [2] + state().applyTerminalTopologySlice(movedSlice(2, request.targetTabId)) + return { status: 'moved', ptyId: PTY, publishSeq: 2 } + }, + managerWithPanes(() => paneIds) ) - expect(store.closeTab).toHaveBeenCalledWith( + + await expect(result).resolves.toMatchObject({ leafId: LEAF_2 }) + expect(closeTab).toHaveBeenCalledWith( SOURCE_TAB_ID, expect.objectContaining({ localPtyTeardownOwnedExternally: true }) ) }) - it('commits the move in main before the target tab exists, using the same tab id', async () => { - const store = createStore() - let release!: () => void - const commitMove = vi.fn( - (_request: TerminalLeafMoveRequest) => - new Promise((resolve) => { - release = () => resolve(moved) - }) - ) - - const detaching = detach({ commitMove, store }) - await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce()) - expect(store.createTab).not.toHaveBeenCalled() - release() - await detaching - - const request = commitMove.mock.calls[0]?.[0] - expect(request).toEqual({ - worktreeId: WORKTREE_ID, - sourceTabId: SOURCE_TAB_ID, - targetTabId: expect.any(String), - leafId: LEAF_2, - ptyId: expect.any(String) - }) - expect(store.createTab).toHaveBeenCalledWith( - WORKTREE_ID, - TARGET_GROUP_ID, - 'powershell.exe', - expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 }) - ) + it('shows the failure toast and keeps the pane when main refuses or the commit throws', async () => { + for (const answer of [ + async (): Promise => ({ status: 'refused', reason: 'pty_mismatch' }), + async (): Promise => { + throw new Error('write failed') + } + ]) { + toastErrorMock.mockClear() + const manager = managerWithPanes() + await expect(detach(answer, manager).result).resolves.toBeNull() + expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled() + expect(state().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(toastErrorMock).toHaveBeenCalledOnce() + } }) it('ignores a repeat drop of a pane whose move is still committing', async () => { - const store = createStore() - let release!: () => void - const commitMove = vi.fn( - (_request: TerminalLeafMoveRequest) => - new Promise((resolve) => { - release = () => resolve(moved) - }) - ) - - const first = detach({ commitMove, store }) - await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce()) - await expect(detach({ commitMove, store })).resolves.toBeNull() - release() - - await expect(first).resolves.toMatchObject({ leafId: LEAF_2 }) - expect(commitMove).toHaveBeenCalledOnce() - expect(toastErrorMock).not.toHaveBeenCalled() - }) - - it('binds the moved tab’s layout to the live PTY, not the saved one', async () => { - const store = createStore(splitLayout()) - const saved = store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.ptyIdsByLeafId?.[LEAF_2] - expect(saved).toBeTruthy() - - const result = await detach({ livePtyId: 'pty-respawned', store }) - - expect(result?.ptyId).toBe('pty-respawned') - expect(store.terminalLayoutsByTabId['tab-detached']?.ptyIdsByLeafId?.[LEAF_2]).toBe( - 'pty-respawned' - ) + let release!: (answer: MoveAnswer) => void + const first = detach(() => new Promise((resolve) => (release = resolve))) + await vi.waitFor(() => expect(first.moveLeafToNewTab).toHaveBeenCalledOnce()) + const repeat = detach(async () => ({ status: 'moved', ptyId: PTY })) + + await expect(repeat.result).resolves.toBeNull() + expect(repeat.moveLeafToNewTab).not.toHaveBeenCalled() + state().applyTerminalTopologySlice(movedSlice(2, targetTabIdOf(first.moveLeafToNewTab))) + release({ status: 'moved', ptyId: PTY, publishSeq: 2 }) + await expect(first.result).resolves.toMatchObject({ leafId: LEAF_2 }) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 75ef69692e80..aac787546984 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -51,6 +51,7 @@ async function expectDeferredSplitDetachRejected(sourcePaneCwd: SourcePaneCwd): const result = await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot, sourcePaneCwd, @@ -181,6 +182,7 @@ describe('detachTerminalPaneToTab', () => { const store = createStore({ ...splitLayout(), chatLeafId }) await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager: { getPanes: () => [{ id: 1 }, { id: 2 }], getLeafId: () => LEAF_2, @@ -214,6 +216,7 @@ describe('detachTerminalPaneToTab', () => { const result = await detachTerminalPaneToTab({ manager, getStore: () => store, + subscribe: () => () => {}, persistLayoutSnapshot, sourcePaneCwd: { cwd: '/remote/repo', @@ -279,6 +282,7 @@ describe('detachTerminalPaneToTab', () => { await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 1, @@ -306,6 +310,7 @@ describe('detachTerminalPaneToTab', () => { const result = await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 1, @@ -349,6 +354,7 @@ describe('detachTerminalPaneToTab', () => { await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneId: 2, @@ -385,6 +391,7 @@ describe('detachTerminalPaneToTab', () => { await detachTerminalPaneToTab({ livePtyId: 'remote:env-2@@terminal-9', getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneCwd: { @@ -440,6 +447,7 @@ describe('detachTerminalPaneToTab', () => { const result = await detachTerminalPaneToTab({ getStore: () => store, + subscribe: () => () => {}, manager, persistLayoutSnapshot: vi.fn(), sourcePaneCwd: { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index b71d435af537..82ec538630aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -4,7 +4,9 @@ import { translate } from '@/i18n/i18n' import { createBrowserUuid } from '@/lib/browser-uuid' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalLeafMoveRequest } from '../../../../shared/terminal-leaf-move' +import { emptyLayoutSnapshot } from '@/store/slices/terminal-helpers' import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' +import { terminalTopologyApplied } from '@/store/terminals/terminal-topology-mirror' import type { PaneCwdEntry } from './resolve-split-cwd' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' export { @@ -18,13 +20,18 @@ export type TerminalPaneTabDetachStore = Pick< | 'closeTab' | 'createTab' | 'groupsByWorktree' + | 'markPendingTerminalPane' + | 'moveUnifiedTabToGroup' + | 'pendingTerminalPanes' | 'reorderUnifiedTabs' | 'setActiveTab' | 'setActiveTabType' | 'setTabLayout' | 'syncPaneDetachPtyOwnership' + | 'settlePendingTerminalPaneRemoval' | 'tabsByWorktree' | 'terminalLayoutsByTabId' + | 'terminalTopologySeqByWorktree' > type TerminalPaneTabDetachManager = { @@ -73,6 +80,7 @@ type DetachTerminalPaneToTabArgs = { /** The PTY the source pane's transport is attached to now; it outranks the saved layout. */ livePtyId?: string | null getStore: () => TerminalPaneTabDetachStore + subscribe: (listener: () => void) => () => void manager: TerminalPaneTabDetachManager | null persistLayoutSnapshot: () => void sourcePaneId: number @@ -89,8 +97,8 @@ type DetachTerminalPaneToTabArgs = { const leavesMovingToNewTab = new Set() /** - * Moves a pane into a new tab. Main commits the move (leaf, binding and pane-keyed records) before - * the target tab exists here, so the moved pane's reattach never races a second owner (STA-9259). + * Moves a pane into a new tab. Main commits the move (leaf, binding and pane-keyed records) and + * publishes the new tab, so the moved pane's reattach never races a second owner (STA-9259). * Once main has moved it, this window only rolls forward: it never asks main to put it back. */ export async function detachTerminalPaneToTab( @@ -144,10 +152,10 @@ async function moveLeafToNewTab( return null }) if (answer?.status === 'moved') { - return applyMove(args, request, true, answer.ptyId) + return followMainMove(args, request, answer.ptyId, answer.publishSeq) } if (answer?.status === 'not_held') { - return applyMove(args, request, false, request.ptyId) + return applyMove(args, request, request.ptyId) } // A failed write rolls main back; one whose outcome is unknown faults persistence, and the next // load converges on whichever side main kept. @@ -156,11 +164,82 @@ async function moveLeafToNewTab( return null } -/** Applies the move here; the pane is found by its leaf, since its pane id may have changed. */ +/** + * Main moved the leaf; its push brings the new tab and drops the leaf from the source, and the + * reconciler detaches the source pane. This window adds only presentation and the live + * attachment, after that push, whichever of the push and the reply arrived first. + */ +async function followMainMove( + args: DetachTerminalPaneToTabArgs, + request: TerminalLeafMoveRequest, + ptyId: string | null, + publishSeq: number | undefined +): Promise { + const { leafId, sourceTabId, targetTabId, worktreeId } = request + const closedMeanwhile = args + .getStore() + .pendingTerminalPanes.some( + (entry) => + entry.change === 'remove' && + entry.tabId === sourceTabId && + (entry.leafId === undefined || entry.leafId === leafId) + ) + if (closedMeanwhile) { + // The user closed the pane or its tab while main moved it, so the new tab holds nothing. + commitTerminalSurfaceClose( + args.getStore(), + worktreeId, + { kind: 'tab', tabId: targetTabId }, + 'cleanup' + ) + return null + } + await terminalTopologyApplied(args.subscribe, args.getStore, worktreeId, publishSeq) + const store = args.getStore() + const tab = store.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === targetTabId) + if (!tab) { + return null + } + store.moveUnifiedTabToGroup(targetTabId, args.targetGroupId) + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: args.getStore(), + tabId: targetTabId, + targetIndex: args.targetIndex, + worktreeId + }) + store.syncPaneDetachPtyOwnership({ + detachedLeafId: leafId, + detachedPtyId: ptyId, + sourceLayout: store.terminalLayoutsByTabId[sourceTabId] ?? emptyLayoutSnapshot(), + sourceTabId, + targetTabId + }) + const panes = args.manager?.getPanes() ?? [] + if (panes.length === 1 && args.manager?.getLeafId(panes[0].id) === leafId) { + // A sibling closed meanwhile; the pane manager keeps a last pane, so its tab goes instead. + closeSourceTabKeepingPty(store, sourceTabId) + } + store.setActiveTab(targetTabId) + store.setActiveTabType('terminal', worktreeId) + return { tab, leafId, ptyId } +} + +function closeSourceTabKeepingPty(store: TerminalPaneTabDetachStore, sourceTabId: string): void { + // The new tab reattaches the PTY, so the source tab's close must not kill it. + store.closeTab(sourceTabId, { + reason: 'cleanup', + recordInteraction: false, + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true + }) +} + +/** Moves a pane main doesn't hold (a runtime host's tab) here; the pane is found by its leaf, + * since its pane id may have changed. */ function applyMove( args: DetachTerminalPaneToTabArgs, request: TerminalLeafMoveRequest, - mainMoved: boolean, ptyId: string | null ): DetachedTerminalPaneTab | null { const { leafId, sourceTabId, targetTabId, worktreeId } = request @@ -180,11 +259,6 @@ function applyMove( !movedLayout || (!lastPane && !manager.detachPaneForExternalMove(paneId)) ) { - // Why: the pane or its tab is gone here (or would not detach), so the tab main moved it into - // holds nothing; close it the way any tab close reaches main. - if (mainMoved) { - commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId: targetTabId }, 'cleanup') - } return null } const detachedLayout = ptyId @@ -228,13 +302,7 @@ function applyMove( targetTabId: tab.id }) if (!split) { - // The new tab reattaches the PTY, so the source tab's close must not kill it. - afterCreateStore.closeTab(sourceTabId, { - reason: 'cleanup', - recordInteraction: false, - captureRecentlyClosed: false, - localPtyTeardownOwnedExternally: true - }) + closeSourceTabKeepingPty(afterCreateStore, sourceTabId) } afterCreateStore.setActiveTab(tab.id) afterCreateStore.setActiveTabType('terminal', worktreeId) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index efad698fca4f..8de030879aa1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -59,7 +59,11 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr // The user closed a launch pane whose agent is still starting; main stops that launch. noteAgentLaunchPaneClosedByUser(tabId, leafId) } - commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId }) + commitTerminalSurfaceClose(useAppStore.getState(), worktreeId, { + kind: 'pane', + tabId, + leafId + }) retireUnboundIpcTerminalPane({ getState: useAppStore.getState, tabId, @@ -245,6 +249,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr void detachTerminalPaneToTab({ livePtyId, getStore: useAppStore.getState, + subscribe: useAppStore.subscribe, manager: managerRef.current, persistLayoutSnapshot, sourcePaneId, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts index e05eb868a902..41d73b11508e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useEffect, useLayoutEffect } from 'react' import { applyExpandedLayoutTo, cancelPendingPaneSizeRefreshFrames, @@ -6,11 +6,9 @@ import { } from './expand-collapse' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' -import { - reconcileMountedTerminalLayout, - trackRetiredLeafIds -} from './terminal-live-layout-reconciliation' -import { collectLeafIds } from './terminal-pane-layout-tree' +import { useAppStore } from '@/store' +import { pendingTerminalLeafIds } from '@/store/terminals/terminal-pending-panes' +import { reconcileMountedTerminalLayout } from './terminal-live-layout-reconciliation' import { useTerminalPaneProcessExitActions } from './use-terminal-pane-process-exit-actions' import type { TerminalPaneCloseController } from './use-terminal-pane-close-actions' @@ -25,16 +23,10 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr managerRef, paneCount, paneLayoutRevision, - paneTransportsRef, pendingPaneSizeRefreshFrameIdsRef, restoredLayout, tabId } = controller - // Leaves the last layout named, and the ones it has since dropped whose panes - // are still mounted; a removal needs the layout to have named the leaf first. - const layoutLeafIdsRef = useRef>(new Set()) - const retiredLeafIdsRef = useRef>(new Set()) - useEffect(() => { closeTerminalLinkActions() }, [closeTerminalLinkActions, isActive, isRendererVisible, paneLayoutRevision]) @@ -45,22 +37,13 @@ export function useTerminalPaneReconciliation(controller: TerminalPaneCloseContr if (!manager || !root) { return } - const layoutLeafIds = new Set(collectLeafIds(root)) - const retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds: retiredLeafIdsRef.current, - previousLayoutLeafIds: layoutLeafIdsRef.current, - layoutLeafIds, - mountedLeafIds: manager.getPanes().map((pane) => pane.leafId) - }) - layoutLeafIdsRef.current = layoutLeafIds - retiredLeafIdsRef.current = retiredLeafIds + const { pendingTerminalPanes } = useAppStore.getState() const panesChanged = reconcileMountedTerminalLayout( manager, { root, ptyIdsByLeafId: restoredLayout.ptyIdsByLeafId }, - retiredLeafIds, - (paneId) => { - const transport = paneTransportsRef.current.get(paneId) - return !transport || transport.isConnectPending?.() === true + { + added: pendingTerminalLeafIds(pendingTerminalPanes, tabId, 'add'), + removed: pendingTerminalLeafIds(pendingTerminalPanes, tabId, 'remove') } ) if (!panesChanged) { diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts index ad6f7de8714b..71547fe2dd29 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts @@ -110,7 +110,18 @@ describe('createTerminalProviderSnapshotBoundPtyIdsSelector', () => { } expectRecollect('pty bound to a leaf', () => { - useAppStore.getState().replaceTerminalLayoutPanePtyId(tabId(0), 'leaf-b-0', 'pty-0-b-next') + useAppStore.setState((state) => { + const layout = state.terminalLayoutsByTabId[tabId(0)] + return { + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [tabId(0)]: { + ...layout, + ptyIdsByLeafId: { ...layout?.ptyIdsByLeafId, 'leaf-b-0': 'pty-0-b-next' } + } + } + } + }) }) expect(boundPtyIds).toContain('pty-0-b-next') diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index ee54c4e32cb6..6c1e58487cd7 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -114,12 +114,6 @@ function testDeps(args: { } terminalLayoutsByTabId[tabId] = { ...layout, ptyIdsByLeafId: { ...layout.ptyIdsByLeafId } } }) - const replaceTerminalLayoutPanePtyId = vi.fn((tabId: string, leafId: string, ptyId: string) => { - const layout = terminalLayoutsByTabId[tabId] - if (layout) { - layout.ptyIdsByLeafId[leafId] = ptyId - } - }) let createdCount = 0 const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => { createdCount += 1 @@ -154,7 +148,6 @@ function testDeps(args: { sleeping.map((record) => [record.paneKey, record]) ), updateTabPtyId, - replaceTerminalLayoutPanePtyId, terminalLayoutsByTabId, unifiedTabsByWorktree: { [WORKTREE_ID]: args.structured @@ -337,9 +330,7 @@ describe('worktree agent activation gate', () => { // The host's graph omits unmounted panes; minting here forked the agent onto a second tab. expect(createTab).not.toHaveBeenCalled() - expect(deps.getState().terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: ptyId - }) + // The leaf's binding is main's; this window records only the live attachment. expect(deps.getState().ptyIdsByTabId['tab-live']).toEqual([ptyId]) expect(resume).not.toHaveBeenCalled() }) @@ -613,11 +604,6 @@ describe('worktree agent activation gate', () => { expect(createTab).not.toHaveBeenCalled() expect(store.updateTabPtyId).toHaveBeenCalledWith('tab-live', livePtyId) - expect(store.replaceTerminalLayoutPanePtyId).toHaveBeenCalledWith( - 'tab-live', - LIVE_LEAF_ID, - livePtyId - ) }) it('materializes the host tab id for a surface this renderer never mounted', async () => { @@ -736,9 +722,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() expect(resume).not.toHaveBeenCalled() - expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: livePtyId - }) + expect(store.ptyIdsByTabId['tab-live']).toEqual([livePtyId]) } ) @@ -759,9 +743,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() - expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: livePtyId - }) + expect(store.ptyIdsByTabId['tab-live']).toEqual([livePtyId]) }) it('re-reads local ownership after the census before minting', async () => { diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 33df753468dc..3952ca68a0ce 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -15,7 +15,6 @@ export type LiveSurfaceAdoptionStore = Pick< | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'updateTabPtyId' - | 'replaceTerminalLayoutPanePtyId' > function layoutContainsLeaf( @@ -58,7 +57,6 @@ export function bindLivePtyToExactSurface( return false } store.updateTabPtyId(terminal.tabId, terminal.ptyId) - store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId) return true } const created = store.createTab(worktreeId, undefined, undefined, { diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts index 42fe578beb79..618200c4f8fa 100644 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { collectLeafIds } from '../components/terminal-pane/terminal-pane-layout-tree' -import { - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds -} from '../components/terminal-pane/terminal-live-layout-reconciliation' +import { planTerminalLiveLayoutRemovals } from '../components/terminal-pane/terminal-live-layout-reconciliation' import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync' import { clearWebSessionTerminalOrphanRecoveryForTests, @@ -61,28 +57,19 @@ function retiredSnapshot(): RuntimeMobileSessionTabsResult { function createReconciliation() { let state = makeState() - let previousLayoutLeafIds: ReadonlySet = new Set() - let retiredLeafIds: ReadonlySet = new Set() const mounted = new Set(mountedLeaves) const call = vi.fn(async () => { throw new Error('execution host unavailable') }) - function plan(secondStarting = false): number[] { + function plan(secondPending = false): string[] { const root = state.terminalLayoutsByTabId[TAB_ID]?.root expect(root).toBeDefined() - const layoutLeafIds = new Set(root ? collectLeafIds(root) : []) - retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds, - previousLayoutLeafIds, - layoutLeafIds, - mountedLeafIds: mounted - }) - previousLayoutLeafIds = layoutLeafIds - return selectRetiredPaneIds(planTerminalLiveLayoutRemovals(root, mounted, retiredLeafIds), { - paneIdForLeaf: (leaf) => (leaf === LEAF_ID ? 1 : 2), - isPaneStarting: (pane) => pane === 2 && secondStarting - }) + return planTerminalLiveLayoutRemovals( + root, + mounted, + new Set(secondPending ? [SECOND_LEAF_ID] : []) + ) } return { @@ -127,14 +114,14 @@ describe('host snapshot fences before split-pane retirement', () => { expect(view.plan()).toEqual([]) }) - it('defers proven retirement while the pane is starting and does not detach twice', async () => { + it('defers proven retirement while the pane is pending and does not detach twice', async () => { const view = createReconciliation() await view.receive(snapshot(2)) view.plan() await view.receive(retiredSnapshot()) expect(view.leaves()).toEqual([LEAF_ID]) expect(view.plan(true)).toEqual([]) - expect(view.plan()).toEqual([2]) + expect(view.plan()).toEqual([SECOND_LEAF_ID]) view.removeSecond() expect(view.plan()).toEqual([]) }) diff --git a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts index 7f9042db583f..a302c27863bc 100644 --- a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts +++ b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts @@ -230,34 +230,3 @@ describe('codex restart notices with colliding account labels', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(dismissed) }) }) - -describe('replaceTerminalLayoutPanePtyId', () => { - const LEAF = '11111111-1111-4111-8111-111111111111' - - it('rebinds one leaf and leaves split siblings alone', () => { - const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' - useAppStore.setState({ - terminalLayoutsByTabId: { - 'tab-1': { - root: null, - activeLeafId: LEAF, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF]: 'pty-old', [OTHER_LEAF]: 'pty-sibling' } - } - } - }) - - useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-1', LEAF, 'pty-new') - - expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ - [LEAF]: 'pty-new', - [OTHER_LEAF]: 'pty-sibling' - }) - }) - - it('does nothing for a tab with no layout', () => { - const before = useAppStore.getState().terminalLayoutsByTabId - useAppStore.getState().replaceTerminalLayoutPanePtyId('tab-none', LEAF, 'pty-new') - expect(useAppStore.getState().terminalLayoutsByTabId).toBe(before) - }) -}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index ec118bb7bc88..374b54d245e2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -23,6 +23,7 @@ import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' import { createTerminalTopologyMirrorActions } from '../terminals/terminal-topology-mirror' +import { createTerminalPendingPaneActions } from '../terminals/terminal-pending-panes' import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ @@ -64,6 +65,7 @@ export const createTerminalSlice: StateCreator contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, terminalTopologySeqByWorktree: {}, + pendingTerminalPanes: [], closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, pendingReconnectWorktreeIds: [], @@ -99,5 +101,6 @@ export const createTerminalSlice: StateCreator ...createTerminalStartupQueueActions(set, get), ...createWorkspaceTerminalHydrationActions(set, get), ...createTerminalTopologyMirrorActions(set), + ...createTerminalPendingPaneActions(set), ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index ba07d1ac950c..14c52c6eab6e 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -4,6 +4,7 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' +import type { PendingTerminalPane, PendingTerminalPaneKey } from './terminal-pending-panes' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -194,7 +195,6 @@ export type TerminalActions = { clearCodexRestartNotice: (ptyId: string) => void dismissCodexRestartNotices: (ptyIds: string[]) => void reopenCodexRestartPrompt: (ptyId: string) => void - replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void @@ -288,6 +288,9 @@ export type TerminalActions = { removeDeferredSshSessionId: (tabId: string) => void /** Mirrors main's terminal topology for one worktree; a slice older than the last applied is ignored. */ applyTerminalTopologySlice: (slice: TerminalTopologySlice) => void + markPendingTerminalPane: (entry: PendingTerminalPane) => void + /** Main answered a close; the pending remove ends once the mirror holds `publishSeq`. */ + settlePendingTerminalPaneRemoval: (pane: PendingTerminalPaneKey, publishSeq?: number) => void /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ hydrateWorkspaceSession: ( session: WorkspaceSessionState, diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 455e73eb0b5f..4e2246b7d0a5 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -21,7 +21,6 @@ export function createTerminalLayoutActions( get: TerminalStoreGet ): Pick< TerminalSlice, - | 'replaceTerminalLayoutPanePtyId' | 'setTabPaneExpanded' | 'setTabCanExpandPane' | 'setTabLayout' @@ -46,23 +45,6 @@ export function createTerminalLayoutActions( return { localOnlyScrollbackByTabId: { ...current, [tabId]: buffersByLeafId } } }) }, - replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { - set((s) => { - const layout = s.terminalLayoutsByTabId[tabId] - if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return s - } - return { - terminalLayoutsByTabId: { - ...s.terminalLayoutsByTabId, - [tabId]: { - ...layout, - ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } - } - } - } - }) - }, // Why: pane mount/unmount re-asserts the same booleans; bailing like setTabLayout keeps map subscribers asleep. setTabPaneExpanded: (tabId, expanded) => { set((s) => diff --git a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts index 4d68e867770a..9ad260565651 100644 --- a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts +++ b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts @@ -16,7 +16,6 @@ describe('terminal no-op subscriber budget', () => { for (let i = 0; i < 25; i += 1) { const s = store.getState() - s.replaceTerminalLayoutPanePtyId('missing', 'leaf', 'pty') expect(s.consumeSuppressedPtyExit('missing')).toBe(false) expect(s.consumePendingCodexPaneRestart('missing')).toBe(false) s.clearCodexRestartNotice('missing') @@ -50,7 +49,6 @@ describe('terminal no-op subscriber budget', () => { () => store.getState().markNativeChatLaunchPromptFailed('tab'), () => store.getState().markNativeChatLaunchDraftAdopted('tab'), () => store.getState().resolveNativeChatLaunchDraft('tab', draft), - () => store.getState().replaceTerminalLayoutPanePtyId('tab', 'leaf', 'pty'), () => store.getState().clearNativeChatLaunchPrompt('tab'), () => store.getState().clearNativeChatLaunchDraft('tab') ] diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts new file mode 100644 index 000000000000..ebd8dd18cc29 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' +import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' +import { pendingTerminalLeafIds } from './terminal-pending-panes' + +const WT = 'repo::/wt' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const initial = useAppStore.getState() +const state = () => useAppStore.getState() +const apply = (next: TerminalTopologySlice) => state().applyTerminalTopologySlice(next) +const tabIds = () => (state().tabsByWorktree[WT] ?? []).map((tab) => tab.id) +const unifiedIds = () => (state().unifiedTabsByWorktree[WT] ?? []).map((tab) => tab.entityId) + +function slice(publishSeq: number, tabs: Record): TerminalTopologySlice { + const leaf = (leafId: string) => ({ type: 'leaf' as const, leafId }) + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: Object.keys(tabs).map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + layouts: Object.fromEntries( + Object.entries(tabs).map(([id, [first, second]]) => [ + id, + { + root: second + ? { + type: 'split' as const, + direction: 'vertical' as const, + first: leaf(first), + second: leaf(second) + } + : leaf(first) + } + ]) + ), + sleeping: {} + } +} + +/** Main's close replies are released by the test, so either order can be driven. */ +function stubCloseReplies(): { + reply: (answer: TerminalTopologyReply | Error) => void + closeTerminalSurface: ReturnType +} { + const pending: ((answer: TerminalTopologyReply | Error) => void)[] = [] + const closeTerminalSurface = vi.fn( + () => + new Promise((resolve, reject) => { + pending.push((answer) => (answer instanceof Error ? reject(answer) : resolve(answer))) + }) + ) + vi.stubGlobal('window', { api: { session: { closeTerminalSurface } } }) + return { reply: (answer) => pending.shift()?.(answer), closeTerminalSurface } +} + +const settled = () => new Promise((resolve) => setTimeout(resolve, 0)) + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + apply(slice(1, { a: [LEAF_A] })) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + useAppStore.setState(initial, true) +}) + +describe('a tab this window creates', () => { + it('survives a push that does not yet contain it, and settles once main names it', () => { + const tab = state().createTab(WT) + + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(tabIds()).toEqual(['a', tab.id]) + expect(state().pendingTerminalPanes).toHaveLength(1) + + apply(slice(3, { a: [LEAF_A, LEAF_B], [tab.id]: [LEAF_B] })) + expect(tabIds()).toEqual(['a', tab.id]) + expect(state().pendingTerminalPanes).toEqual([]) + + // Main owns it now, so main dropping it removes it here. + apply(slice(4, { a: [LEAF_A] })) + expect(tabIds()).toEqual(['a']) + }) + + it('keeps a pane main has not named yet, and only that one', () => { + state().markPendingTerminalPane({ worktreeId: WT, tabId: 'a', leafId: LEAF_B, change: 'add' }) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'add')).toEqual( + new Set([LEAF_B]) + ) + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(state().pendingTerminalPanes).toEqual([]) + }) +}) + +describe('a tab this window closes', () => { + it.each([ + ['the reply', true], + ['the push', false] + ])('is not brought back by an older push when %s comes first', async (_, replyFirst) => { + apply(slice(10, { a: [LEAF_A], b: [LEAF_B] })) + const main = stubCloseReplies() + state().closeTab('b') + expect(main.closeTerminalSurface).toHaveBeenCalledOnce() + + // A push main sent before it closed the tab still names it. + apply(slice(11, { a: [LEAF_A], b: [LEAF_B] })) + expect(tabIds()).toEqual(['a']) + expect(unifiedIds()).toEqual(['a']) + + if (replyFirst) { + main.reply({ publishSeq: 12 }) + await settled() + apply(slice(12, { a: [LEAF_A] })) + } else { + apply(slice(12, { a: [LEAF_A] })) + main.reply({ publishSeq: 12 }) + await settled() + } + expect(tabIds()).toEqual(['a']) + expect(state().pendingTerminalPanes).toEqual([]) + }) + + it('shows main’s copy again when main refuses the close', async () => { + apply(slice(10, { a: [LEAF_A], b: [LEAF_B] })) + const main = stubCloseReplies() + state().closeTab('b') + main.reply(new Error('write failed')) + await settled() + expect(state().pendingTerminalPanes).toEqual([]) + + apply(slice(11, { a: [LEAF_A], b: [LEAF_B] })) + expect(tabIds()).toEqual(['a', 'b']) + }) +}) + +describe('a pane this window closes', () => { + it('stays hidden until main’s push holding the close is applied', async () => { + const main = stubCloseReplies() + commitTerminalSurfaceClose(state(), WT, { kind: 'pane', tabId: 'a', leafId: LEAF_B }) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'remove')).toEqual( + new Set([LEAF_B]) + ) + main.reply({ publishSeq: 3 }) + await settled() + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'remove').size).toBe(1) + + apply(slice(3, { a: [LEAF_A] })) + expect(state().pendingTerminalPanes).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts new file mode 100644 index 000000000000..cf0164f00d39 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -0,0 +1,132 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' +import { collectLeafIds } from '@/components/terminal-pane/terminal-pane-layout-tree' +import type { AppState } from '../types' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' + +/** A whole tab when `leafId` is absent. */ +export type PendingTerminalPaneKey = { worktreeId: string; tabId: string; leafId?: string } + +/** + * A tab or pane this window shows (`add`) or hides (`remove`) before main's topology does. An add + * stands until a slice names it; a remove until the slice holding main's reply (`publishSeq`). + */ +export type PendingTerminalPane = PendingTerminalPaneKey & { + change: 'add' | 'remove' + publishSeq?: number +} + +function isSamePane(a: PendingTerminalPaneKey, b: PendingTerminalPaneKey): boolean { + return a.worktreeId === b.worktreeId && a.tabId === b.tabId && a.leafId === b.leafId +} + +/** A newer change to the same tab or pane replaces the older one: a close supersedes its add. */ +export function withPendingTerminalPane( + pending: readonly PendingTerminalPane[], + entry: PendingTerminalPane +): PendingTerminalPane[] { + return [...pending.filter((current) => !isSamePane(current, entry)), entry] +} + +export function withoutPendingTerminalTab( + pending: PendingTerminalPane[], + tabId: string +): PendingTerminalPane[] { + return pending.some((entry) => entry.tabId === tabId) + ? pending.filter((entry) => entry.tabId !== tabId) + : pending +} + +export function isPendingTerminalTab( + pending: readonly PendingTerminalPane[], + worktreeId: string, + tabId: string, + change: PendingTerminalPane['change'] +): boolean { + return pending.some( + (entry) => + entry.worktreeId === worktreeId && + entry.tabId === tabId && + entry.leafId === undefined && + entry.change === change + ) +} + +export function pendingTerminalLeafIds( + pending: readonly PendingTerminalPane[], + tabId: string, + change: PendingTerminalPane['change'] +): Set { + return new Set( + pending.flatMap((entry) => + entry.tabId === tabId && entry.leafId && entry.change === change ? [entry.leafId] : [] + ) + ) +} + +/** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ +export function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { + const entry = state.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) + ) + return parseExecutionHostId(entry?.executionHostId)?.kind === 'runtime' +} + +/** A pane this window made, which main's layout doesn't name yet, stays until main names it. */ +export function markTerminalPaneIfAheadOfMain( + state: AppState, + pane: Required +): void { + const named = collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId) + if (!named && !isRuntimeHostedTab(state, pane.worktreeId, pane.tabId)) { + state.markPendingTerminalPane({ ...pane, change: 'add' }) + } +} + +/** The entries still pending once `slice` is applied; the same array when it settles none. */ +export function pendingAfterTerminalTopologySlice( + pending: PendingTerminalPane[], + slice: TerminalTopologySlice +): PendingTerminalPane[] { + const tabIds = new Set(slice.tabs.map((tab) => tab.id)) + const leafIds = new Set(Object.values(slice.layouts).flatMap(({ root }) => collectLeafIds(root))) + const settled = (entry: PendingTerminalPane): boolean => + entry.worktreeId === slice.worktreeId && + (entry.change === 'add' + ? entry.leafId + ? leafIds.has(entry.leafId) + : tabIds.has(entry.tabId) + : entry.publishSeq !== undefined && entry.publishSeq <= slice.publishSeq) + return pending.some(settled) ? pending.filter((entry) => !settled(entry)) : pending +} + +export function createTerminalPendingPaneActions( + set: TerminalStoreSet +): Pick { + return { + markPendingTerminalPane: (entry) => { + set((s) => ({ pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, entry) })) + }, + settlePendingTerminalPaneRemoval: (pane, publishSeq) => { + set((s) => { + const entry = s.pendingTerminalPanes.find( + (candidate) => candidate.change === 'remove' && isSamePane(candidate, pane) + ) + if (!entry) { + return s + } + // No publishSeq (refused, or no mirror) or one already applied: main's state stands. + const stands = + publishSeq === undefined || + publishSeq <= (s.terminalTopologySeqByWorktree[pane.worktreeId] ?? 0) + return { + pendingTerminalPanes: stands + ? s.pendingTerminalPanes.filter((candidate) => candidate !== entry) + : s.pendingTerminalPanes.map((candidate) => + candidate === entry ? { ...candidate, publishSeq } : candidate + ) + } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index b2ee0542c5da..9fc77f8eadc5 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -22,6 +22,7 @@ import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-c import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' +import type { PendingTerminalPane } from './terminal-pending-panes' export type DirectSshLayoutEdit = { targetId: string @@ -115,6 +116,8 @@ export type TerminalState = { defaultTerminalTabsAppliedByWorktreeId: Record /** The publishSeq of main's last applied terminal topology slice per worktree. */ terminalTopologySeqByWorktree: Record + /** What this window shows or hides ahead of main's topology; see terminal-pending-panes.ts. */ + pendingTerminalPanes: PendingTerminalPane[] closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean pendingReconnectWorktreeIds: string[] diff --git a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts index 25393aa8129b..3e0815d248a3 100644 --- a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts +++ b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts @@ -1,16 +1,32 @@ import type { TerminalSurfaceCloseTarget } from '../../../../shared/terminal-surface-close-target' +import type { TerminalSlice } from './terminal-state' -/** Commits a terminal tab or split-pane close in main, which owns membership: a renderer save - * cannot shrink it, so without this the close would ride on the PTY exit. */ +/** Commits a terminal tab or split-pane close in main, which owns membership. The surface stays + * hidden here until main's topology drops it, so an older push cannot bring it back. */ export function commitTerminalSurfaceClose( + store: Pick, worktreeId: string, target: TerminalSurfaceCloseTarget, reason?: 'user' | 'cleanup' ): void { + const pane = { + worktreeId, + tabId: target.tabId, + ...(target.kind === 'pane' ? { leafId: target.leafId } : {}) + } + store.markPendingTerminalPane({ ...pane, change: 'remove' }) // Why optional: an older preload can linger through an in-place renderer reload. - void globalThis.window?.api?.session - ?.closeTerminalSurface?.({ worktreeId, target, ...(reason ? { reason } : {}) }) - ?.catch((error: unknown) => { - console.warn('[terminal-close] main did not commit the close', error) + void Promise.resolve( + globalThis.window?.api?.session?.closeTerminalSurface?.({ + worktreeId, + target, + ...(reason ? { reason } : {}) }) + ).then( + (reply) => store.settlePendingTerminalPaneRemoval(pane, reply?.publishSeq), + (error: unknown) => { + console.warn('[terminal-close] main did not commit the close', error) + store.settlePendingTerminalPaneRemoval(pane) + } + ) } diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 198a2e114730..87fae5bc46a3 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -14,6 +14,7 @@ import { import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers' import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' +import { withoutPendingTerminalTab } from './terminal-pending-panes' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { removePaneKeysByTabPrefix } from '../slices/agent-status-pane-keyed-records' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' @@ -195,6 +196,7 @@ export function createTerminalTabCloseActions( } return { tabsByWorktree: next, + pendingTerminalPanes: withoutPendingTerminalTab(s.pendingTerminalPanes, tabId), activeTabId: s.activeTabId === tabId ? null : s.activeTabId, activeTabIdByWorktree: nextActiveTabIdByWorktree, ptyIdsByTabId: nextPtyIdsByTabId, @@ -258,7 +260,7 @@ export function createTerminalTabCloseActions( } }) if (intentReason && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { - commitTerminalSurfaceClose(closingWorktreeId, { kind: 'tab', tabId }, intentReason) + commitTerminalSurfaceClose(get(), closingWorktreeId, { kind: 'tab', tabId }, intentReason) } // Why only a user close: it is the explicit abandon. Other closes keep the drafts until their // workspace is removed in Orca; drafts have no budget that retires them. diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 79ee1e522332..fd8c35f802eb 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -20,6 +20,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { ownsGlobalSelection } from '../global-selection-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { withPendingTerminalPane } from './terminal-pending-panes' import { getRemoteConnectionIdForWorktree, resolveCreatedTabShellOverride, @@ -229,6 +230,12 @@ export function createTerminalTabCreationActions( ...orphanCleanupPatch.tabsByWorktree, [worktreeId]: [...existing, tab] }, + // Shown before main's topology names it; its pane's spawn tells main. + pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, { + worktreeId, + tabId: id, + change: 'add' + }), // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index f77e3d70285f..51940605a241 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -1,5 +1,4 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import { parseExecutionHostId } from '../../../../shared/execution-host' import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { @@ -13,6 +12,11 @@ import type { AppState } from '../types' import { emptyLayoutSnapshot } from '../slices/terminal-helpers' import type { TerminalSlice, TerminalStoreSet } from './terminal-state' import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' +import { + isPendingTerminalTab, + isRuntimeHostedTab, + pendingAfterTerminalTopologySlice +} from './terminal-pending-panes' const OPTIONAL_ROW_FIELDS = [ 'launchAgent', @@ -89,16 +93,9 @@ function mirrorSleepingRecords( return structuralValuesEqual(next, current) ? current : next } -/** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ -function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { - const entry = state.unifiedTabsByWorktree[worktreeId]?.find( - (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) - ) - return parseExecutionHostId(entry?.executionHostId)?.kind === 'runtime' -} - /** - * Main's slice replaces the worktree's terminal topology; everything else is the window's. + * Main's slice replaces the worktree's terminal topology, except tabs still pending here (shown + * before main names them, or hidden before main drops them); everything else is the window's. * Unchanged rows and layouts keep their identity, so an identical slice changes only the seq. */ export function mirrorTerminalTopologySlice( @@ -106,6 +103,9 @@ export function mirrorTerminalTopologySlice( slice: TerminalTopologySlice ): Partial { const { worktreeId } = slice + const pending = pendingAfterTerminalTopologySlice(state.pendingTerminalPanes, slice) + const isPending = (tabId: string, change: 'add' | 'remove'): boolean => + isPendingTerminalTab(pending, worktreeId, tabId, change) const currentTabs = state.tabsByWorktree[worktreeId] ?? [] const rowById = new Map(slice.tabs.map((row) => [row.id, row])) const kept = currentTabs.flatMap((tab) => { @@ -113,11 +113,11 @@ export function mirrorTerminalTopologySlice( if (row) { return [mirrorTabRow(tab, row)] } - return isRuntimeHostedTab(state, worktreeId, tab.id) ? [tab] : [] + return isRuntimeHostedTab(state, worktreeId, tab.id) || isPending(tab.id, 'add') ? [tab] : [] }) const currentIds = new Set(currentTabs.map((tab) => tab.id)) const added = slice.tabs - .filter((row) => !currentIds.has(row.id)) + .filter((row) => !currentIds.has(row.id) && !isPending(row.id, 'remove')) .map((row, index) => newTabRow(row, kept.length + index)) const tabs = [...kept, ...added] const tabsChanged = @@ -127,7 +127,9 @@ export function mirrorTerminalTopologySlice( const layouts = { ...state.terminalLayoutsByTabId } for (const [tabId, layout] of Object.entries(slice.layouts)) { - layouts[tabId] = mirrorLayout(layouts[tabId], layout) + if (!isPending(tabId, 'remove')) { + layouts[tabId] = mirrorLayout(layouts[tabId], layout) + } } for (const tab of added) { layouts[tab.id] ??= emptyLayoutSnapshot() @@ -149,6 +151,7 @@ export function mirrorTerminalTopologySlice( ? { sleepingAgentSessionsByPaneKey: sleeping } : {}), ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), + ...(pending !== state.pendingTerminalPanes ? { pendingTerminalPanes: pending } : {}), terminalTopologySeqByWorktree: { ...state.terminalTopologySeqByWorktree, [worktreeId]: slice.publishSeq @@ -170,3 +173,27 @@ export function createTerminalTopologyMirrorActions( } } } + +/** Resolves once this window has applied main's topology up to `publishSeq`. */ +export function terminalTopologyApplied( + subscribe: (listener: () => void) => () => void, + getState: () => Pick, + worktreeId: string, + publishSeq: number | undefined +): Promise { + const applied = (): boolean => + publishSeq === undefined || + (getState().terminalTopologySeqByWorktree[worktreeId] ?? 0) >= publishSeq + return new Promise((resolve) => { + if (applied()) { + resolve() + return + } + const unsubscribe = subscribe(() => { + if (applied()) { + unsubscribe() + resolve() + } + }) + }) +} From 3035bc37a2ccb18b590e70c30249bd8046bbf24c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 15:26:53 -0400 Subject: [PATCH 16/75] fix(terminal): a layout gesture writes only the worktree's home partition setLayout wrote every local/ssh partition holding the tab. A worktree has one home (the partition main's push projects), so main now resolves it with the same owner rule and writes only there; an unresolved home writes nothing and replies home_unresolved. --- .../ipc/session-terminal-set-layout.test.ts | 19 +++++-- src/main/ipc/session.ts | 6 ++- .../loading-store/pty-binding-persistence.ts | 15 ++++-- .../terminal-layout-set.test.ts | 51 ++++++++++++------ .../terminal-topology/terminal-layout-set.ts | 54 ++++++------------- .../terminal-topology-commit.ts | 23 ++++---- src/main/runtime/orca-runtime.ts | 6 +++ .../runtime-workspace-session-controller.ts | 7 +++ .../runtime/terminal-topology-owners.test.ts | 19 +++++-- src/shared/terminal-layout-set.ts | 5 +- 10 files changed, 129 insertions(+), 76 deletions(-) diff --git a/src/main/ipc/session-terminal-set-layout.test.ts b/src/main/ipc/session-terminal-set-layout.test.ts index a31a1efc3a52..2a4cf144a2ba 100644 --- a/src/main/ipc/session-terminal-set-layout.test.ts +++ b/src/main/ipc/session-terminal-set-layout.test.ts @@ -23,6 +23,8 @@ const setTerminalTabLayout = vi.fn(async () => { }) // Names the push only once the commit has landed. const settleTerminalTopology = vi.fn(() => (committed ? 9 : 0)) +let homeHostId: string | null = 'local' +const getTerminalTopologyHomeHostId = vi.fn(() => homeHostId) function setLayout(args: unknown): unknown { return mocks.handlers.get('session:terminal-set-layout')?.({}, args) @@ -32,16 +34,17 @@ beforeEach(() => { mocks.handlers.clear() setTerminalTabLayout.mockClear() committed = false + homeHostId = 'local' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the store's layout commit. const store = { setTerminalTabLayout } as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the topology settle. - registerSessionHandlers(store, { settleTerminalTopology } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the home lookup and the topology settle. + registerSessionHandlers(store, { getTerminalTopologyHomeHostId, settleTerminalTopology } as never) }) describe('session:terminal-set-layout', () => { it('replies with the publishSeq of the push holding the commit', async () => { await expect(setLayout(REQUEST)).resolves.toEqual({ status: 'committed', publishSeq: 9 }) - expect(setTerminalTabLayout).toHaveBeenCalledWith(REQUEST) + expect(setTerminalTabLayout).toHaveBeenCalledWith(REQUEST, 'local') expect(settleTerminalTopology).toHaveBeenCalledWith(REQUEST.worktreeId) }) @@ -52,4 +55,14 @@ describe('session:terminal-set-layout', () => { }) expect(setTerminalTabLayout).not.toHaveBeenCalled() }) + + it('writes nothing when the worktree has no resolvable home partition', async () => { + homeHostId = null + await expect(setLayout(REQUEST)).resolves.toEqual({ + status: 'refused', + reason: 'home_unresolved', + publishSeq: 0 + }) + expect(setTerminalTabLayout).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 90056e6d071c..c5bfe6837efb 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -76,7 +76,11 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic if (!request) { return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLayoutSetResult } - const result = await store.setTerminalTabLayout(request) + // One home per worktree; an unresolved one is unverifiable, so nothing is written. + const hostId = runtime.getTerminalTopologyHomeHostId(request.worktreeId) + const result: TerminalLayoutSetResult = hostId + ? await store.setTerminalTabLayout(request, hostId) + : { status: 'refused', reason: 'home_unresolved' } return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } }) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 271515c8a4a1..16835f0b8ff0 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,5 +1,9 @@ import { isDeepStrictEqual } from 'node:util' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' @@ -220,10 +224,13 @@ export class PtyBindingPersistenceOperations { return runtime.runDurableMutation(moveLeaf(request, topologyCommitContext(this))) } - /** A user's divider, direction or pane-order edit; here for the same reason as the move. */ - setTerminalTabLayout(request: TerminalLayoutSetRequest): Promise { + /** A user's divider, direction or pane-order edit, written to the worktree's home partition. */ + setTerminalTabLayout( + request: TerminalLayoutSetRequest, + hostId: ExecutionHostId + ): Promise { const { runtime } = this[ptyBindingPersistenceOperationsContext] - return runtime.runDurableMutation(setLayout(request, topologyCommitContext(this))) + return runtime.runDurableMutation(setLayout(request, hostId, topologyCommitContext(this))) } } diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.test.ts b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts index 1eb56d99e059..1863692806a8 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-set.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts @@ -56,7 +56,7 @@ function session(root: TerminalPaneLayoutNode): WorkspaceSessionState { } function profile(partitions: [ExecutionHostId, WorkspaceSessionState][]) { - const state: Parameters[1]['state'] = { + const state: Parameters[2]['state'] = { workspaceSession: session(leaf(C)), workspaceSessionsByHostId: {}, ui: getDefaultUIState(), @@ -64,9 +64,12 @@ function profile(partitions: [ExecutionHostId, WorkspaceSessionState][]) { } const dirty = new Set() const getSession = (hostId: ExecutionHostId): WorkspaceSessionState => - (hostId === 'local' ? state.workspaceSession : state.workspaceSessionsByHostId?.[hostId]) ?? - session(leaf(C)) - const context: Parameters[1] = { + (hostId === 'local' ? state.workspaceSession : state.workspaceSessionsByHostId?.[hostId]) ?? { + ...session(leaf(C)), + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } + const context: Parameters[2] = { state, hostIds: () => partitions.map(([hostId]) => hostId), getSession, @@ -84,7 +87,7 @@ describe('setLayout', () => { const { context, dirty, rootOf, state } = profile([['local', session(split(leaf(A), leaf(B)))]]) const dragged = split(leaf(A), leaf(B), 0.3) - const result = setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, context)() + const result = setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, 'local', context)() expect(result.value).toEqual({ status: 'committed' }) expect(result.persist).not.toBe(false) @@ -99,7 +102,9 @@ describe('setLayout', () => { it('takes a reordered tree, which keeps the same panes', () => { const { context, rootOf } = profile([['local', session(split(leaf(A), leaf(B)))]]) const swapped = split(leaf(B), leaf(A)) - expect(setLayout({ worktreeId: WT, tabId: TAB, root: swapped }, context)().value).toEqual({ + expect( + setLayout({ worktreeId: WT, tabId: TAB, root: swapped }, 'local', context)().value + ).toEqual({ status: 'committed' }) expect(rootOf('local')).toEqual(swapped) @@ -114,7 +119,7 @@ describe('setLayout', () => { const before = split(leaf(A), leaf(B)) const { context, dirty, rootOf } = profile([['local', session(before)]]) - const result = setLayout({ worktreeId: WT, tabId: TAB, root }, context)() + const result = setLayout({ worktreeId: WT, tabId: TAB, root }, 'local', context)() expect(result.value).toEqual({ status: 'refused', reason: 'leaves_differ' }) expect(result.persist).toBe(false) @@ -124,32 +129,45 @@ describe('setLayout', () => { it('refuses a tab main does not hold', () => { const { context } = profile([['local', session(split(leaf(A), leaf(B)))]]) - const result = setLayout({ worktreeId: WT, tabId: 'other', root: leaf(A) }, context)() + const result = setLayout({ worktreeId: WT, tabId: 'other', root: leaf(A) }, 'local', context)() expect(result.value).toEqual({ status: 'refused', reason: 'tab_not_held' }) }) it('writes nothing for the tree main already holds', () => { const root = split(leaf(A), leaf(B), 0.4) const { context, dirty } = profile([['local', session(root)]]) - const result = setLayout({ worktreeId: WT, tabId: TAB, root: structuredClone(root) }, context)() + const result = setLayout( + { worktreeId: WT, tabId: TAB, root: structuredClone(root) }, + 'local', + context + )() expect(result).toEqual({ value: { status: 'committed' }, persist: false }) expect(dirty.size).toBe(0) }) - it('updates every owner partition holding the tab, never a runtime: mirror', () => { + it('writes only the home partition, leaving a stray copy of the tab untouched', () => { const before = split(leaf(A), leaf(B)) - const { context, rootOf } = profile([ + const { context, dirty, rootOf } = profile([ ['local', session(before)], - ['ssh:target', session(before)], - ['runtime:env', session(before)] + ['ssh:target', session(before)] ]) const dragged = split(leaf(A), leaf(B), 0.7) - setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, context)() + setLayout({ worktreeId: WT, tabId: TAB, root: dragged }, 'ssh:target', context)() - expect(rootOf('local')).toEqual(dragged) expect(rootOf('ssh:target')).toEqual(dragged) - expect(rootOf('runtime:env')).toEqual(before) + expect(rootOf('local')).toEqual(before) + expect([...dirty]).toEqual(['workspaceSessionsByHostId']) + }) + + it('refuses when only another partition holds the tab', () => { + const { context } = profile([['local', session(split(leaf(A), leaf(B)))]]) + const result = setLayout( + { worktreeId: WT, tabId: TAB, root: split(leaf(A), leaf(B), 0.7) }, + 'ssh:target', + context + )() + expect(result.value).toEqual({ status: 'refused', reason: 'tab_not_held' }) }) it('rolls back to the prior tree', () => { @@ -157,6 +175,7 @@ describe('setLayout', () => { const { context, rootOf } = profile([['local', session(before)]]) const result = setLayout( { worktreeId: WT, tabId: TAB, root: split(leaf(A), leaf(B), 0.2) }, + 'local', context )() result.rollback?.() diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.ts b/src/main/persistence/terminal-topology/terminal-layout-set.ts index c70d349df5e5..162a575ffcb0 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-set.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-set.ts @@ -4,57 +4,35 @@ import type { TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' -import { - isTerminalOwnerPartition, - type TerminalSessionPartition -} from './terminal-topology-membership' - -type PlannedTerminalLayoutSet = { - result: TerminalLayoutSetResult - /** Every partition the plan rewrote; empty when nothing changes. */ - sessions: TerminalSessionPartition[] -} const sortedLeafIds = (root: TerminalPaneLayoutNode | null | undefined): string[] => collectLayoutLeafIdsInOrder(root).sort() /** - * Replaces a tab's tree with the window's when it holds exactly the same panes. A geometry edit - * never adds, removes or moves a pane between tabs; a tree that would is refused, not merged. - * Every owner partition holding the tab takes it, as a move does. + * Replaces a tab's tree in the worktree's home partition with the window's, when it holds exactly + * the same panes. A geometry edit never adds, removes or moves a pane; a tree that would is + * refused, not merged. `session` is null when nothing changes. */ export function planTerminalLayoutSet( - partitions: readonly TerminalSessionPartition[], + home: WorkspaceSessionState, { worktreeId, tabId, root }: TerminalLayoutSetRequest -): PlannedTerminalLayoutSet { - const holders = partitions.flatMap((partition) => { - const layout = partition.session.terminalLayoutsByTabId?.[tabId] - const holdsTab = - isTerminalOwnerPartition(partition.hostId) && - partition.session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) - return holdsTab && layout?.root ? [{ ...partition, layout }] : [] - }) - if (holders.length === 0) { - return { result: { status: 'refused', reason: 'tab_not_held' }, sessions: [] } +): { result: TerminalLayoutSetResult; session: WorkspaceSessionState | null } { + const layout = home.terminalLayoutsByTabId?.[tabId] + if (!layout?.root || !home.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId)) { + return { result: { status: 'refused', reason: 'tab_not_held' }, session: null } } - const leafIds = sortedLeafIds(root) - if (holders.some(({ layout }) => !isDeepStrictEqual(sortedLeafIds(layout.root), leafIds))) { - return { result: { status: 'refused', reason: 'leaves_differ' }, sessions: [] } + if (!isDeepStrictEqual(sortedLeafIds(layout.root), sortedLeafIds(root))) { + return { result: { status: 'refused', reason: 'leaves_differ' }, session: null } } return { result: { status: 'committed' }, - sessions: holders - .filter(({ layout }) => !isDeepStrictEqual(layout.root, root)) - .map(({ hostId, session, layout }) => ({ - hostId, - session: { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [tabId]: { ...layout, root } - } + session: isDeepStrictEqual(layout.root, root) + ? null + : { + ...home, + terminalLayoutsByTabId: { ...home.terminalLayoutsByTabId, [tabId]: { ...layout, root } } } - })) } } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 8503da9eb5ae..d16e7c4bff86 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -51,18 +51,22 @@ export function moveLeaf( ) } -/** Replaces a tab's tree with the user's same-pane geometry edit; any other tree is refused. */ +/** Replaces a tab's tree in `hostId`, the worktree's home, with the user's same-pane geometry edit. */ export function setLayout( request: TerminalLayoutSetRequest, + hostId: ExecutionHostId, context: TerminalTopologyCommitContext ): () => DurableProfileStateMutation { return traced( 'set_layout', () => { - const planned = planTerminalLayoutSet(partitionsOf(context), request) - return planned.sessions.length === 0 - ? { value: planned.result, persist: false } - : { value: planned.result, rollback: writePartitions(planned.sessions, context) } + const planned = planTerminalLayoutSet(context.getSession(hostId), request) + return planned.session + ? { + value: planned.result, + rollback: writePartitions([{ hostId, session: planned.session }], context) + } + : { value: planned.result, persist: false } }, (result) => (result.status === 'refused' ? result.reason : undefined) ) @@ -127,10 +131,6 @@ function writeRestorable(read: () => V, write: (value: V) => void, next: V): } } -function partitionsOf(context: TerminalTopologyCommitContext): TerminalSessionPartition[] { - return context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })) -} - /** Puts each planned partition in place; the returned rollback restores them. */ function writePartitions( sessions: readonly TerminalSessionPartition[], @@ -151,7 +151,10 @@ function commitLeafMove( context: TerminalTopologyCommitContext ): DurableProfileStateMutation { const { state } = context - const planned = planTerminalLeafMove(partitionsOf(context), request) + const planned = planTerminalLeafMove( + context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), + request + ) if (planned.sessions.length === 0) { return { value: planned.result, persist: false } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f04b4b49f6c1..d3800e7df0e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,7 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' +import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import { TerminalTopologyPublisher } from './terminal-topology-publisher' @@ -38,6 +39,11 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { return this.terminalTopology.snapshot() } + /** Where main writes a worktree's terminal layout: the partition its push projects. */ + getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { + return this.workspaceSessions.getTerminalTopologyHomeHostId(worktreeId) + } + /** The publishSeq of the push holding every topology write made so far, for a reply to name. */ settleTerminalTopology(worktreeId?: string): number { return this.terminalTopology.settle(worktreeId) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index c75103f1b263..67ae2cd8964c 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -151,6 +151,13 @@ export class RuntimeWorkspaceSessionController { return worktreeIds } + /** The one `local`/`ssh:` partition a worktree's terminal rows live in; null when unresolved or another server's. */ + getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { + const store = this.deps.getStore() + const hostId = store ? this.tryGetPreferredHostId(worktreeId, store) : null + return hostId && isTerminalOwnerPartition(hostId) ? hostId : null + } + /** * Worktrees with terminal rows in their home partition. `runtime:` homes are another server's; * a home that can't be resolved (a missing or ambiguous folder) maps to null. diff --git a/src/main/runtime/terminal-topology-owners.test.ts b/src/main/runtime/terminal-topology-owners.test.ts index 81a096a2665f..9bbc6a0d0079 100644 --- a/src/main/runtime/terminal-topology-owners.test.ts +++ b/src/main/runtime/terminal-topology-owners.test.ts @@ -30,7 +30,7 @@ function sessionWith(worktreeIds: string[]): WorkspaceSessionState { return session } -function ownersFor(sessions: Map) { +function controllerFor(sessions: Map) { const repos = [ { id: 'repo-local' }, { id: 'repo-ssh', connectionId: 'c1' }, @@ -49,7 +49,7 @@ function ownersFor(sessions: Map) { getWorkspaceSession: (hostId: ExecutionHostId) => sessions.get(hostId) ?? getDefaultWorkspaceSession() } as never - const controller = new RuntimeWorkspaceSessionController({ + return new RuntimeWorkspaceSessionController({ getStore: () => store, resolveFolderConnectionId: (workspace: FolderWorkspace) => { if (workspace.id === 'mixed') { @@ -59,7 +59,10 @@ function ownersFor(sessions: Map) { }, hasRuntimeOwnedPtyCandidate: () => false }) - return controller.getTerminalTopologyOwners() +} + +function ownersFor(sessions: Map) { + return controllerFor(sessions).getTerminalTopologyOwners() } describe('terminal topology owners', () => { @@ -111,4 +114,14 @@ describe('terminal topology owners', () => { ]) ) }) + + it('resolves one home for a layout write, and none for a runtime or ambiguous worktree', () => { + const controller = controllerFor(new Map([['local', sessionWith([SSH_WT])]])) + + expect(controller.getTerminalTopologyHomeHostId(LOCAL_WT)).toBe('local') + expect(controller.getTerminalTopologyHomeHostId(SSH_WT)).toBe('ssh:c1') + expect(controller.getTerminalTopologyHomeHostId(SSH_FOLDER)).toBe('ssh:c1') + expect(controller.getTerminalTopologyHomeHostId(RUNTIME_WT)).toBeNull() + expect(controller.getTerminalTopologyHomeHostId(AMBIGUOUS_FOLDER)).toBeNull() + }) }) diff --git a/src/shared/terminal-layout-set.ts b/src/shared/terminal-layout-set.ts index d44c698681af..952350e730fd 100644 --- a/src/shared/terminal-layout-set.ts +++ b/src/shared/terminal-layout-set.ts @@ -12,7 +12,10 @@ export type TerminalLayoutSetRequest = z.infer Date: Wed, 7 Oct 2026 15:32:27 -0400 Subject: [PATCH 17/75] refactor(terminal): a layout gesture is a pending change, beside pending tabs and panes One record of what the window keeps over main's pushes until main publishes it: a gesture's tree is a 'layout' entry in pendingTerminalPanes, settled like a close (by the reply's publishSeq) or at once when main's tab has other panes. Closes and gestures share one commit helper; the separate gesture map and its file are gone. --- .../pending-split-close-test-fixture.ts | 2 +- .../terminal-layout-gesture-persist.test.tsx | 25 +++-- .../terminal-pane/terminal-pane-tab-detach.ts | 2 +- .../use-terminal-pane-layout-persistence.ts | 9 +- src/renderer/src/store/slices/terminals.ts | 3 - .../src/store/terminals/terminal-actions.ts | 18 +--- .../terminals/terminal-layout-gestures.ts | 88 --------------- ...est.ts => terminal-pending-layout.test.ts} | 21 +++- .../store/terminals/terminal-pending-panes.ts | 100 ++++++++++++++---- .../src/store/terminals/terminal-state.ts | 3 - .../terminal-surface-close-intent.ts | 35 +++--- .../terminals/terminal-topology-mirror.ts | 24 ++--- 12 files changed, 147 insertions(+), 183 deletions(-) delete mode 100644 src/renderer/src/store/terminals/terminal-layout-gestures.ts rename src/renderer/src/store/terminals/{terminal-layout-gestures.test.ts => terminal-pending-layout.test.ts} (85%) diff --git a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts index 14b5e18a508b..11bf4602346a 100644 --- a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts +++ b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts @@ -110,7 +110,7 @@ export async function preparePendingSplitClose( retireAgentPaneAuthority: vi.fn(), suppressPtyExit: vi.fn(), markPendingTerminalPane: vi.fn(), - settlePendingTerminalPaneRemoval: vi.fn() + settlePendingTerminalPane: vi.fn() }) as unknown as AppState const spawn = Promise.withResolvers() vi.mocked(window.api.pty.spawn).mockReturnValueOnce(spawn.promise) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx index e2e34999de8e..df39803c2cb1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx @@ -8,7 +8,11 @@ const RIGHT = '22222222-2222-4222-8222-222222222222' const store = vi.hoisted(() => { const terminalLayoutsByTabId: Record = {} - return { terminalLayoutsByTabId, commitTerminalLayoutGesture: vi.fn() } + return { + terminalLayoutsByTabId, + markPendingTerminalPane: vi.fn(), + settlePendingTerminalPane: vi.fn() + } }) vi.mock('../../store', () => ({ useAppStore: { getState: () => store } })) vi.mock('@/runtime/web-runtime-session', () => ({ clearWebRuntimeTerminalBuffer: () => true })) @@ -72,7 +76,7 @@ function renderPersistence(ptyIdForPane = (paneId: number) => `remote:host:${pan afterEach(() => { cleanup() store.terminalLayoutsByTabId = {} - store.commitTerminalLayoutGesture.mockClear() + store.markPendingTerminalPane.mockClear() }) describe('terminal layout gesture persist', () => { @@ -117,7 +121,7 @@ describe('terminal layout gesture persist', () => { it('never commits a remote tab to main; its host takes the push', () => { const { hook } = renderPersistence() hook.result.current.persistLayoutSnapshot('gesture') - expect(store.commitTerminalLayoutGesture).not.toHaveBeenCalled() + expect(store.markPendingTerminalPane).not.toHaveBeenCalled() }) }) @@ -129,16 +133,17 @@ describe('local terminal layout gestures', () => { tab: { root: { type: 'leaf', leafId: LEFT }, activeLeafId: LEFT, expandedLeafId: null } } const { hook, setTabLayout } = renderPersistence(local) - store.commitTerminalLayoutGesture.mockClear() + store.markPendingTerminalPane.mockClear() hook.result.current.persistLayoutSnapshot('gesture') const saved = setTabLayout.mock.calls.at(-1)?.[1] - expect(store.commitTerminalLayoutGesture).toHaveBeenCalledExactlyOnceWith( - 'wt', - 'tab', - saved.root - ) + expect(store.markPendingTerminalPane).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'wt', + tabId: 'tab', + change: 'layout', + root: saved.root + }) }) it('commits nothing for an automatic persist or a gesture that left the tree as it was', () => { @@ -148,7 +153,7 @@ describe('local terminal layout gestures', () => { hook.result.current.persistLayoutSnapshot() hook.result.current.removePaneTitle(1) - expect(store.commitTerminalLayoutGesture).not.toHaveBeenCalled() + expect(store.markPendingTerminalPane).not.toHaveBeenCalled() }) it("saves main's bindings, not the live transports' PTY ids", () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index 82ec538630aa..2e4409681477 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -28,7 +28,7 @@ export type TerminalPaneTabDetachStore = Pick< | 'setActiveTabType' | 'setTabLayout' | 'syncPaneDetachPtyOwnership' - | 'settlePendingTerminalPaneRemoval' + | 'settlePendingTerminalPane' | 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'terminalTopologySeqByWorktree' diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index ff2b96cbf6ff..2a2d2d6d2311 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -5,6 +5,7 @@ import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' +import { commitPendingTerminalChange } from '@/store/terminals/terminal-pending-panes' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { clearWebRuntimeTerminalBuffer } from '@/runtime/web-runtime-session' @@ -126,7 +127,13 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if (hasRemotePane) { remotePaneLayoutPusherRef.current?.push({ worktreeId, tabId, layout, intent }) } else if (geometryEdit && layout.root) { - useAppStore.getState().commitTerminalLayoutGesture(worktreeId, tabId, layout.root) + const root = layout.root + commitPendingTerminalChange( + useAppStore.getState(), + { worktreeId, tabId, change: 'layout', root }, + // Why optional: an older preload can linger through an in-place renderer reload. + () => globalThis.window?.api?.session?.setTerminalLayout?.({ worktreeId, tabId, root }) + ) } for (const leafId of currentLeafIds) { clearedScrollbackLeafIds.delete(leafId) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 2215388adc79..374b54d245e2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -23,7 +23,6 @@ import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' import { createTerminalTopologyMirrorActions } from '../terminals/terminal-topology-mirror' -import { createTerminalLayoutGestureActions } from '../terminals/terminal-layout-gestures' import { createTerminalPendingPaneActions } from '../terminals/terminal-pending-panes' import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' @@ -66,7 +65,6 @@ export const createTerminalSlice: StateCreator contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, terminalTopologySeqByWorktree: {}, - terminalLayoutGesturesByWorktree: {}, pendingTerminalPanes: [], closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, @@ -103,7 +101,6 @@ export const createTerminalSlice: StateCreator ...createTerminalStartupQueueActions(set, get), ...createWorkspaceTerminalHydrationActions(set, get), ...createTerminalTopologyMirrorActions(set), - ...createTerminalLayoutGestureActions(set), ...createTerminalPendingPaneActions(set), ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index d6c63fce4f8d..d3aa3f71ec3b 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,14 +1,10 @@ import type { AgentAttentionUnreadReason } from '@/attention/agent-attention-contract' import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../../../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' -import type { PendingTerminalPane, PendingTerminalPaneKey } from './terminal-pending-panes' +import type { PendingTerminalPane } from './terminal-pending-panes' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -292,15 +288,9 @@ export type TerminalActions = { removeDeferredSshSessionId: (tabId: string) => void /** Mirrors main's terminal topology for one worktree; a slice older than the last applied is ignored. */ applyTerminalTopologySlice: (slice: TerminalTopologySlice) => void - /** Sends a user's geometry edit to main and keeps it over older pushes until main publishes it. */ - commitTerminalLayoutGesture: ( - worktreeId: string, - tabId: string, - root: TerminalPaneLayoutNode - ) => void markPendingTerminalPane: (entry: PendingTerminalPane) => void - /** Main answered a close; the pending remove ends once the mirror holds `publishSeq`. */ - settlePendingTerminalPaneRemoval: (pane: PendingTerminalPaneKey, publishSeq?: number) => void + /** Main answered `entry`'s commit; it ends once the mirror holds `publishSeq`. */ + settlePendingTerminalPane: (entry: PendingTerminalPane, publishSeq?: number) => void /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ hydrateWorkspaceSession: ( session: WorkspaceSessionState, diff --git a/src/renderer/src/store/terminals/terminal-layout-gestures.ts b/src/renderer/src/store/terminals/terminal-layout-gestures.ts deleted file mode 100644 index fe283788b1ae..000000000000 --- a/src/renderer/src/store/terminals/terminal-layout-gestures.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' -import type { - TerminalTopologyLayout, - TerminalTopologySlice -} from '../../../../shared/terminal-topology-slice' -import { collectLeafIds } from '@/components/terminal-pane/terminal-pane-layout-tree' -import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' -import type { TerminalSlice, TerminalStoreSet } from './terminal-state' - -/** A user's geometry edit main has not published yet; `publishSeq` arrives with main's reply. */ -export type TerminalLayoutGesture = { root: TerminalPaneLayoutNode; publishSeq?: number } - -const leafSet = (root: TerminalPaneLayoutNode | null): string => - collectLeafIds(root).sort().join('\n') - -/** - * Main's layouts with each pending gesture's root kept, so an older push can't snap a divider - * back. A gesture ends once the slice holds its publishSeq, or once main's tab has other panes - * (main refuses a gesture that would change them). - */ -export function holdTerminalLayoutGestures( - gestures: Record, - slice: TerminalTopologySlice -): { - layouts: Record - held: Record -} { - const layouts = { ...slice.layouts } - const held: Record = {} - for (const [tabId, gesture] of Object.entries(gestures)) { - const layout = slice.layouts[tabId] - if ( - layout && - !(gesture.publishSeq !== undefined && slice.publishSeq >= gesture.publishSeq) && - leafSet(layout.root) === leafSet(gesture.root) - ) { - layouts[tabId] = { ...layout, root: gesture.root } - held[tabId] = gesture - } - } - return { layouts, held } -} - -export function createTerminalLayoutGestureActions( - set: TerminalStoreSet -): Pick { - return { - commitTerminalLayoutGesture: (worktreeId, tabId, root) => { - // Why optional: an older preload can linger through an in-place renderer reload. - const send = globalThis.window?.api?.session?.setTerminalLayout - if (!send) { - return - } - set((s) => ({ - terminalLayoutGesturesByWorktree: { - ...s.terminalLayoutGesturesByWorktree, - [worktreeId]: { ...s.terminalLayoutGesturesByWorktree[worktreeId], [tabId]: { root } } - } - })) - const settle = (publishSeq: number | undefined): void => - set((s) => { - const gestures = s.terminalLayoutGesturesByWorktree[worktreeId] ?? {} - // A later gesture on the tab owns it now. - if (gestures[tabId]?.root !== root) { - return s - } - const unpublished = - publishSeq !== undefined && - (s.terminalTopologySeqByWorktree[worktreeId] ?? 0) < publishSeq - return { - terminalLayoutGesturesByWorktree: { - ...s.terminalLayoutGesturesByWorktree, - [worktreeId]: unpublished - ? { ...gestures, [tabId]: { root, publishSeq } } - : omitRecordKeys(gestures, [tabId]) - } - } - }) - void send({ worktreeId, tabId, root }).then( - (reply) => settle(reply.publishSeq), - (error: unknown) => { - console.warn('[terminal-layout] main did not commit the layout', error) - settle(undefined) - } - ) - } - } -} diff --git a/src/renderer/src/store/terminals/terminal-layout-gestures.test.ts b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts similarity index 85% rename from src/renderer/src/store/terminals/terminal-layout-gestures.test.ts rename to src/renderer/src/store/terminals/terminal-pending-layout.test.ts index 8196b11ac293..0e19a6b8a484 100644 --- a/src/renderer/src/store/terminals/terminal-layout-gestures.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import type { TerminalLayoutSetResult } from '../../../../shared/terminal-layout-set' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' +import { commitPendingTerminalChange, withPendingTerminalPane } from './terminal-pending-panes' const WT = 'repo::/wt' const TAB = 'tab' @@ -31,7 +32,7 @@ const initial = useAppStore.getState() const originalWindow = globalThis.window let replies: { resolve: (reply: Reply) => void; reject: (error: Error) => void }[] = [] const setTerminalLayout = vi.fn( - () => + (_request: unknown) => new Promise((resolve, reject) => { replies.push({ resolve, reject }) }) @@ -59,7 +60,9 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) function dragEnd(root: TerminalPaneLayoutNode): void { const layout = state().terminalLayoutsByTabId[TAB] state().setTabLayout(TAB, { ...layout!, root }) - state().commitTerminalLayoutGesture(WT, TAB, root) + commitPendingTerminalChange(state(), { worktreeId: WT, tabId: TAB, change: 'layout', root }, () => + setTerminalLayout({ worktreeId: WT, tabId: TAB, root }) + ) } beforeEach(() => { @@ -129,7 +132,7 @@ describe('a divider drag committed to main', () => { apply(2, DRAGGED) replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) await flush() - expect(state().terminalLayoutGesturesByWorktree[WT]).toEqual({}) + expect(state().pendingTerminalPanes).toEqual([]) apply(3, BEFORE) expect(rootInStore()).toEqual(BEFORE) @@ -140,7 +143,7 @@ describe('a divider drag committed to main', () => { const closed = split(leaf(A), leaf(C)) apply(2, closed) expect(rootInStore()).toEqual(closed) - expect(state().terminalLayoutGesturesByWorktree[WT]).toEqual({}) + expect(state().pendingTerminalPanes).toEqual([]) }) it('keeps the latest of two drags when the first reply lands', async () => { @@ -163,4 +166,14 @@ describe('a divider drag committed to main', () => { apply(2, BEFORE) expect(rootInStore()).toEqual(BEFORE) }) + + it("leaves the tab's pending add in place; only a later drag replaces a drag", () => { + const add = { worktreeId: WT, tabId: TAB, change: 'add' } as const + const first = { worktreeId: WT, tabId: TAB, change: 'layout', root: DRAGGED } as const + const second = { ...first, root: BEFORE } + expect(withPendingTerminalPane(withPendingTerminalPane([add], first), second)).toEqual([ + add, + second + ]) + }) }) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index cf0164f00d39..cc0964bf7781 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -1,5 +1,9 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../../../shared/terminal-topology-slice' import { collectLeafIds } from '@/components/terminal-pane/terminal-pane-layout-tree' import type { AppState } from '../types' import type { TerminalSlice, TerminalStoreSet } from './terminal-state' @@ -8,24 +12,35 @@ import type { TerminalSlice, TerminalStoreSet } from './terminal-state' export type PendingTerminalPaneKey = { worktreeId: string; tabId: string; leafId?: string } /** - * A tab or pane this window shows (`add`) or hides (`remove`) before main's topology does. An add - * stands until a slice names it; a remove until the slice holding main's reply (`publishSeq`). + * A tab or pane this window shows (`add`) or hides (`remove`), or a tab tree it keeps (`layout`, + * a user's geometry edit), before main's topology does. An add stands until a slice names it; a + * remove or layout until the slice holding main's reply (`publishSeq`). A layout also ends once + * main's tab has other panes, since main refuses it then. */ -export type PendingTerminalPane = PendingTerminalPaneKey & { - change: 'add' | 'remove' - publishSeq?: number -} +export type PendingTerminalPane = PendingTerminalPaneKey & { publishSeq?: number } & ( + | { change: 'add' | 'remove' } + | { change: 'layout'; root: TerminalPaneLayoutNode } + ) + +type PendingTerminalLayout = Extract function isSamePane(a: PendingTerminalPaneKey, b: PendingTerminalPaneKey): boolean { return a.worktreeId === b.worktreeId && a.tabId === b.tabId && a.leafId === b.leafId } -/** A newer change to the same tab or pane replaces the older one: a close supersedes its add. */ +/** A newer change to the same tab or pane replaces the older one: a close its add, a drag the + * previous drag. A tab's tree and its membership are separate changes. */ export function withPendingTerminalPane( pending: readonly PendingTerminalPane[], entry: PendingTerminalPane ): PendingTerminalPane[] { - return [...pending.filter((current) => !isSamePane(current, entry)), entry] + const isLayout = entry.change === 'layout' + return [ + ...pending.filter( + (current) => !isSamePane(current, entry) || (current.change === 'layout') !== isLayout + ), + entry + ] } export function withoutPendingTerminalTab( @@ -64,6 +79,21 @@ export function pendingTerminalLeafIds( ) } +/** The tree a pending gesture keeps over main's for this tab, if any. */ +export function pendingTerminalLayoutRoot( + pending: readonly PendingTerminalPane[], + worktreeId: string, + tabId: string +): TerminalPaneLayoutNode | undefined { + return pending.find( + (entry): entry is PendingTerminalLayout => + entry.change === 'layout' && entry.worktreeId === worktreeId && entry.tabId === tabId + )?.root +} + +const leafSet = (root: TerminalPaneLayoutNode | null | undefined): string => + collectLeafIds(root).sort().join('\n') + /** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ export function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { const entry = state.unifiedTabsByWorktree[worktreeId]?.find( @@ -90,35 +120,59 @@ export function pendingAfterTerminalTopologySlice( ): PendingTerminalPane[] { const tabIds = new Set(slice.tabs.map((tab) => tab.id)) const leafIds = new Set(Object.values(slice.layouts).flatMap(({ root }) => collectLeafIds(root))) - const settled = (entry: PendingTerminalPane): boolean => - entry.worktreeId === slice.worktreeId && - (entry.change === 'add' - ? entry.leafId - ? leafIds.has(entry.leafId) - : tabIds.has(entry.tabId) - : entry.publishSeq !== undefined && entry.publishSeq <= slice.publishSeq) + const settled = (entry: PendingTerminalPane): boolean => { + if (entry.worktreeId !== slice.worktreeId) { + return false + } + if (entry.change === 'add') { + return entry.leafId ? leafIds.has(entry.leafId) : tabIds.has(entry.tabId) + } + if ( + entry.change === 'layout' && + leafSet(slice.layouts[entry.tabId]?.root) !== leafSet(entry.root) + ) { + return true + } + return entry.publishSeq !== undefined && entry.publishSeq <= slice.publishSeq + } return pending.some(settled) ? pending.filter((entry) => !settled(entry)) : pending } +/** + * Holds `entry` over main's pushes while `send` commits it in main, and until the push holding + * main's reply is applied. Not superseded meanwhile: a later change to the same target owns it. + */ +export function commitPendingTerminalChange( + store: Pick, + entry: PendingTerminalPane, + send: () => Promise | undefined +): void { + store.markPendingTerminalPane(entry) + void Promise.resolve(send()).then( + (reply) => store.settlePendingTerminalPane(entry, reply?.publishSeq), + (error: unknown) => { + console.warn(`[terminal-topology] main did not commit the ${entry.change}`, error) + store.settlePendingTerminalPane(entry) + } + ) +} + export function createTerminalPendingPaneActions( set: TerminalStoreSet -): Pick { +): Pick { return { markPendingTerminalPane: (entry) => { set((s) => ({ pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, entry) })) }, - settlePendingTerminalPaneRemoval: (pane, publishSeq) => { + settlePendingTerminalPane: (entry, publishSeq) => { set((s) => { - const entry = s.pendingTerminalPanes.find( - (candidate) => candidate.change === 'remove' && isSamePane(candidate, pane) - ) - if (!entry) { + if (!s.pendingTerminalPanes.includes(entry)) { return s } // No publishSeq (refused, or no mirror) or one already applied: main's state stands. const stands = publishSeq === undefined || - publishSeq <= (s.terminalTopologySeqByWorktree[pane.worktreeId] ?? 0) + publishSeq <= (s.terminalTopologySeqByWorktree[entry.worktreeId] ?? 0) return { pendingTerminalPanes: stands ? s.pendingTerminalPanes.filter((candidate) => candidate !== entry) diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index a12e86207140..9fc77f8eadc5 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -22,7 +22,6 @@ import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-c import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' -import type { TerminalLayoutGesture } from './terminal-layout-gestures' import type { PendingTerminalPane } from './terminal-pending-panes' export type DirectSshLayoutEdit = { @@ -117,8 +116,6 @@ export type TerminalState = { defaultTerminalTabsAppliedByWorktreeId: Record /** The publishSeq of main's last applied terminal topology slice per worktree. */ terminalTopologySeqByWorktree: Record - /** Per worktree and tab, the user's geometry edits main has not published yet. */ - terminalLayoutGesturesByWorktree: Record> /** What this window shows or hides ahead of main's topology; see terminal-pending-panes.ts. */ pendingTerminalPanes: PendingTerminalPane[] closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId diff --git a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts index 3e0815d248a3..edaf307d413d 100644 --- a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts +++ b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts @@ -1,32 +1,29 @@ import type { TerminalSurfaceCloseTarget } from '../../../../shared/terminal-surface-close-target' +import { commitPendingTerminalChange } from './terminal-pending-panes' import type { TerminalSlice } from './terminal-state' /** Commits a terminal tab or split-pane close in main, which owns membership. The surface stays * hidden here until main's topology drops it, so an older push cannot bring it back. */ export function commitTerminalSurfaceClose( - store: Pick, + store: Pick, worktreeId: string, target: TerminalSurfaceCloseTarget, reason?: 'user' | 'cleanup' ): void { - const pane = { - worktreeId, - tabId: target.tabId, - ...(target.kind === 'pane' ? { leafId: target.leafId } : {}) - } - store.markPendingTerminalPane({ ...pane, change: 'remove' }) - // Why optional: an older preload can linger through an in-place renderer reload. - void Promise.resolve( - globalThis.window?.api?.session?.closeTerminalSurface?.({ + commitPendingTerminalChange( + store, + { worktreeId, - target, - ...(reason ? { reason } : {}) - }) - ).then( - (reply) => store.settlePendingTerminalPaneRemoval(pane, reply?.publishSeq), - (error: unknown) => { - console.warn('[terminal-close] main did not commit the close', error) - store.settlePendingTerminalPaneRemoval(pane) - } + tabId: target.tabId, + ...(target.kind === 'pane' ? { leafId: target.leafId } : {}), + change: 'remove' + }, + // Why optional: an older preload can linger through an in-place renderer reload. + () => + globalThis.window?.api?.session?.closeTerminalSurface?.({ + worktreeId, + target, + ...(reason ? { reason } : {}) + }) ) } diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 5535b6adf708..e7e9603ef270 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -12,11 +12,11 @@ import type { AppState } from '../types' import { emptyLayoutSnapshot } from '../slices/terminal-helpers' import type { TerminalSlice, TerminalStoreSet } from './terminal-state' import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' -import { holdTerminalLayoutGestures } from './terminal-layout-gestures' import { isPendingTerminalTab, isRuntimeHostedTab, - pendingAfterTerminalTopologySlice + pendingAfterTerminalTopologySlice, + pendingTerminalLayoutRoot } from './terminal-pending-panes' const OPTIONAL_ROW_FIELDS = [ @@ -126,14 +126,14 @@ export function mirrorTerminalTopologySlice( const keptIds = new Set(tabs.map((tab) => tab.id)) const removedIds = currentTabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id) - const gestures = holdTerminalLayoutGestures( - state.terminalLayoutGesturesByWorktree[worktreeId] ?? {}, - slice - ) const layouts = { ...state.terminalLayoutsByTabId } - for (const [tabId, layout] of Object.entries(gestures.layouts)) { + for (const [tabId, layout] of Object.entries(slice.layouts)) { if (!isPending(tabId, 'remove')) { - layouts[tabId] = mirrorLayout(layouts[tabId], layout) + const heldRoot = pendingTerminalLayoutRoot(pending, worktreeId, tabId) + layouts[tabId] = mirrorLayout( + layouts[tabId], + heldRoot ? { ...layout, root: heldRoot } : layout + ) } } for (const tab of added) { @@ -156,14 +156,6 @@ export function mirrorTerminalTopologySlice( ? { sleepingAgentSessionsByPaneKey: sleeping } : {}), ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), - ...(worktreeId in state.terminalLayoutGesturesByWorktree - ? { - terminalLayoutGesturesByWorktree: { - ...state.terminalLayoutGesturesByWorktree, - [worktreeId]: gestures.held - } - } - : {}), ...(pending !== state.pendingTerminalPanes ? { pendingTerminalPanes: pending } : {}), terminalTopologySeqByWorktree: { ...state.terminalTopologySeqByWorktree, From d11d78bd18b2407809fb972da9ada42f69aa2889 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 15:33:57 -0400 Subject: [PATCH 18/75] refactor(terminal): setLayout writes its one partition without a shared multi-write helper --- .../terminal-topology-commit.ts | 43 ++++++++----------- .../store/terminals/terminal-pending-panes.ts | 4 +- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index d16e7c4bff86..ea84a777fabd 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -17,10 +17,7 @@ import { import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { planTerminalLayoutSet } from './terminal-layout-set' -import { - assignWorkspaceSessionPartition, - type TerminalSessionPartition -} from './terminal-topology-membership' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and // the pane move; the close transform still lives in runtime/ and other writers move here later. @@ -61,12 +58,15 @@ export function setLayout( 'set_layout', () => { const planned = planTerminalLayoutSet(context.getSession(hostId), request) - return planned.session - ? { - value: planned.result, - rollback: writePartitions([{ hostId, session: planned.session }], context) - } - : { value: planned.result, persist: false } + if (!planned.session) { + return { value: planned.result, persist: false } + } + const rollback = writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + planned.session + ) + return { value: planned.result, rollback } }, (result) => (result.status === 'refused' ? result.reason : undefined) ) @@ -131,21 +131,6 @@ function writeRestorable(read: () => V, write: (value: V) => void, next: V): } } -/** Puts each planned partition in place; the returned rollback restores them. */ -function writePartitions( - sessions: readonly TerminalSessionPartition[], - context: TerminalTopologyCommitContext -): () => void { - const restores = sessions.map(({ hostId, session }) => - writeRestorable( - () => context.getSession(hostId), - (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), - session - ) - ) - return () => restores.forEach((restore) => restore()) -} - function commitLeafMove( request: TerminalLeafMoveRequest, context: TerminalTopologyCommitContext @@ -158,7 +143,13 @@ function commitLeafMove( if (planned.sessions.length === 0) { return { value: planned.result, persist: false } } - const restores = [writePartitions(planned.sessions, context)] + const restores = planned.sessions.map(({ hostId, session }) => + writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), + session + ) + ) const rekeyed = rekeyMovedLeafProfileRecords(state, request) if (rekeyed.ui) { restores.push( diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index cc0964bf7781..fe1c2c17f583 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -139,8 +139,8 @@ export function pendingAfterTerminalTopologySlice( } /** - * Holds `entry` over main's pushes while `send` commits it in main, and until the push holding - * main's reply is applied. Not superseded meanwhile: a later change to the same target owns it. + * Holds `entry` over main's pushes while `send` commits it in main, until the push holding main's + * reply is applied. A later change to the same target replaces `entry`; this reply then settles nothing. */ export function commitPendingTerminalChange( store: Pick, From 2a9ea62bdad76250b78080cf7a3928b7e3398550 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 15:49:38 -0400 Subject: [PATCH 19/75] fix(terminal): a pane drag-out writes only the worktree's home partition moveLeaf rewrote every local/ssh partition holding the source tab. A worktree has one home (the partition main's push projects), so the move now resolves it with the same rule setLayout uses and writes only there; an unresolved home writes nothing and replies home_unresolved. A stray copy in another partition is left as it is (D-core-3). --- src/main/ipc/pty/ipc/leaf-move.test.ts | 32 +++- src/main/ipc/pty/ipc/leaf-move.ts | 7 +- .../loading-store/pty-binding-persistence.ts | 15 +- .../terminal-layout-set.test.ts | 1 - ...erminal-leaf-move-concurrent-close.test.ts | 4 +- .../terminal-leaf-move.test.ts | 164 ++++++------------ .../terminal-topology/terminal-leaf-move.ts | 75 +++----- .../terminal-topology-commit.ts | 20 +-- .../terminal-topology-model.test.ts | 17 +- src/shared/terminal-leaf-move.ts | 7 +- 10 files changed, 150 insertions(+), 192 deletions(-) diff --git a/src/main/ipc/pty/ipc/leaf-move.test.ts b/src/main/ipc/pty/ipc/leaf-move.test.ts index 19c4796d3622..70c9ca9a1581 100644 --- a/src/main/ipc/pty/ipc/leaf-move.test.ts +++ b/src/main/ipc/pty/ipc/leaf-move.test.ts @@ -15,15 +15,17 @@ const request = { ptyId: 'pty-agent' } -function deps(result: TerminalLeafMoveResult) { +function deps(result: TerminalLeafMoveResult, homeHostId: string | null = 'ssh:target-1') { const rekeyWorkerTerminalResourcePaneKey = vi.fn(() => 1) + const moveTerminalLeafToNewTab = vi.fn(async () => result) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only this Store method. - const store = { moveTerminalLeafToNewTab: vi.fn(async () => result) } as unknown as Store - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only the orchestration DB accessor. + const store = { moveTerminalLeafToNewTab } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only the home resolver and the orchestration DB accessor. const runtime = { + getTerminalTopologyHomeHostId: () => homeHostId, getExistingOrchestrationDb: () => ({ rekeyWorkerTerminalResourcePaneKey }) } as unknown as OrcaRuntimeService - return { store, runtime, rekeyWorkerTerminalResourcePaneKey } + return { store, runtime, rekeyWorkerTerminalResourcePaneKey, moveTerminalLeafToNewTab } } afterEach(() => { @@ -70,6 +72,28 @@ describe('pty:moveLeafToNewTab', () => { expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() }) + it("writes only the worktree's home partition", async () => { + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const { store, runtime, moveTerminalLeafToNewTab } = deps({ status: 'moved', ptyId: null }) + + await commitLeafMoveAndRekey({ store, runtime }, request) + + expect(moveTerminalLeafToNewTab).toHaveBeenCalledExactlyOnceWith(request, 'ssh:target-1') + }) + + it('writes nothing when the home is unresolved', async () => { + const { store, runtime, moveTerminalLeafToNewTab } = deps( + { status: 'moved', ptyId: null }, + null + ) + + await expect(commitLeafMoveAndRekey({ store, runtime }, request)).resolves.toEqual({ + status: 'refused', + reason: 'home_unresolved' + }) + expect(moveTerminalLeafToNewTab).not.toHaveBeenCalled() + }) + it('replies with the publishSeq of the push that carries the move', async () => { vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) const handlers = new Map unknown>() diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts index f28e68a8c8d6..3cab0c6959f2 100644 --- a/src/main/ipc/pty/ipc/leaf-move.ts +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -17,7 +17,12 @@ export async function commitLeafMoveAndRekey( if (!deps.store) { return { status: 'not_held' } } - const result = await deps.store.moveTerminalLeafToNewTab(request) + // One home per worktree; an unresolved one is unverifiable, so nothing is written. + const hostId = deps.runtime?.getTerminalTopologyHomeHostId(request.worktreeId) + if (!hostId) { + return { status: 'refused', reason: 'home_unresolved' } + } + const result = await deps.store.moveTerminalLeafToNewTab(request, hostId) if (result.status !== 'moved') { return result } diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 16835f0b8ff0..1db1595d9fb5 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -216,12 +216,16 @@ export class PtyBindingPersistenceOperations { } /** - * Detach-to-new-tab, committed before the renderer mounts the target tab (STA-9259). It lives on - * the binding domain only for its runtime and partition access; the commit module owns the write. + * Detach-to-new-tab in the worktree's home partition, committed before the renderer mounts the + * target tab (STA-9259). It lives on the binding domain only for its runtime and partition + * access; the commit module owns the write. */ - moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { + moveTerminalLeafToNewTab( + request: TerminalLeafMoveRequest, + hostId: ExecutionHostId + ): Promise { const { runtime } = this[ptyBindingPersistenceOperationsContext] - return runtime.runDurableMutation(moveLeaf(request, topologyCommitContext(this))) + return runtime.runDurableMutation(moveLeaf(request, hostId, topologyCommitContext(this))) } /** A user's divider, direction or pane-order edit, written to the worktree's home partition. */ @@ -236,11 +240,10 @@ export class PtyBindingPersistenceOperations { function topologyCommitContext( owner: PtyBindingPersistenceOperations -): Parameters[1] { +): Parameters[2] { const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] return { state: runtime.state, - hostIds: () => sessions.getWorkspaceSessionHostIds(), getSession: (hostId) => sessions.getWorkspaceSession(hostId), markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) } diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.test.ts b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts index 1863692806a8..6538f74ca2f8 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-set.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-set.test.ts @@ -71,7 +71,6 @@ function profile(partitions: [ExecutionHostId, WorkspaceSessionState][]) { } const context: Parameters[2] = { state, - hostIds: () => partitions.map(([hostId]) => hostId), getSession, markDirty: (domain) => dirty.add(domain) } diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts index d5d0a89c4bcb..32bbd5c89faa 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts @@ -64,7 +64,7 @@ async function openMovedStore() { const store = openStore(dataFile) store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) await seedSplitSource(store) - await expect(store.moveTerminalLeafToNewTab(request)).resolves.toMatchObject({ + await expect(store.moveTerminalLeafToNewTab(request, 'local')).resolves.toMatchObject({ status: 'moved' }) return { dataFile, store } @@ -108,7 +108,7 @@ describe('a close that lands while main commits the move', () => { it('grafts a late spawn result for the moved leaf back into its source tab', async () => { const store = openStore(newDataFile()) await seedSplitSource(store) - await store.moveTerminalLeafToNewTab({ ...request, ptyId: null }) + await store.moveTerminalLeafToNewTab({ ...request, ptyId: null }, 'local') await store.persistPtyBinding({ worktreeId: WT, tabId: SOURCE, diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts index 14ff2d7b764a..adc5100b67ba 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts @@ -57,7 +57,7 @@ describe('moving a pane to a new tab', () => { }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') ).resolves.toEqual({ status: 'moved', ptyId: 'pty-agent' }) const session = store.getWorkspaceSession() @@ -85,7 +85,7 @@ describe('moving a pane to a new tab', () => { expect(ownersOf(session, 'pty-agent')).toEqual([TO]) }) - it('re-keys the SSH lease and moves within the partition that holds the tab', async () => { + it('re-keys the SSH lease and moves within the SSH home partition', async () => { const store = openStore(newDataFile()) const hostId = toSshExecutionHostId('ssh-1') await seedSplitSource(store, hostId) @@ -99,7 +99,7 @@ describe('moving a pane to a new tab', () => { }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, hostId) ).resolves.toMatchObject({ status: 'moved' }) expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) @@ -112,7 +112,7 @@ describe('moving a pane to a new tab', () => { it('reports a leaf main never held instead of inventing one', async () => { const store = openStore(newDataFile()) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') ).resolves.toEqual({ status: 'not_held' }) expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() }) @@ -121,44 +121,42 @@ describe('moving a pane to a new tab', () => { const store = openStore(newDataFile()) await seedSplitSource(store) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-other' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-other' }, 'local') ).resolves.toEqual({ status: 'refused', reason: 'pty_mismatch' }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, targetTabId: SOURCE, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab( + { ...moveRequest, targetTabId: SOURCE, ptyId: 'pty-agent' }, + 'local' + ) ).resolves.toEqual({ status: 'refused', reason: 'target_tab_exists' }) expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([SOURCE]) }) it('moves a leaf that a stray layout of a removed tab still names', () => { const planned = planTerminalLeafMove( - [ - { - hostId: 'local', - session: { - activeRepoId: 'repo-1', - activeWorktreeId: WT, - activeTabId: SOURCE, - tabsByWorktree: { [WT]: [makeTerminalTab({ id: SOURCE, worktreeId: WT })] }, - terminalLayoutsByTabId: { - [SOURCE]: { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEFT }, - second: { type: 'leaf', leafId: MOVED } - }, - activeLeafId: LEFT, - expandedLeafId: null - }, - 'tab-removed': { - root: { type: 'leaf', leafId: MOVED }, - activeLeafId: MOVED, - expandedLeafId: null - } - } + { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: SOURCE, + tabsByWorktree: { [WT]: [makeTerminalTab({ id: SOURCE, worktreeId: WT })] }, + terminalLayoutsByTabId: { + [SOURCE]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: MOVED } + }, + activeLeafId: LEFT, + expandedLeafId: null + }, + 'tab-removed': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null } } - ], + }, { ...moveRequest, ptyId: 'pty-agent' } ) expect(planned.result).toEqual({ status: 'moved', ptyId: 'pty-agent' }) @@ -178,9 +176,9 @@ describe('moving a pane to a new tab', () => { await seedSplitSource(store) records.length = 0 const request = { ...moveRequest, ptyId: 'pty-agent' } - await store.moveTerminalLeafToNewTab(request) - await store.moveTerminalLeafToNewTab(request) - await store.moveTerminalLeafToNewTab({ ...request, targetTabId: 'tab-other' }) + await store.moveTerminalLeafToNewTab(request, 'local') + await store.moveTerminalLeafToNewTab(request, 'local') + await store.moveTerminalLeafToNewTab({ ...request, targetTabId: 'tab-other' }, 'local') const spans = records.filter((record) => record.name === 'persistence.terminal-topology') expect(spans.map((span) => span.attributes)).toEqual([ @@ -205,96 +203,40 @@ describe('moving a pane to a new tab', () => { }) }) -// After a restart the relay reattach writes the SSH pane into `local` as well as `ssh:`; a move -// that left either copy behind refused the moved pane and the next relay reattach. -describe('moving an SSH pane held by both partitions', () => { - it('moves every copy, so the target reattach and the next relay reattach both bind', async () => { +// A relay reattach can leave a stray copy of an SSH pane in `local`. Main writes only the worktree's +// home; the stray copy is neither moved nor repaired (D-core-2, D-core-3). +describe('moving an SSH pane whose stray copy sits in local', () => { + it('moves the home copy only and leaves the stray copy as it was', async () => { const store = openStore(newDataFile()) vi.spyOn(console, 'warn').mockImplementation(() => {}) const hostId = toSshExecutionHostId('ssh-1') await seedSplitSource(store, hostId) - const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } - expect( - await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) - ).toBe(true) + await seedSplitSource(store) + const strayCopy = store.getWorkspaceSession() await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) - ).resolves.toMatchObject({ status: 'moved' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, hostId) + ).resolves.toEqual({ status: 'moved', ptyId: 'pty-agent' }) - expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect(store.getWorkspaceSession()).toEqual(strayCopy) expect( - await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) - ).toBe(true) - expect( - await store.persistPtyBinding({ - ...relay, - tabId: TARGET, - origin: 'relay_reattach', - mayReviveRetiredSurface: false - }) - ).toBe(true) - }) - - // Relay reattach into local, move, target reattach in ssh:, next-start relay reattach. - it('keeps one holder per partition across a restart, and the next relay reattach binds', async () => { - const dataFile = newDataFile() - const store = openStore(dataFile) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) - const hostId = toSshExecutionHostId('ssh-1') - await seedSplitSource(store, hostId) - const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } - await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) - await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) - expect( - await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + await store.persistPtyBinding( + { worktreeId: WT, tabId: TARGET, leafId: MOVED, ptyId: 'pty-agent', origin: 'reattach' }, + hostId + ) ).toBe(true) - store.flush() - - const restarted = openStore(dataFile) - expect( - await restarted.persistPtyBinding({ - ...relay, - tabId: TARGET, - origin: 'relay_reattach', - mayReviveRetiredSurface: false - }) - ).toBe(true) - for (const session of [ - restarted.getWorkspaceSession(), - restarted.getWorkspaceSession(hostId) - ]) { - expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) - expect(ownersOf(session, 'pty-agent')).toEqual([TO]) - } }) - it('follows the live PTY when an SSH respawn bound one partition before the other', async () => { + it('refuses a pane only the stray copy holds', async () => { const store = openStore(newDataFile()) - vi.spyOn(console, 'warn').mockImplementation(() => {}) const hostId = toSshExecutionHostId('ssh-1') - await seedSplitSource(store, hostId) - const relay = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, incarnationId: 'inc-1' } - await store.persistPtyBinding({ ...relay, ptyId: 'pty-agent', origin: 'relay_reattach' }) - await store.persistPtyBinding( - { ...relay, ptyId: 'pty-respawn', incarnationId: 'inc-2' }, - hostId - ) + await seedSplitSource(store) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-respawn' }) - ).resolves.toEqual({ status: 'moved', ptyId: 'pty-respawn' }) - - for (const session of [store.getWorkspaceSession(), store.getWorkspaceSession(hostId)]) { - expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) - expect(session.terminalLayoutsByTabId[TARGET]?.ptyIdsByLeafId).toEqual({ - [MOVED]: 'pty-respawn' - }) - } - expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[TO]).toBe('inc-2') - expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[TO]).toBeUndefined() + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, hostId) + ).resolves.toEqual({ status: 'not_held' }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([SOURCE]) }) it('refuses a move while another tab already holds the leaf', async () => { @@ -321,7 +263,7 @@ describe('moving an SSH pane held by both partitions', () => { }) await expect( - store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') ).resolves.toEqual({ status: 'refused', reason: 'leaf_in_other_tab' }) }) }) @@ -338,7 +280,7 @@ describe('STA-9259 move sequence', () => { await seedSplitSource(store) const preMoveRendererSnapshot = structuredClone(store.getWorkspaceSession()) - await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }) + await store.moveTerminalLeafToNewTab({ ...moveRequest, ptyId: 'pty-agent' }, 'local') // The moved pane mounts in the target tab and reattaches with its stable-owner fence. const reattached = await store.persistPtyBinding({ worktreeId: WT, diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 627e4a705e72..dbe99e20e5f3 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -10,16 +10,11 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { retireLeavesFromTerminalLayout } from '../../runtime/mobile-session-terminal-retirement' import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' -import { - advanceTerminalTopologyRevision, - isTerminalOwnerPartition, - type TerminalSessionPartition -} from './terminal-topology-membership' +import { advanceTerminalTopologyRevision } from './terminal-topology-membership' type PlannedTerminalLeafMove = { result: TerminalLeafMoveResult - /** Every partition the plan rewrote; empty when nothing changes. */ - sessions: TerminalSessionPartition[] + session: WorkspaceSessionState | null } function moveRecordKey( @@ -52,7 +47,8 @@ function hasTabId(session: WorkspaceSessionState, tabId: string): boolean { ) } -type SourceHolder = TerminalSessionPartition & { +type SourceHolder = { + session: WorkspaceSessionState sourceTab: TerminalTab sourceLayout: TerminalLayoutSnapshot } @@ -65,7 +61,6 @@ function moveLeafInPartition( const { worktreeId, sourceTabId, targetTabId, leafId } = request const tabs = session.tabsByWorktree?.[worktreeId] ?? [] const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) - const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId] const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId])) const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {}) const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({ @@ -120,11 +115,10 @@ function moveLeafInPartition( tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: [...nextTabs, targetTab] }, terminalLayoutsByTabId, ...(session.remoteSessionIdsByTabId ? { remoteSessionIdsByTabId } : {}), - // A stale copy's incarnation belongs to the PTY it no longer names. terminalPtyIncarnationsByPaneKey: moveRecordKey( session.terminalPtyIncarnationsByPaneKey, fromPaneKey, - boundHere && boundHere !== ptyId ? null : toPaneKey + toPaneKey ), sleepingAgentSessionsByPaneKey: moveRecordKey( session.sleepingAgentSessionsByPaneKey, @@ -138,62 +132,47 @@ function moveLeafInPartition( } /** - * Moves one leaf and its binding into a new tab in a single session write (STA-9259). The leaf - * id and the PTY are kept; only the tab half of the pane key changes, so every pane-keyed record - * follows it here instead of being rebuilt later by a renderer save that main's membership rebase - * would discard. Every owner partition holding the pane moves together: a relay reattach writes an - * SSH pane into `local` as well as `ssh:`, and a copy left behind refuses the moved pane's bind. + * Moves one leaf and its binding into a new tab in the worktree's home partition, in a single + * session write (STA-9259). The leaf id and the PTY are kept; only the tab half of the pane key + * changes, so every pane-keyed record follows it here instead of being rebuilt later by a renderer + * save that main's membership rebase would discard. `session` is null when nothing changes. */ export function planTerminalLeafMove( - partitions: readonly TerminalSessionPartition[], + home: WorkspaceSessionState, request: TerminalLeafMoveRequest ): PlannedTerminalLeafMove { const { worktreeId, sourceTabId, leafId } = request const refuse = ( reason: Extract['reason'] - ): PlannedTerminalLeafMove => ({ result: { status: 'refused', reason }, sessions: [] }) - const owners = partitions.filter(({ hostId }) => isTerminalOwnerPartition(hostId)) - if (partitions.some(({ session }) => hasTabId(session, request.targetTabId))) { + ): PlannedTerminalLeafMove => ({ result: { status: 'refused', reason }, session: null }) + if (hasTabId(home, request.targetTabId)) { return refuse('target_tab_exists') } // A layout left behind by a removed tab row owns nothing. - const leafElsewhere = owners.some(({ session }) => { - const tabIds = liveTabIds(session) - return Object.entries(session.terminalLayoutsByTabId ?? {}).some( - ([tabId, layout]) => - tabId !== sourceTabId && - tabIds.has(tabId) && - layoutContainsLeafId(layout?.root ?? null, leafId) - ) - }) + const tabIds = liveTabIds(home) + const leafElsewhere = Object.entries(home.terminalLayoutsByTabId ?? {}).some( + ([tabId, layout]) => + tabId !== sourceTabId && + tabIds.has(tabId) && + layoutContainsLeafId(layout?.root ?? null, leafId) + ) if (leafElsewhere) { return refuse('leaf_in_other_tab') } - const holders = owners.flatMap(({ hostId, session }) => { - const sourceTab = session.tabsByWorktree?.[worktreeId]?.find((tab) => tab.id === sourceTabId) - const sourceLayout = session.terminalLayoutsByTabId?.[sourceTabId] - return sourceTab && sourceLayout && layoutContainsLeafId(sourceLayout.root, leafId) - ? [{ hostId, session, sourceTab, sourceLayout }] - : [] - }) + const sourceTab = home.tabsByWorktree?.[worktreeId]?.find((tab) => tab.id === sourceTabId) + const sourceLayout = home.terminalLayoutsByTabId?.[sourceTabId] // Main never saw this leaf, so no binding of it can be duplicated here. - if (holders.length === 0) { - return { result: { status: 'not_held' }, sessions: [] } + if (!sourceTab || !sourceLayout || !layoutContainsLeafId(sourceLayout.root, leafId)) { + return { result: { status: 'not_held' }, session: null } } - const boundPtyIds = new Set( - holders.flatMap(({ sourceLayout }) => sourceLayout.ptyIdsByLeafId?.[leafId] ?? []) - ) - // The renderer's live PTY id wins: after an SSH respawn one copy can still name the old PTY. - const ptyId = request.ptyId ?? (boundPtyIds.size === 1 ? [...boundPtyIds][0] : null) - if (boundPtyIds.size > 0 && !(ptyId && boundPtyIds.has(ptyId))) { + const boundPtyId = sourceLayout.ptyIdsByLeafId?.[leafId] + const ptyId = request.ptyId ?? boundPtyId ?? null + if (boundPtyId && ptyId !== boundPtyId) { return refuse('pty_mismatch') } return { result: { status: 'moved', ptyId }, - sessions: holders.map((holder) => ({ - hostId: holder.hostId, - session: moveLeafInPartition(holder, request, ptyId) - })) + session: moveLeafInPartition({ session: home, sourceTab, sourceLayout }, request, ptyId) } } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index ea84a777fabd..12b7ec25bfed 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -36,14 +36,15 @@ export function closeLeafOrTab( ) } -/** Moves a leaf, its binding and its pane-keyed records into a new tab in one durable mutation. */ +/** Moves a leaf, its binding and its pane-keyed records into a new tab in `hostId`, the worktree's home. */ export function moveLeaf( request: TerminalLeafMoveRequest, + hostId: ExecutionHostId, context: TerminalTopologyCommitContext ): () => DurableProfileStateMutation { return traced( 'move_leaf', - () => commitLeafMove(request, context), + () => commitLeafMove(request, hostId, context), (result) => (result.status === 'refused' ? result.reason : undefined) ) } @@ -113,7 +114,6 @@ type TopologyState = Pick< type TerminalTopologyCommitContext = { state: TopologyState - hostIds: () => ExecutionHostId[] getSession: (hostId: ExecutionHostId) => WorkspaceSessionState markDirty: ( domain: 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' @@ -133,23 +133,21 @@ function writeRestorable(read: () => V, write: (value: V) => void, next: V): function commitLeafMove( request: TerminalLeafMoveRequest, + hostId: ExecutionHostId, context: TerminalTopologyCommitContext ): DurableProfileStateMutation { const { state } = context - const planned = planTerminalLeafMove( - context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), - request - ) - if (planned.sessions.length === 0) { + const planned = planTerminalLeafMove(context.getSession(hostId), request) + if (!planned.session) { return { value: planned.result, persist: false } } - const restores = planned.sessions.map(({ hostId, session }) => + const restores = [ writeRestorable( () => context.getSession(hostId), (value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)), - session + planned.session ) - ) + ] const rekeyed = rekeyMovedLeafProfileRecords(state, request) if (rekeyed.ui) { restores.push( diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index 04a48908298f..e87909a26bdb 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -406,13 +406,16 @@ async function runSeed(seed: number): Promise { const pane = source.leaves.get(leafId)! log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`) await expect( - store.moveTerminalLeafToNewTab({ - worktreeId: source.worktreeId, - sourceTabId, - targetTabId, - leafId, - ptyId: pane.ptyId ?? null - }) + store.moveTerminalLeafToNewTab( + { + worktreeId: source.worktreeId, + sourceTabId, + targetTabId, + leafId, + ptyId: pane.ptyId ?? null + }, + 'local' + ) ).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null }) // The window then shows what main moved, as the drag-out does today. const layout = window.session.terminalLayoutsByTabId[sourceTabId]! diff --git a/src/shared/terminal-leaf-move.ts b/src/shared/terminal-leaf-move.ts index ddf3a8236194..2129bac8a164 100644 --- a/src/shared/terminal-leaf-move.ts +++ b/src/shared/terminal-leaf-move.ts @@ -17,7 +17,12 @@ export type TerminalLeafMoveResult = | { status: 'not_held' } | { status: 'refused' - reason: 'invalid_request' | 'target_tab_exists' | 'pty_mismatch' | 'leaf_in_other_tab' + reason: + | 'invalid_request' + | 'home_unresolved' + | 'target_tab_exists' + | 'pty_mismatch' + | 'leaf_in_other_tab' } /** The moved pane's key before and after; only valid requests reach here. */ From 791ba2718fb0964a7f953cf8ac7fcb52c261b50e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 15:35:28 -0400 Subject: [PATCH 20/75] refactor(terminal): sleeping-agent records commit to main (core C5c) The window sends main its own sleeping-record changes (sleep, wake) through two batched IPCs; main keeps each record beside its tab in every owner partition that holds it, and its next topology push carries the result. The quit capture rides the synchronous before-unload stage, committed before the sessions are staged. Mirror applies, tab closes and pane moves send nothing: main already holds those. Co-Authored-By: Claude --- src/main/ipc/renderer-shutdown-checkpoint.ts | 6 + src/main/ipc/session.ts | 41 ++++ .../terminal-sleep-wake-commit.test.ts | 210 ++++++++++++++++++ .../terminal-topology-commit.ts | 42 +++- src/preload/api/app-api.ts | 3 + src/preload/api/session-bridge.ts | 2 + src/preload/api/workspace-session-api.ts | 5 + .../app-shell/use-app-session-persistence.ts | 8 +- .../slices/agent-status-authority-actions.ts | 4 +- .../slices/agent-status-launch-actions.ts | 4 +- .../store/slices/agent-status-live-actions.ts | 4 +- .../agent-status-provider-session-actions.ts | 4 +- .../slices/agent-status-recovery-actions.ts | 10 +- .../src/store/slices/agent-status-runtime.ts | 9 +- .../slices/store-cascades-test-harness.ts | 5 + .../store-sleep-pane-hibernation.test.ts | 7 + .../terminals/terminal-pane-hibernation.ts | 55 ++--- .../terminals/terminal-shutdown-state.ts | 35 +-- .../terminal-sleeping-record-commits.test.ts | 131 +++++++++++ .../terminal-sleeping-record-commits.ts | 66 ++++++ .../preload-api/web-workspace-session-api.ts | 3 + src/shared/terminal-topology-slice.ts | 6 + 22 files changed, 602 insertions(+), 58 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 82fcbee0d4e2..941540431e20 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -1,13 +1,17 @@ import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../shared/execution-host' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { TerminalSleepingRecordChanges } from '../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { Store } from '../persistence' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { commitTerminalSleepingRecords } from './session' type StageBeforeUnloadSyncArgs = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial + /** Changes the window had not sent yet, including the quit capture. */ + sleepingRecords?: TerminalSleepingRecordChanges } export type ShutdownCheckpointResult = { ok: boolean } @@ -49,6 +53,8 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => { let ok = true try { + // Before the stage, in the same handler, so the quit capture can't be lost to ordering. + commitTerminalSleepingRecords(store, args.sleepingRecords) for (const { state, hostId } of args.sessions) { if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { store.stageWorkspaceSessionBeforeUnload(state, hostId) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 1925de7ac8bd..25112683c95d 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -8,6 +8,39 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { TerminalSleepingRecordChanges } from '../../shared/terminal-topology-slice' +import { sleepingAgentSessionsByPaneKeySchema } from '../../shared/workspace-session-sleeping-agents' +import { sleepLeaf, wakeLeaf } from '../persistence/terminal-topology/terminal-topology-commit' +import { isTerminalOwnerPartition } from '../persistence/terminal-topology/terminal-topology-membership' + +/** Commits a window's own sleeping-agent record changes; main's next topology push carries them. */ +export function commitTerminalSleepingRecords( + store: Store, + changes: { sleep?: unknown; wake?: unknown } | undefined +): void { + const parsed: TerminalSleepingRecordChanges = { + sleep: sleepingAgentSessionsByPaneKeySchema.safeParse(changes?.sleep).data ?? {}, + wake: Array.isArray(changes?.wake) + ? changes.wake.filter((paneKey) => typeof paneKey === 'string') + : [] + } + if (Object.keys(parsed.sleep).length === 0 && parsed.wake.length === 0) { + return + } + for (const hostId of store.getWorkspaceSessionHostIds()) { + if (!isTerminalOwnerPartition(hostId) || isFrozenOrcadSourceSessionPartition(store, hostId)) { + continue + } + const session = store.getWorkspaceSession(hostId) + const next = sleepLeaf(wakeLeaf(session, parsed.wake), parsed.sleep) + if (next !== session) { + store.patchWorkspaceSession( + { sleepingAgentSessionsByPaneKey: next.sleepingAgentSessionsByPaneKey }, + hostId + ) + } + } +} export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void { // Why: renderer saves would change a fenced host's frozen source partition. @@ -66,6 +99,14 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic } ) + ipcMain.handle('session:terminal-sleep-leaves', (_event, sleep: unknown) => { + commitTerminalSleepingRecords(store, { sleep }) + }) + + ipcMain.handle('session:terminal-wake-leaves', (_event, wake: unknown) => { + commitTerminalSleepingRecords(store, { wake }) + }) + // Pull-after-listen: a window subscribes to pushes first, then reads what it missed. ipcMain.handle('session:get-terminal-topology-slices', () => runtime.getTerminalTopologySlices()) diff --git a/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts new file mode 100644 index 000000000000..599347119e0a --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts @@ -0,0 +1,210 @@ +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 type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Store } from '../loading-store/store' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' +import { sleepLeaf, wakeLeaf } from './terminal-topology-commit' + +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ + syncHandlers: new Map void>(), + invokeHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: (channel: string, handler: (event: { returnValue?: unknown }, args: unknown) => void) => + syncHandlers.set(channel, handler), + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => + invokeHandlers.set(channel, handler) + } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +import { registerRendererShutdownCheckpointHandler } from '../../ipc/renderer-shutdown-checkpoint' +import { registerSessionHandlers } from '../../ipc/session' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeNotifier } from '../../runtime/runtime-notifier-contract' + +const TAB = 'tab-agent' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB}:${LEAF}` +const OTHER_PANE_KEY = `tab-elsewhere:${LEAF}` + +const directories: string[] = [] +const stores: Store[] = [] + +afterEach(async () => { + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + syncHandlers.clear() + invokeHandlers.clear() +}) + +function record(paneKey: string, capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish the task', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin: 'worktree-sleep' + } +} + +function sessionWithAgentTab(session: WorkspaceSessionState): WorkspaceSessionState { + return { + ...session, + tabsByWorktree: { + [WORKTREE]: [ + { + id: TAB, + ptyId: null, + worktreeId: WORKTREE, + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null } + } + } +} + +function notifier(pushes: TerminalTopologySlice[]): RuntimeNotifier { + const ignore = (): void => {} + return { + worktreesChanged: ignore, + reposChanged: ignore, + activateWorktree: ignore, + createTerminal: ignore, + splitTerminal: ignore, + renameTerminal: ignore, + focusTerminal: ignore, + closeTerminal: ignore, + sleepWorktree: ignore, + terminalFitOverrideChanged: ignore, + terminalDriverChanged: ignore, + terminalTopologyChanged: (slice) => pushes.push(slice) + } +} + +/** A real store holding the agent's tab, main's IPC handlers, and the pushes main publishes. */ +async function setup() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-wake-commit-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + stores.push(store) + store.setWorkspaceSession(sessionWithAgentTab(store.getWorkspaceSession())) + const runtime = new OrcaRuntimeService(store) + const pushes: TerminalTopologySlice[] = [] + runtime.setNotifier(notifier(pushes)) + runtime.getTerminalTopologySlices() + registerSessionHandlers(store, runtime) + registerRendererShutdownCheckpointHandler(store) + const invoke = (channel: string, args: unknown): unknown => invokeHandlers.get(channel)!({}, args) + const nextPush = async (): Promise => { + pushes.length = 0 + await Promise.resolve() + return pushes.find((slice) => slice.worktreeId === WORKTREE) + } + return { directory, store, invoke, nextPush } +} + +describe('sleepLeaf and wakeLeaf', () => { + const session = sessionWithAgentTab(getDefaultWorkspaceSession()) + + it('a partition takes only the records whose tab it holds', () => { + const next = sleepLeaf(session, { + [PANE_KEY]: record(PANE_KEY, 1), + [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) + }) + expect(next.sleepingAgentSessionsByPaneKey).toEqual({ [PANE_KEY]: record(PANE_KEY, 1) }) + expect(sleepLeaf(session, { [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) })).toBe(session) + }) + + it('an unchanged record or an absent wake leaves the session as it was', () => { + const held = sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 1) }) + expect(sleepLeaf(held, { [PANE_KEY]: record(PANE_KEY, 1) })).toBe(held) + expect(wakeLeaf(held, [OTHER_PANE_KEY])).toBe(held) + expect(wakeLeaf(held, [PANE_KEY]).sleepingAgentSessionsByPaneKey).toEqual({}) + }) +}) + +describe('sleep and wake commits', () => { + it('a sleep commit arrives in the next slice, and a wake removes it', async () => { + const { invoke, nextPush } = await setup() + + invoke('session:terminal-sleep-leaves', { [PANE_KEY]: record(PANE_KEY, 1) }) + expect((await nextPush())?.sleeping).toEqual({ [PANE_KEY]: record(PANE_KEY, 1) }) + + invoke('session:terminal-wake-leaves', [PANE_KEY]) + expect((await nextPush())?.sleeping).toEqual({}) + }) + + it('drops a malformed record and a record whose tab main does not hold', async () => { + const { store, invoke } = await setup() + + invoke('session:terminal-sleep-leaves', { + [PANE_KEY]: { ...record(PANE_KEY, 1), agent: 'not-an-agent' }, + [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) + }) + + expect(store.getWorkspaceSession().sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + }) + + it('the quit stage commits sleep records before staging the session', async () => { + const { directory, store } = await setup() + const quitRecord = record(PANE_KEY, 2) + const stage = store.stageWorkspaceSessionBeforeUnload.bind(store) + let heldAtStage: SleepingAgentSessionRecord | undefined + vi.spyOn(store, 'stageWorkspaceSessionBeforeUnload').mockImplementation((...args) => { + heldAtStage = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] + stage(...args) + }) + // The window's session as it stages it today, quit capture included. + const window = structuredClone(store.getWorkspaceSession()) + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: quitRecord } + const event: { returnValue?: unknown } = {} + + syncHandlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: window }], + ui: {}, + sleepingRecords: { sleep: { [PANE_KEY]: quitRecord }, wake: [] } + }) + + expect(event.returnValue).toEqual({ ok: true }) + expect(heldAtStage).toEqual(quitRecord) + stores.splice(stores.indexOf(store), 1) + const relaunched = await reopenTopologyStore(store, directory) + stores.push(relaunched) + expect(relaunched.getWorkspaceSession().sleepingAgentSessionsByPaneKey).toEqual({ + [PANE_KEY]: quitRecord + }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dc1b562a51da..20412c4f0dd0 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,5 +1,8 @@ +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { ExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { structuralValuesEqual } from '../../../shared/structural-value-equality' import type { TerminalLeafMoveRequest, TerminalLeafMoveResult @@ -14,8 +17,9 @@ import type { DurableProfileStateMutation } from '../loading-store/store-runtime import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' -// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and -// the pane move; the close transform still lives in runtime/ and other writers move here later. +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings, sleeping agents). +// Wraps the close and the pane move; the close transform still lives in runtime/ and other writers +// move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' @@ -43,6 +47,40 @@ export function moveLeaf( ) } +/** A sleeping agent's record lives beside its tab, in each partition that holds the tab. */ +export function sleepLeaf( + session: WorkspaceSessionState, + records: Record +): WorkspaceSessionState { + const current = session.sleepingAgentSessionsByPaneKey ?? {} + const held = Object.entries(records).filter( + ([paneKey, record]) => + session.tabsByWorktree?.[record.worktreeId]?.some( + (tab) => tab.id === parsePaneKey(paneKey)?.tabId + ) && !structuralValuesEqual(current[paneKey], record) + ) + return held.length > 0 + ? { ...session, sleepingAgentSessionsByPaneKey: { ...current, ...Object.fromEntries(held) } } + : session +} + +export function wakeLeaf( + session: WorkspaceSessionState, + paneKeys: string[] +): WorkspaceSessionState { + const current = session.sleepingAgentSessionsByPaneKey ?? {} + if (!paneKeys.some((paneKey) => Object.hasOwn(current, paneKey))) { + return session + } + const woken = new Set(paneKeys) + return { + ...session, + sleepingAgentSessionsByPaneKey: Object.fromEntries( + Object.entries(current).filter(([paneKey]) => !woken.has(paneKey)) + ) + } +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 55f82e23568a..584d80dbdf66 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -8,6 +8,7 @@ import type { import type { MacCapturedDigitRowChord } from '../../shared/macos-symbolic-hotkeys' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { TerminalSleepingRecordChanges } from '../../shared/terminal-topology-slice' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { KeyboardLayoutSnapshot } from '../../shared/keyboard-layout-snapshot' @@ -31,6 +32,8 @@ export type AppApi = { stageBeforeUnloadSync: (args: { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial + /** Committed before the sessions, so the quit capture can't be lost to ordering. */ + sleepingRecords?: TerminalSleepingRecordChanges }) => void /** Resolves once the last staged checkpoint is durably written; rejects if that * write failed, so a reload/restart can abort instead of losing the snapshot. */ diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 8516ba992e88..5336b4a0b1bc 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -9,6 +9,8 @@ export const sessionApi = { set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), + sleepTerminalLeaves: (records) => ipcRenderer.invoke('session:terminal-sleep-leaves', records), + wakeTerminalLeaves: (paneKeys) => ipcRenderer.invoke('session:terminal-wake-leaves', paneKeys), getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), onTerminalTopologyChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 97e97d3f27f9..75f0b7b6e11c 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -6,6 +6,7 @@ import type { import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { + TerminalSleepingRecordChanges, TerminalTopologyReply, TerminalTopologySlice } from '../../shared/terminal-topology-slice' @@ -30,6 +31,10 @@ export type WorkspaceSessionApi = { target: TerminalSurfaceCloseTarget reason?: 'user' | 'cleanup' }) => Promise + /** Commits sleeping-agent records this window wrote, beside their tabs in main. */ + sleepTerminalLeaves: (records: TerminalSleepingRecordChanges['sleep']) => Promise + /** Drops sleeping-agent records this window removed. */ + wakeTerminalLeaves: (paneKeys: TerminalSleepingRecordChanges['wake']) => Promise /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ getTerminalTopologySlices: () => Promise onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6574438265de..31b38cfa9cfe 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -41,6 +41,7 @@ import type { AppState } from '../store/types' import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' +import { takeSleepingRecordChanges } from '../store/terminals/terminal-sleeping-record-commits' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 @@ -245,7 +246,12 @@ export function useAppSessionPersistence(): void { // the user could only complete with SIGKILL, which loses strictly more (#15352). isDegradableShutdownInProgress: () => isIntentionalAppRestartInProgress() || isWindowCloseCheckpointInProgress(), - stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args) + // The quit capture's sleeping records ride the synchronous stage, not a later async send. + stageBeforeUnloadSync: (args) => + window.api.app.stageBeforeUnloadSync({ + ...args, + sleepingRecords: takeSleepingRecordChanges() + }) }) const shutdownCheckpoint = createShutdownCheckpointGuard( shutdownCheckpointPersist.run, diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index 2001f6587523..59b40459c128 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -27,7 +27,7 @@ export function createAgentStatusAuthorityActions( | 'restoreAgentPaneAuthority' | 'transferAgentPaneAuthority' > { - const { get, set, freshness } = runtime + const { get, set, setCommittingSleepingRecords, freshness } = runtime return { scheduleAgentStatusFreshness: () => freshness.schedule(), @@ -50,7 +50,7 @@ export function createAgentStatusAuthorityActions( rendererAgentStatusObservations.forget(key) } let hadLive = false - set((s) => { + setCommittingSleepingRecords((s) => { const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) hadLive = retiredLivePaneKeys.length > 0 let nextRetentionSuppressedPaneKeys = removePaneKeys( diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts index 4d13ea57d675..c32ebf355197 100644 --- a/src/renderer/src/store/slices/agent-status-launch-actions.ts +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -16,10 +16,10 @@ export function createAgentStatusLaunchActions( AgentStatusSlice, 'registerAgentLaunchConfig' | 'getAgentLaunchConfigForStatusEntry' | 'clearAgentLaunchConfig' > { - const { get, set } = runtime + const { get, set, setCommittingSleepingRecords } = runtime return { registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => { - set((s) => { + setCommittingSleepingRecords((s) => { const copiedLaunchConfig = copyLaunchConfig(launchConfig) const nextRegistryEntry: AgentLaunchConfigRegistryEntry = { launchConfig: copiedLaunchConfig, diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index 4bfbe8701305..87198cf72193 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -35,7 +35,7 @@ export function createAgentStatusLiveActions( ): Pick { const { get, - set, + setCommittingSleepingRecords, applyGeneratedTabTitleUpdate, freshness, requestFreshness, @@ -68,7 +68,7 @@ export function createAgentStatusLiveActions( } let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null let liveEntryDelta: FreshnessLiveEntryDelta | null = null - set((state) => { + setCommittingSleepingRecords((state) => { const retirement = state.recentlyRetiredAgentStatusPaneKeys[paneKey] if ( (retirement !== undefined && diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 8deddac21be8..c2c22dfa5bc7 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -26,7 +26,7 @@ import { agentVerdictFields } from '../../../../shared/agent-main-agent-verdict' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime ): Pick { - const { get, set, requestFreshness } = runtime + const { get, setCommittingSleepingRecords, requestFreshness } = runtime return { recordAgentProviderSession: ( paneKey: string, @@ -49,7 +49,7 @@ export function createAgentStatusProviderSessionActions( return } let removedLiveStatus = false - set((s) => { + setCommittingSleepingRecords((s) => { const existingStatus = s.agentStatusByPaneKey[paneKey] const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] if ( diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts index 0e256834a9f7..0b24563457ba 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -25,10 +25,10 @@ export function createAgentStatusRecoveryActions( | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { - const { set, clearSleepingAgentSessionsByPaneKey } = runtime + const { setCommittingSleepingRecords, clearSleepingAgentSessionsByPaneKey } = runtime return { captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => { - set((s) => { + setCommittingSleepingRecords((s) => { const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { paneKeys, captureMode: 'manual-worktree-sleep' @@ -52,7 +52,7 @@ export function createAgentStatusRecoveryActions( }, captureAllSleepingAgentSessions: (mode) => { - set((s) => { + setCommittingSleepingRecords((s) => { const capturedAt = Date.now() const origin = mode === 'quit' ? ('quit' as const) : ('live' as const) const next: Record = { @@ -109,7 +109,7 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSessionsByPaneKey, clearSleepingAgentSessionsByWorktree: (worktreeId) => { - set((s) => { + setCommittingSleepingRecords((s) => { let changed = false const next: Record = {} const removed: string[] = [] @@ -139,7 +139,7 @@ export function createAgentStatusRecoveryActions( }, pruneSleepingAgentSessions: (validWorktreeIds) => { - set((s) => { + setCommittingSleepingRecords((s) => { let changed = false const next: Record = {} const removed: string[] = [] diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts index 0716fa633aa2..35cea24a4eea 100644 --- a/src/renderer/src/store/slices/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -4,6 +4,7 @@ import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agen import type { AgentStatusSlice } from './agent-status-slice-contract' import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import { commitSleepingRecordWrite } from '../terminals/terminal-sleeping-record-commits' export type AgentStatusStateUpdate = | AppState @@ -13,6 +14,8 @@ export type AgentStatusStateUpdate = export type AgentStatusRuntime = { get: () => AppState set: (update: AgentStatusStateUpdate) => void + /** `set`, then commits this window's sleeping-record change to main. */ + setCommittingSleepingRecords: (update: AgentStatusStateUpdate) => void runAfterCommit: (effect: () => void) => void applyGeneratedTabTitleUpdate: (update: GeneratedTabTitleUpdate) => void requestFreshness: (acceptedInBatch: boolean) => void @@ -61,6 +64,9 @@ export function createAgentStatusRuntime( Object.assign(staged, nextState) } + const setCommittingSleepingRecords = (update: AgentStatusStateUpdate): void => + commitSleepingRecordWrite(get, () => set(update)) + const runAfterCommit = (effect: () => void): void => { if (batchedAgentStatusEffects) { batchedAgentStatusEffects.push(effect) @@ -187,7 +193,7 @@ export function createAgentStatusRuntime( return } const uniquePaneKeys = new Set(paneKeys) - set((s) => { + setCommittingSleepingRecords((s) => { let nextSleeping = s.sleepingAgentSessionsByPaneKey let nextLaunchConfigs = s.agentLaunchConfigByPaneKey for (const paneKey of uniquePaneKeys) { @@ -220,6 +226,7 @@ export function createAgentStatusRuntime( return { get, set, + setCommittingSleepingRecords, runAfterCommit, applyGeneratedTabTitleUpdate, requestFreshness, diff --git a/src/renderer/src/store/slices/store-cascades-test-harness.ts b/src/renderer/src/store/slices/store-cascades-test-harness.ts index 5e731f934846..761852c48da7 100644 --- a/src/renderer/src/store/slices/store-cascades-test-harness.ts +++ b/src/renderer/src/store/slices/store-cascades-test-harness.ts @@ -15,6 +15,7 @@ export type StoreCascadesMockApi = { settings: { get: Mock; set: Mock } runtimeEnvironments: { call: Mock } cache: { getGitHub: Mock; setGitHub: Mock } + session: { sleepTerminalLeaves: Mock; wakeTerminalLeaves: Mock } } /** window.api double shared by the store cascade suites; installs itself on globalThis. */ @@ -51,6 +52,10 @@ export function createStoreCascadesMockApi(): StoreCascadesMockApi { cache: { getGitHub: vi.fn().mockResolvedValue(null), setGitHub: vi.fn().mockResolvedValue(undefined) + }, + session: { + sleepTerminalLeaves: vi.fn().mockResolvedValue(undefined), + wakeTerminalLeaves: vi.fn().mockResolvedValue(undefined) } } diff --git a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts index 11bba797aac3..0b012e7ac1db 100644 --- a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts +++ b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts @@ -270,6 +270,13 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { providerSession: { key: 'session_id', id: 'sess-rollback-1' } }) expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + // Main got the sleep, then the rollback's record. + expect(mockApi.session.sleepTerminalLeaves.mock.calls.at(-2)?.[0]).toEqual({ + [targetPaneKey]: expect.objectContaining({ origin: 'worktree-sleep' }) + }) + expect(mockApi.session.sleepTerminalLeaves.mock.calls.at(-1)?.[0]).toEqual({ + [targetPaneKey]: state.sleepingAgentSessionsByPaneKey[targetPaneKey] + }) }) it('persists the sleeping record and suppression before issuing the hibernation kill', async () => { diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index edade70fcf4f..c8502cc30013 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -12,6 +12,7 @@ import { } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' +import { commitSleepingRecordWrite } from './terminal-sleeping-record-commits' import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' export function createTerminalPaneHibernationActions( @@ -81,33 +82,37 @@ export function createTerminalPaneHibernationActions( } } const rollbackTargetShutdownState = (): void => { - set((s) => { - const next = { ...s.suppressedPtyExitIds } - for (const ptyId of exitGuardPtyIds) { - delete next[ptyId] - } - const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } - for (const key of sleepingRecordKeys) { - const replaced = replacedSleepingRecords[key] - if (replaced) { - nextSleeping[key] = replaced - } else { - delete nextSleeping[key] + commitSleepingRecordWrite(get, () => + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of exitGuardPtyIds) { + delete next[ptyId] } - } - return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } - }) + const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } + for (const key of sleepingRecordKeys) { + const replaced = replacedSleepingRecords[key] + if (replaced) { + nextSleeping[key] = replaced + } else { + delete nextSleeping[key] + } + } + return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } + }) + ) } - set((s) => ({ - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords - } - })) + commitSleepingRecordWrite(get, () => + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) + ) if (expectedRuntimePtyIds.length > 0) { if (!runtimeEnvironmentId) { rollbackTargetShutdownState() diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts index 389c8166a18e..a608cd2207f2 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-state.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -13,6 +13,7 @@ import { } from '../slices/agent-status' import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { copyOnWriteRecord } from '../copy-on-write-record' +import { commitSleepingRecordWrite } from './terminal-sleeping-record-commits' export function commitTerminalShutdownState({ exitGuardPtyIds, @@ -146,23 +147,25 @@ export function commitTerminalShutdownState({ }) if (keepIdentifiers) { - set((state) => { - const base = - shutdownReason === 'manual-sleep' - ? removeSleepingRecordsReplacedByManualWorktreeSleep( - state.sleepingAgentSessionsByPaneKey, - worktreeId, - sleepingPaneKeys, - sleepingAgentSessionRecords - ).records - : state.sleepingAgentSessionsByPaneKey - return { - sleepingAgentSessionsByPaneKey: { - ...base, - ...sleepingAgentSessionRecords + commitSleepingRecordWrite(get, () => + set((state) => { + const base = + shutdownReason === 'manual-sleep' + ? removeSleepingRecordsReplacedByManualWorktreeSleep( + state.sleepingAgentSessionsByPaneKey, + worktreeId, + sleepingPaneKeys, + sleepingAgentSessionRecords + ).records + : state.sleepingAgentSessionsByPaneKey + return { + sleepingAgentSessionsByPaneKey: { + ...base, + ...sleepingAgentSessionRecords + } } - } - }) + }) + ) } else { get().clearSleepingAgentSessionsByWorktree(worktreeId) } diff --git a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts new file mode 100644 index 000000000000..a313b9382734 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore, makeTab } from '../slices/store-test-helpers' +import { takeSleepingRecordChanges } from './terminal-sleeping-record-commits' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = `tab-1:${LEAF}` +const MOVED = `tab-2:${LEAF}` +const session = { key: 'session_id' as const, id: 'codex-session-1' } + +let api: { + sleepTerminalLeaves: ReturnType + wakeTerminalLeaves: ReturnType + closeTerminalSurface: ReturnType +} + +beforeEach(() => { + api = { + sleepTerminalLeaves: vi.fn(async () => {}), + wakeTerminalLeaves: vi.fn(async () => {}), + closeTerminalSurface: vi.fn(async () => ({})) + } + vi.stubGlobal('window', { api: { session: api } }) +}) + +afterEach(() => { + takeSleepingRecordChanges() + vi.unstubAllGlobals() +}) + +/** A store whose agent pane already has a committed sleeping record. */ +async function storeWithSleepingAgent() { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [ + makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + makeTab({ id: 'tab-2', worktreeId: 'wt-1' }) + ] + } + }) + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: session } + ) + await Promise.resolve() + api.sleepTerminalLeaves.mockClear() + return store +} + +describe("this window's sleeping-record commits", () => { + it('a new record sleeps its leaf, in one batch', async () => { + const store = await storeWithSleepingAgent() + const committed = store.getState().sleepingAgentSessionsByPaneKey[PANE] + expect(committed?.providerSession).toEqual(session) + + store + .getState() + .recordAgentProviderSession( + PANE, + 'codex', + { ...session, id: 'codex-session-2' }, + { updatedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } + ) + store.getState().captureAllSleepingAgentSessions('periodic') + await Promise.resolve() + + expect(api.sleepTerminalLeaves).toHaveBeenCalledTimes(1) + expect(api.sleepTerminalLeaves).toHaveBeenCalledWith({ + [PANE]: store.getState().sleepingAgentSessionsByPaneKey[PANE] + }) + expect(api.wakeTerminalLeaves).not.toHaveBeenCalled() + }) + + it('the periodic capture commits what it captured', async () => { + const store = await storeWithSleepingAgent() + // A live agent whose record main's last push dropped. + store.setState({ sleepingAgentSessionsByPaneKey: {} }) + + store.getState().captureAllSleepingAgentSessions('periodic') + const captured = store.getState().sleepingAgentSessionsByPaneKey[PANE] + await Promise.resolve() + + expect(captured?.providerSession).toEqual(session) + expect(api.sleepTerminalLeaves).toHaveBeenCalledWith({ [PANE]: captured }) + }) + + it('a removal wakes its leaf', async () => { + const store = await storeWithSleepingAgent() + + store.getState().clearSleepingAgentSession(PANE) + await Promise.resolve() + + expect(api.wakeTerminalLeaves).toHaveBeenCalledWith([PANE]) + expect(api.sleepTerminalLeaves).not.toHaveBeenCalled() + }) + + it('a pane move and a tab close commit nothing: main moves and closes them itself', async () => { + const store = await storeWithSleepingAgent() + + store.getState().transferAgentPaneAuthority({ fromPaneKey: PANE, toPaneKey: MOVED }) + expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toEqual([MOVED]) + store.getState().closeTab('tab-2') + expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) + await Promise.resolve() + + expect(api.sleepTerminalLeaves).not.toHaveBeenCalled() + expect(api.wakeTerminalLeaves).not.toHaveBeenCalled() + }) + + it('the quit stage takes the quit capture synchronously, and nothing is sent after', async () => { + const store = await storeWithSleepingAgent() + + store.getState().captureAllSleepingAgentSessions('quit') + const changes = takeSleepingRecordChanges() + await Promise.resolve() + + expect(changes).toEqual({ + sleep: { [PANE]: store.getState().sleepingAgentSessionsByPaneKey[PANE] }, + wake: [] + }) + expect(changes.sleep[PANE]?.origin).toBe('quit') + expect(api.sleepTerminalLeaves).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts new file mode 100644 index 000000000000..744c9e519f6d --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts @@ -0,0 +1,66 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { TerminalSleepingRecordChanges } from '../../../../shared/terminal-topology-slice' + +type SleepingRecords = Record + +/** Each pane's latest unsent value; null drops its record. */ +const unsent = new Map() + +/** + * Runs `write`, then sends main the window's own change to its sleeping-agent records, batched per + * microtask. The store keeps the new value meanwhile; main's next topology push brings back the + * committed one. Not for a mirror apply, a tab close or a pane move: main already holds those. + */ +export function commitSleepingRecordWrite( + get: () => { sleepingAgentSessionsByPaneKey: SleepingRecords }, + write: () => void +): void { + const before = get().sleepingAgentSessionsByPaneKey + write() + const after = get().sleepingAgentSessionsByPaneKey + if (before === after) { + return + } + const wasEmpty = unsent.size === 0 + for (const [paneKey, record] of Object.entries(after)) { + if (before[paneKey] !== record) { + unsent.set(paneKey, record) + } + } + for (const paneKey of Object.keys(before)) { + if (!Object.hasOwn(after, paneKey)) { + unsent.set(paneKey, null) + } + } + if (wasEmpty && unsent.size > 0) { + queueMicrotask(sendSleepingRecordChanges) + } +} + +/** Empties the batch; the quit stage carries it synchronously instead. */ +export function takeSleepingRecordChanges(): TerminalSleepingRecordChanges { + const changes: TerminalSleepingRecordChanges = { sleep: {}, wake: [] } + for (const [paneKey, record] of unsent) { + if (record) { + changes.sleep[paneKey] = record + } else { + changes.wake.push(paneKey) + } + } + unsent.clear() + return changes +} + +function sendSleepingRecordChanges(): void { + const { sleep, wake } = takeSleepingRecordChanges() + // Why optional: an older preload can linger through an in-place renderer reload. + const session = globalThis.window?.api?.session + const warn = (error: unknown): void => + console.warn('[terminal-sleep] main did not commit sleeping records', error) + if (wake.length > 0) { + void Promise.resolve(session?.wakeTerminalLeaves?.(wake)).catch(warn) + } + if (Object.keys(sleep).length > 0) { + void Promise.resolve(session?.sleepTerminalLeaves?.(sleep)).catch(warn) + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index f8108c75d610..58ba41286bf6 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -83,6 +83,9 @@ export function createWebWorkspaceSessionApi(): Partial { }, // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. closeTerminalSurface: async () => ({}), + // Web clients keep sleeping records in their saved session. + sleepTerminalLeaves: async () => {}, + wakeTerminalLeaves: async () => {}, // Why empty: web clients follow the host's session tabs, not main's topology push. getTerminalTopologySlices: async () => [], onTerminalTopologyChanged: () => () => {}, diff --git a/src/shared/terminal-topology-slice.ts b/src/shared/terminal-topology-slice.ts index cc475ad0c458..4d5b8d8c4678 100644 --- a/src/shared/terminal-topology-slice.ts +++ b/src/shared/terminal-topology-slice.ts @@ -36,3 +36,9 @@ export type TerminalTopologySlice = { /** On a reply whose write main publishes: the publishSeq of the push holding it. */ export type TerminalTopologyReply = { publishSeq?: number } + +/** A window's own sleeping-agent record changes: records it now holds, and pane keys it dropped. */ +export type TerminalSleepingRecordChanges = { + sleep: Record + wake: string[] +} From 1c8ecc4ff0300cdaabc71e7cdeb68f270e1c339e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 16:23:08 -0400 Subject: [PATCH 21/75] refactor(terminal): binding sites keep liveness; main owns which pane holds which PTY updateTabPtyId and the pane-level binding writers no longer write a local or SSH pane's layout binding (on spawn, handle rotation, exit or close): main records it on spawn and the mirror brings it. Their liveness half is unchanged (ptyIdsByTabId, tab.ptyId as the live attachment, the direct-SSH ledger, pendingActivationSpawn, Codex notice migration, activity bumps). A remote runtime's pane bindings stay window-written, for the push to its host. After an exit main keeps the binding (R17), so a remount does one stale reattach, then spawns fresh. A closed pane's binding is main's stale copy until main's push drops the leaf, so a late reattach reply to a closed pane is still retired. --- .../codex-detached-pane-restart.test.ts | 11 ++- .../pending-split-close-test-fixture.ts | 8 +- .../terminal-pane/pending-split-close.test.ts | 9 ++- .../retire-unbound-ipc-terminal-pane.ts | 7 +- .../terminal-pane-binding-ownership.test.tsx | 79 +++++++++++++++++++ .../use-terminal-pane-layout-bindings.ts | 8 +- .../terminal-exited-pane-liveness.test.ts | 15 +++- .../store/terminals/terminal-pty-bindings.ts | 51 ++++++------ .../terminals/terminal-topology-mirror.ts | 5 +- src/shared/terminal-tab-types.ts | 1 + 10 files changed, 154 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts index 1aa4adfcd9b9..9aa2951b6168 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts @@ -143,7 +143,8 @@ describe('codex detached pane restart executor', () => { const state = useAppStore.getState() expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY]) expect(state.tabsByWorktree.wt1?.[0]?.ptyId).toBe(NEW_PTY) - expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: NEW_PTY }) + // The pane's binding is main's: its spawn commit records the replacement and its push brings it. + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: OLD_PTY }) expect(state.pendingCodexPaneRestartIds).toEqual({}) expect(state.suppressedPtyExitIds[OLD_PTY]).toBeUndefined() // The whole point: the restart completed, so nothing may block input anymore. @@ -165,7 +166,8 @@ describe('codex detached pane restart executor', () => { const state = useAppStore.getState() expect(window.api.pty.kill).not.toHaveBeenCalled() expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY]) - expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: NEW_PTY }) + // The pane's binding is main's: its spawn commit records the replacement and its push brings it. + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: OLD_PTY }) expect(blocksCodexPaneInput(state.codexRestartNoticeByPtyId[NEW_PTY])).toBe(false) }) @@ -340,8 +342,9 @@ describe('codex detached pane restart executor', () => { const layout = useAppStore.getState().terminalLayoutsByTabId['tab-1'] expect(layout?.root?.type).toBe('split') + // Main's binding until its push: the window rewrites neither leaf. expect(layout?.ptyIdsByLeafId).toEqual({ - [LEAF_ID]: NEW_PTY, + [LEAF_ID]: OLD_PTY, [SIBLING_LEAF]: 'wt1@@sibling' }) // Split-pane safety: only the codex pane's PTY is replaced. @@ -395,7 +398,7 @@ describe('codex detached pane restart executor', () => { expect(window.api.pty.kill).not.toHaveBeenCalled() const state = useAppStore.getState() expect(state.ptyIdsByTabId['tab-1']).toEqual([NEW_PTY]) - expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe(NEW_PTY) + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF_ID]).toBe(OLD_PTY) expect(state.pendingCodexPaneRestartIds).toEqual({}) } finally { unregister() diff --git a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts index 11bf4602346a..89bdc538f950 100644 --- a/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts +++ b/src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts @@ -5,6 +5,7 @@ spec, where they would drift apart. */ import { afterEach, beforeEach, vi } from 'vitest' import type { AppState } from '@/store/types' +import type { PendingTerminalPane } from '@/store/terminals/terminal-pending-panes' import type { TerminalTabRetirementState } from '@/store/slices/terminal-tab-retirement' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { PaneManager } from '@/lib/pane-manager/pane-manager' @@ -103,13 +104,17 @@ export async function preparePendingSplitClose( } } } + const pendingTerminalPanes: PendingTerminalPane[] = [] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real close paths read only this retirement state and the supplied actions. store.current = Object.assign(state, { setCacheTimerStartedAt: vi.fn(), dropAgentStatus: vi.fn(), retireAgentPaneAuthority: vi.fn(), suppressPtyExit: vi.fn(), - markPendingTerminalPane: vi.fn(), + pendingTerminalPanes, + markPendingTerminalPane: vi.fn((entry: PendingTerminalPane) => + pendingTerminalPanes.push(entry) + ), settlePendingTerminalPane: vi.fn() }) as unknown as AppState const spawn = Promise.withResolvers() @@ -202,6 +207,7 @@ export async function preparePendingSplitClose( controller, actions, state, + pendingTerminalPanes, tabId, leafId, siblingLeafId diff --git a/src/renderer/src/components/terminal-pane/pending-split-close.test.ts b/src/renderer/src/components/terminal-pane/pending-split-close.test.ts index 3ab94611f941..7e0e98d8215d 100644 --- a/src/renderer/src/components/terminal-pane/pending-split-close.test.ts +++ b/src/renderer/src/components/terminal-pane/pending-split-close.test.ts @@ -16,7 +16,10 @@ it.each(['reattach', 'cold-restore-new', 'ordinary-fresh'] as const)( expect(p.transport.getPtyId()).toBeNull() p.actions.executeClosePane(1) expect(p.transports.has(1)).toBe(false) - expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBeUndefined() + // The leaf's binding is main's until its push drops the leaf; the window only hides the pane. + expect(p.pendingTerminalPanes).toContainEqual( + expect.objectContaining({ tabId: p.tabId, leafId: p.leafId, change: 'remove' }) + ) expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') p.spawn.resolve(replyFor(kind)) await p.connecting @@ -83,7 +86,8 @@ it.each(['same-leaf', 'different-tab', 'new-transport-map'] as const)( const { createIpcPtyTransport } = await import('./pty-transport') const replacement = createIpcPtyTransport({}) if (owner === 'same-leaf') { - p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId = { [p.leafId]: 'pty-restored' } + // Main refused the close, so the pane is back and its leaf owns the PTY again. + p.pendingTerminalPanes.length = 0 } else if (owner === 'different-tab') { p.state.tabsByWorktree.workspace.push(makeCloseTestTab('new-owner', 'pty-restored')) } else { @@ -203,6 +207,5 @@ it('commits the explicit split close in main by its leaf without waiting for its target: { kind: 'pane', tabId: p.tabId, leafId: p.leafId } }) expect(p.transports.has(1)).toBe(false) - expect(p.state.terminalLayoutsByTabId[p.tabId].ptyIdsByLeafId?.[p.leafId]).toBeUndefined() expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith('pty-restored') }) diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts index a3235fea66a7..39139e4c796a 100644 --- a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts +++ b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts @@ -3,6 +3,7 @@ import { getTerminalPtyOwnershipIdentity } from '@/store/slices/terminal-tab-retirement' import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers' +import { pendingTerminalLeafIds } from '@/store/terminals/terminal-pending-panes' import { terminalPaneHasOtherOwner, type UnboundTerminalPaneRetirement @@ -56,8 +57,10 @@ export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement if (returnedPtyId !== requestedPtyId) { return false } - // A replacement can own even this same leaf by the time the reply arrives. - if (!hasOtherOwner()) { + // A replacement can own even this same leaf by the time the reply arrives, but only once + // a refused close gives it back: while it is closing, its binding is main's stale copy. + const closing = pendingTerminalLeafIds(getState().pendingTerminalPanes, tabId, 'remove') + if (!hasOtherOwner(closing.has(leafId) ? leafId : undefined)) { requestRetirement() } return true diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx new file mode 100644 index 000000000000..dcb5188184b9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-binding-ownership.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { useTerminalPaneLayoutBindings } from './use-terminal-pane-layout-bindings' +import type { TerminalPaneLayoutController } from './use-terminal-pane-layout-persistence' + +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +const store = vi.hoisted((): { terminalLayoutsByTabId: Record } => ({ + terminalLayoutsByTabId: {} +})) +vi.mock('../../store', () => ({ useAppStore: { getState: () => store } })) + +function splitBoundTo(left: string, right: string): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + }, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: left, [RIGHT]: right } + } +} + +function renderBindings(layout: TerminalLayoutSnapshot) { + store.terminalLayoutsByTabId = { tab: layout } + const setTabLayout = vi.fn() + const controller = { + containerRef: { current: null }, + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + managerRef: { current: { getLeafId: (paneId: number) => (paneId === 1 ? LEFT : RIGHT) } }, + paneTransportsRef: { current: new Map() }, + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + persistLayoutSnapshot: vi.fn(), + setExpandedPaneId: vi.fn(), + setTabLayout, + setTabPaneExpanded: vi.fn(), + tabId: 'tab' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding writers read only these controller fields. + const typed = controller as unknown as TerminalPaneLayoutController + const { result } = renderHook(() => useTerminalPaneLayoutBindings(typed)) + return { bindings: result.current, setTabLayout } +} + +afterEach(cleanup) + +// R17: main keeps an exited pane's binding, so its remount does one stale reattach, then spawns. +describe("a pane's PTY binding", () => { + it('is never written by the window for a local or SSH pane, on spawn or on exit', () => { + const { bindings, setTabLayout } = renderBindings(splitBoundTo('pty-left', 'ssh:t@@pty-right')) + + bindings.syncPanePtyLayoutBinding(1, 'pty-fresh') + bindings.syncPanePtyLayoutBinding(2, null) + bindings.clearExitedPanePtyLayoutBinding(1, 'pty-left') + bindings.clearExitedPanePtyLayoutBindingForLeaf(RIGHT, 'ssh:t@@pty-right') + + expect(setTabLayout).not.toHaveBeenCalled() + }) + + it("is still written by the window for a remote runtime's pane, for the push to its host", () => { + const { bindings, setTabLayout } = renderBindings( + splitBoundTo('remote:env@@pty-left', 'remote:env@@pty-right') + ) + + bindings.clearExitedPanePtyLayoutBinding(1, 'remote:env@@pty-left') + + expect(setTabLayout).toHaveBeenCalledExactlyOnceWith( + 'tab', + expect.objectContaining({ ptyIdsByLeafId: { [RIGHT]: 'remote:env@@pty-right' } }) + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts index ebbe137cec42..7bd125026c99 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts @@ -5,6 +5,7 @@ import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { shouldIgnoreStalePanePtyLayoutBinding } from './pty-connection/pane-pty-layout-binding' import { useExpandCollapseActions } from './expand-collapse' import type { TerminalPaneLayoutController } from './use-terminal-pane-layout-persistence' +import { isRemoteRuntimePtyId } from '@/store/terminals/terminal-pty-identities' export function useTerminalPaneLayoutBindings(controller: TerminalPaneLayoutController) { const { @@ -31,6 +32,11 @@ export function useTerminalPaneLayoutBindings(controller: TerminalPaneLayoutCont const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } = existingLayout const existingBindings = existingLayout.ptyIdsByLeafId ?? {} + // A local or SSH pane's binding is main's: recorded on spawn, mirrored here, kept after exit + // (R17). Only a remote runtime's is written here, for the push to its host. + if (!isRemoteRuntimePtyId(ptyId ?? existingBindings[leafId])) { + return + } if (ptyId && sourcePaneId !== undefined) { const currentTransportPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() @@ -107,7 +113,7 @@ export function useTerminalPaneLayoutBindings(controller: TerminalPaneLayoutCont const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } = existingLayout const existingBindings = existingLayout.ptyIdsByLeafId ?? {} - if (existingBindings[leafId] !== exitedPtyId) { + if (existingBindings[leafId] !== exitedPtyId || !isRemoteRuntimePtyId(exitedPtyId)) { return } const nextBindings = { ...existingBindings } diff --git a/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts index 141d3141b641..741e7f614b8b 100644 --- a/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts +++ b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts @@ -9,8 +9,8 @@ vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.f createStoreSessionMockApi() // "Is this pane running" reads the liveness map; an exit leaves the pane's layout binding in place -// for its one stale reattach. The mirror refactor keeps this split: liveness stays in the window, -// the binding becomes main's. +// for its one stale reattach (R17; the reattach-then-spawn itself is pinned in +// pty-connection-reattach-binding.test.ts). Liveness stays in the window; the binding is main's. const WORKTREE = 'repo1::/path/wt1' const LEFT = '11111111-1111-4111-8111-111111111111' @@ -73,4 +73,15 @@ describe('exited pane in a split', () => { [RIGHT]: 'pty-right' }) }) + + it("never writes main's binding, on a spawn, a handle rotation or an exit", () => { + const store = storeWithSplitTab() + + store.getState().updateTabPtyId('tab-1', 'pty-left-2', 'pty-left') + store.getState().updateTabPtyId('tab-1', 'pty-extra') + store.getState().clearTabPtyId('tab-1', 'pty-right') + store.getState().clearTabPtyId('tab-1') + + expect(store.getState().terminalLayoutsByTabId['tab-1']).toEqual(splitLayout()) + }) }) diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 499ea63e8ded..ff3f8d00fb5f 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -1,4 +1,5 @@ import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { isTerminalTabPresent } from '../slices/terminal-tab-retirement' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' @@ -12,6 +13,23 @@ import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' +/** A remote runtime's pane bindings are this window's, pushed to its host; they follow a handle rotation. */ +function rotateRemotePaneBinding( + layout: TerminalLayoutSnapshot | undefined, + replacedPtyId: string, + ptyId: string +): TerminalLayoutSnapshot | null { + const bindings = layout?.ptyIdsByLeafId + if (!layout || !bindings || !Object.values(bindings).includes(replacedPtyId)) { + return null + } + const rotated = Object.entries(bindings).map(([leafId, id]) => [ + leafId, + id === replacedPtyId ? ptyId : id + ]) + return { ...layout, ptyIdsByLeafId: Object.fromEntries(rotated) } +} + export function createTerminalPtyBindingActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -64,30 +82,11 @@ export function createTerminalPtyBindingActions( : existingPtyIds.includes(ptyId) ? existingPtyIds : [...existingPtyIds, ptyId] - // Keep provider handle rotation atomic across tab and pane ownership. - let nextTerminalLayoutsByTabId = s.terminalLayoutsByTabId - if (replacementPtyId) { - const existingLayout = s.terminalLayoutsByTabId[tabId] - const existingBindings = existingLayout?.ptyIdsByLeafId - if (existingLayout && existingBindings) { - let changed = false - const nextBindings = Object.fromEntries( - Object.entries(existingBindings).map(([leafId, currentPtyId]) => { - if (currentPtyId !== replacementPtyId) { - return [leafId, currentPtyId] - } - changed = true - return [leafId, ptyId] - }) - ) - if (changed) { - nextTerminalLayoutsByTabId = { - ...s.terminalLayoutsByTabId, - [tabId]: { ...existingLayout, ptyIdsByLeafId: nextBindings } - } - } - } - } + // Liveness only: a local or SSH pane's binding is main's, mirrored into the layout (D1). + const rotatedLayout = + replacementPtyId && isRemoteRuntimePtyId(replacementPtyId) + ? rotateRemotePaneBinding(s.terminalLayoutsByTabId[tabId], replacementPtyId, ptyId) + : null let nextTabsByWorktree = s.tabsByWorktree for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { const index = tabs.findIndex((t) => t.id === tabId) @@ -270,8 +269,8 @@ export function createTerminalPtyBindingActions( migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, - ...(nextTerminalLayoutsByTabId !== s.terminalLayoutsByTabId - ? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId } + ...(rotatedLayout + ? { terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: rotatedLayout } } : {}), ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index e7e9603ef270..360c0bd6c23d 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -34,7 +34,10 @@ type _UnmirroredRowField = Exclude< > void (true satisfies [_UnmirroredRowField] extends [never] ? true : never) -/** Main's row fields replace the window's; presentation stays, and so does `ptyId`, the live attachment (D1). */ +/** + * Main's row fields replace the window's; presentation stays, and so does `ptyId`. In the window it + * is the PTY the tab is attached to now (liveness, D1), not main's persisted binding. + */ function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { const next: TerminalTab = { ...current, ...row, ptyId: current.ptyId } for (const field of OPTIONAL_ROW_FIELDS) { diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 689aee495462..c4b367a5599e 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -57,6 +57,7 @@ export type TerminalTabRecoveryLedger = { // ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ export type TerminalTab = { id: string + /** Main: the row's persisted PTY (a wake hint). Window: the PTY attached now; never main's binding. */ ptyId: string | null worktreeId: string title: string From 414e00e6a82315ef9f966c566f57449ba88500fe Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 16:26:14 -0400 Subject: [PATCH 22/75] fix(terminal): a running agent re-attached to a split pane keeps its terminal Re-attaching an agent's live terminal that no pane claims into its pane wrote the pane's binding only in the window, which main's push overwrites, so the pane in a split started a new shell. The window now asks main to record it (session:terminal-bind-leaf -> bindLeaf, a reattach bind that never creates the pane, in the worktree's home partition); main's push carries it to the pane. --- .../ipc/session-terminal-bind-leaf.test.ts | 99 +++++++++++++++++++ src/main/ipc/session.ts | 19 ++++ .../terminal-topology-commit.ts | 14 +++ src/preload/api/session-bridge.ts | 1 + src/preload/api/workspace-session-api.ts | 8 ++ .../worktree-agent-activation-gate.test.ts | 30 +++++- .../worktree-agent-live-surface-adoption.ts | 7 ++ .../preload-api/web-workspace-session-api.ts | 2 + src/shared/terminal-leaf-bind.ts | 23 +++++ 9 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 src/main/ipc/session-terminal-bind-leaf.test.ts create mode 100644 src/shared/terminal-leaf-bind.ts diff --git a/src/main/ipc/session-terminal-bind-leaf.test.ts b/src/main/ipc/session-terminal-bind-leaf.test.ts new file mode 100644 index 000000000000..5387e9d98595 --- /dev/null +++ b/src/main/ipc/session-terminal-bind-leaf.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import type { Store } from '../persistence' +import { + closeMoveTestStores, + LEFT, + MOVED, + newDataFile, + openStore, + seedSplitSource, + SOURCE, + WT +} from '../persistence/terminal-topology/terminal-leaf-move-fixture' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { + handle: (name: string, callback: (...args: unknown[]) => unknown) => + mocks.handlers.set(name, callback), + on: () => {} + }, + BrowserWindow: { getAllWindows: () => [] } +})) +import { registerSessionHandlers } from './session' + +const ADOPT = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, ptyId: 'pty-adopted' } +let homeHostId: string | null = 'local' +let store: Store + +function bindLeaf(args: unknown): Promise { + return Promise.resolve(mocks.handlers.get('session:terminal-bind-leaf')?.({}, args)) +} + +// A split whose second pane lost its binding while its agent kept running. +beforeEach(async () => { + mocks.handlers.clear() + homeHostId = 'local' + store = openStore(newDataFile()) + await seedSplitSource(store) + await store.retirePtyBinding({ worktreeId: WT, tabId: SOURCE, leafId: MOVED, ptyId: 'pty-agent' }) + registerSessionHandlers( + store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reaches only the home lookup and the topology settle. + { getTerminalTopologyHomeHostId: () => homeHostId, settleTerminalTopology: () => 4 } as never + ) +}) + +afterEach(closeMoveTestStores) + +describe('session:terminal-bind-leaf', () => { + it("records an adopted agent's PTY on its pane in a split, so a remount reattaches", async () => { + await expect(bindLeaf(ADOPT)).resolves.toEqual({ status: 'bound', publishSeq: 4 }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId[SOURCE]?.ptyIdsByLeafId).toEqual({ + [LEFT]: 'pty-left', + [MOVED]: 'pty-adopted' + }) + }) + + it('never creates a pane main does not hold', async () => { + const before = store.getWorkspaceSession() + + await expect( + bindLeaf({ ...ADOPT, leafId: '33333333-3333-4333-8333-333333333333' }) + ).resolves.toEqual({ status: 'refused', reason: 'not_bound', publishSeq: 4 }) + expect(store.getWorkspaceSession()).toEqual(before) + }) + + it('writes nothing when the home is unresolved, or for a malformed request', async () => { + const before = store.getWorkspaceSession() + homeHostId = null + + await expect(bindLeaf(ADOPT)).resolves.toEqual({ + status: 'refused', + reason: 'home_unresolved', + publishSeq: 4 + }) + await expect(bindLeaf({ ...ADOPT, leafId: 'not-a-leaf' })).resolves.toEqual({ + status: 'refused', + reason: 'invalid_request' + }) + expect(store.getWorkspaceSession()).toEqual(before) + }) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index c5bfe6837efb..d78063e42486 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -6,6 +6,11 @@ import { parseTerminalLayoutSetRequest, type TerminalLayoutSetResult } from '../../shared/terminal-layout-set' +import { + parseTerminalLeafBindRequest, + type TerminalLeafBindResult +} from '../../shared/terminal-leaf-bind' +import { bindLeaf } from '../persistence/terminal-topology/terminal-topology-commit' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { @@ -84,6 +89,20 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } }) + // An adopted live PTY: main records it on its pane, and the push carries it to the window. + ipcMain.handle('session:terminal-bind-leaf', async (_event, args: unknown) => { + const request = parseTerminalLeafBindRequest(args) + if (!request) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafBindResult + } + const hostId = runtime.getTerminalTopologyHomeHostId(request.worktreeId) + const bound = hostId !== null && (await bindLeaf(store, request, hostId)) + const result: TerminalLeafBindResult = bound + ? { status: 'bound' } + : { status: 'refused', reason: hostId ? 'not_bound' : 'home_unresolved' } + return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } + }) + // Pull-after-listen: a window subscribes to pushes first, then reads what it missed. ipcMain.handle('session:get-terminal-topology-slices', () => runtime.getTerminalTopologySlices()) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 12b7ec25bfed..5b579ad2774b 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -8,7 +8,9 @@ import type { TerminalLayoutSetRequest, TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' +import type { TerminalLeafBindRequest } from '../../../shared/terminal-leaf-bind' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { PtyBindingPersistenceOperations } from '../loading-store/pty-binding-persistence' import { startSpan } from '../../observability/tracer' import { terminalSurfaceCloseMutation, @@ -49,6 +51,18 @@ export function moveLeaf( ) } +/** Records a live PTY the window adopted onto a pane main holds, as a reattach: never creates the pane. */ +export function bindLeaf( + bindings: Pick, + request: TerminalLeafBindRequest, + hostId: ExecutionHostId +): Promise { + return bindings.persistPtyBinding( + { ...request, mayCreate: false, mayReviveRetiredSurface: false, origin: 'reattach' }, + hostId + ) +} + /** Replaces a tab's tree in `hostId`, the worktree's home, with the user's same-pane geometry edit. */ export function setLayout( request: TerminalLayoutSetRequest, diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 3176040f7ca8..973514bdb050 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -10,6 +10,7 @@ export const sessionApi = { patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), setTerminalLayout: (args) => ipcRenderer.invoke('session:terminal-set-layout', args), + bindTerminalLeaf: (args) => ipcRenderer.invoke('session:terminal-bind-leaf', args), getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), onTerminalTopologyChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index cc8cf97049e0..5878dd05c9fc 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -9,6 +9,10 @@ import type { TerminalLayoutSetRequest, TerminalLayoutSetResult } from '../../shared/terminal-layout-set' +import type { + TerminalLeafBindRequest, + TerminalLeafBindResult +} from '../../shared/terminal-leaf-bind' import type { TerminalTopologyReply, TerminalTopologySlice @@ -38,6 +42,10 @@ export type WorkspaceSessionApi = { setTerminalLayout: ( args: TerminalLayoutSetRequest ) => Promise + /** Has main record a live PTY the window adopted onto a pane main holds. */ + bindTerminalLeaf: ( + args: TerminalLeafBindRequest + ) => Promise /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ getTerminalTopologySlices: () => Promise onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 6c1e58487cd7..89a15407bc9c 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -317,7 +317,7 @@ describe('worktree agent activation gate', () => { }) }) - it('rebinds an unowned PTY to the recorded pane this renderer still holds', async () => { + it('rebinds an unowned PTY to the recorded pane of a split this renderer still holds', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' } const { deps, createTab, resume } = testDeps({ @@ -325,13 +325,37 @@ describe('worktree agent activation gate', () => { surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]]) }) seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID }) + deps.getState().terminalLayoutsByTabId['tab-live'] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: SIBLING_LEAF_ID }, + second: { type: 'leaf', leafId: LIVE_LEAF_ID } + }, + activeLeafId: SIBLING_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SIBLING_LEAF_ID]: `${WORKTREE_ID}@@shell` } + } + const bindTerminalLeaf = vi.fn(async () => ({ status: 'bound' as const })) + vi.stubGlobal('window', { api: { session: { bindTerminalLeaf } } }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + try { + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + } finally { + vi.unstubAllGlobals() + } // The host's graph omits unmounted panes; minting here forked the agent onto a second tab. expect(createTab).not.toHaveBeenCalled() - // The leaf's binding is main's; this window records only the live attachment. + // This window records the live attachment; main records the pane's binding, so the pane + // reattaches instead of starting a new shell. expect(deps.getState().ptyIdsByTabId['tab-live']).toEqual([ptyId]) + expect(bindTerminalLeaf).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WORKTREE_ID, + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + ptyId + }) expect(resume).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 3952ca68a0ce..97437aaec71c 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -57,6 +57,13 @@ export function bindLivePtyToExactSurface( return false } store.updateTabPtyId(terminal.tabId, terminal.ptyId) + // The pane's binding is main's: it records the adopted PTY, and its push brings it to the pane. + void globalThis.window?.api?.session?.bindTerminalLeaf?.({ + worktreeId: existing.ownerWorktreeId, + tabId: terminal.tabId, + leafId: pane.leafId, + ptyId: terminal.ptyId + }) return true } const created = store.createTab(worktreeId, undefined, undefined, { diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 4b54ff19415f..33d5c969cc6d 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -85,6 +85,8 @@ export function createWebWorkspaceSessionApi(): Partial { closeTerminalSurface: async () => ({}), // Why refused: web layout edits reach the host through `session.tabs.updatePaneLayout`. setTerminalLayout: async () => ({ status: 'refused', reason: 'tab_not_held' }), + // Why refused: web panes attach through the host, which records their bindings. + bindTerminalLeaf: async () => ({ status: 'refused', reason: 'not_bound' }), // Why empty: web clients follow the host's session tabs, not main's topology push. getTerminalTopologySlices: async () => [], onTerminalTopologyChanged: () => () => {}, diff --git a/src/shared/terminal-leaf-bind.ts b/src/shared/terminal-leaf-bind.ts new file mode 100644 index 000000000000..c35a824fd1b6 --- /dev/null +++ b/src/shared/terminal-leaf-bind.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { isTerminalLeafId } from './stable-pane-id' + +const id = z.string().min(1).max(512) +const TerminalLeafBindRequestSchema = z.object({ + worktreeId: id, + tabId: id, + leafId: id.refine((leafId): boolean => isTerminalLeafId(leafId)), + ptyId: id +}) + +/** A live PTY the window adopted onto a pane main already holds. */ +export type TerminalLeafBindRequest = z.infer + +export type TerminalLeafBindResult = + | { status: 'bound' } + | { status: 'refused'; reason: 'invalid_request' | 'home_unresolved' | 'not_bound' } + +/** Null for anything malformed. */ +export function parseTerminalLeafBindRequest(value: unknown): TerminalLeafBindRequest | null { + const parsed = TerminalLeafBindRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} From e2de4aee403c65defbd5df80e4d788035917fa7a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 16:27:40 -0400 Subject: [PATCH 23/75] fix(terminal): a divider drag right after a split is kept A drag made before main recorded a just-created split sent a tree naming a pane main didn't have yet; main refused it (leaves_differ) and the drag was lost, and an unrelated push could drop it sooner. The drag's pending layout now ignores panes this window added and main hasn't named, and its commit waits until main names them, then sends once. --- .../use-terminal-pane-layout-persistence.ts | 12 +++- .../terminals/terminal-pending-layout.test.ts | 47 ++++++++++++++- .../store/terminals/terminal-pending-panes.ts | 60 +++++++++++++++++-- .../terminals/terminal-topology-mirror.ts | 24 +++----- 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index 2a2d2d6d2311..e57b46ac5e85 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -5,7 +5,10 @@ import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' -import { commitPendingTerminalChange } from '@/store/terminals/terminal-pending-panes' +import { + commitPendingTerminalChange, + terminalTabPanesNamed +} from '@/store/terminals/terminal-pending-panes' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { clearWebRuntimeTerminalBuffer } from '@/runtime/web-runtime-session' @@ -131,8 +134,11 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup commitPendingTerminalChange( useAppStore.getState(), { worktreeId, tabId, change: 'layout', root }, - // Why optional: an older preload can linger through an in-place renderer reload. - () => globalThis.window?.api?.session?.setTerminalLayout?.({ worktreeId, tabId, root }) + () => + terminalTabPanesNamed(useAppStore.subscribe, useAppStore.getState, tabId).then(() => + // Why optional: an older preload can linger through an in-place renderer reload. + globalThis.window?.api?.session?.setTerminalLayout?.({ worktreeId, tabId, root }) + ) ) } for (const leafId of currentLeafIds) { diff --git a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts index 0e19a6b8a484..06d6d1f05876 100644 --- a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts @@ -3,7 +3,11 @@ import { useAppStore } from '@/store' import type { TerminalLayoutSetResult } from '../../../../shared/terminal-layout-set' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' -import { commitPendingTerminalChange, withPendingTerminalPane } from './terminal-pending-panes' +import { + commitPendingTerminalChange, + terminalTabPanesNamed, + withPendingTerminalPane +} from './terminal-pending-panes' const WT = 'repo::/wt' const TAB = 'tab' @@ -61,7 +65,9 @@ function dragEnd(root: TerminalPaneLayoutNode): void { const layout = state().terminalLayoutsByTabId[TAB] state().setTabLayout(TAB, { ...layout!, root }) commitPendingTerminalChange(state(), { worktreeId: WT, tabId: TAB, change: 'layout', root }, () => - setTerminalLayout({ worktreeId: WT, tabId: TAB, root }) + terminalTabPanesNamed(useAppStore.subscribe, useAppStore.getState, TAB).then(() => + setTerminalLayout({ worktreeId: WT, tabId: TAB, root }) + ) ) } @@ -95,11 +101,12 @@ afterEach(() => { }) describe('a divider drag committed to main', () => { - it('sends the tree once, and a mirror apply never sends one', () => { + it('sends the tree once, and a mirror apply never sends one', async () => { apply(2, split(leaf(A), leaf(B), 0.6)) expect(setTerminalLayout).not.toHaveBeenCalled() dragEnd(DRAGGED) + await flush() expect(setTerminalLayout).toHaveBeenCalledExactlyOnceWith({ worktreeId: WT, @@ -116,6 +123,7 @@ describe('a divider drag committed to main', () => { expect(rootInStore()).toEqual(DRAGGED) expect(state().terminalLayoutsByTabId[TAB]).toBe(kept) + await flush() replies[0]!.resolve({ status: 'committed', publishSeq: 4 }) await flush() apply(3, BEFORE) @@ -129,6 +137,7 @@ describe('a divider drag committed to main', () => { it('settles on the reply when main published it first', async () => { dragEnd(DRAGGED) + await flush() apply(2, DRAGGED) replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) await flush() @@ -150,6 +159,7 @@ describe('a divider drag committed to main', () => { dragEnd(DRAGGED) const second = split(leaf(A), leaf(B), 0.2) dragEnd(second) + await flush() replies[0]!.resolve({ status: 'committed', publishSeq: 2 }) await flush() @@ -160,6 +170,7 @@ describe('a divider drag committed to main', () => { it('stops holding the tree when main could not be reached', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) dragEnd(DRAGGED) + await flush() replies[0]!.reject(new Error('gone')) await flush() @@ -167,6 +178,36 @@ describe('a divider drag committed to main', () => { expect(rootInStore()).toEqual(BEFORE) }) + it('waits for main to name a just-split pane, keeping the drag over pushes until then', async () => { + const splitBefore = split(BEFORE, leaf(C)) + const splitDragged = split(BEFORE, leaf(C), 0.3) + state().markPendingTerminalPane({ worktreeId: WT, tabId: TAB, leafId: C, change: 'add' }) + state().setTabLayout(TAB, { ...state().terminalLayoutsByTabId[TAB]!, root: splitBefore }) + + dragEnd(splitDragged) + await flush() + // Main hasn't recorded the split, so it would refuse the tree. + expect(setTerminalLayout).not.toHaveBeenCalled() + apply(2, BEFORE) + expect(rootInStore()).toEqual(splitDragged) + + // Main records the split from the spawn's proposed tree, which predates the drag. + apply(3, splitBefore) + expect(rootInStore()).toEqual(splitDragged) + await flush() + expect(setTerminalLayout).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WT, + tabId: TAB, + root: splitDragged + }) + + replies[0]!.resolve({ status: 'committed', publishSeq: 4 }) + await flush() + apply(4, splitDragged) + expect(state().pendingTerminalPanes).toEqual([]) + expect(rootInStore()).toEqual(splitDragged) + }) + it("leaves the tab's pending add in place; only a later drag replaces a drag", () => { const add = { worktreeId: WT, tabId: TAB, change: 'add' } as const const first = { worktreeId: WT, tabId: TAB, change: 'layout', root: DRAGGED } as const diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index fe1c2c17f583..da945f3a600a 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -15,7 +15,8 @@ export type PendingTerminalPaneKey = { worktreeId: string; tabId: string; leafId * A tab or pane this window shows (`add`) or hides (`remove`), or a tab tree it keeps (`layout`, * a user's geometry edit), before main's topology does. An add stands until a slice names it; a * remove or layout until the slice holding main's reply (`publishSeq`). A layout also ends once - * main's tab has other panes, since main refuses it then. + * main's tab has other panes than its tree, apart from panes added here and not yet named, since + * main refuses it then. */ export type PendingTerminalPane = PendingTerminalPaneKey & { publishSeq?: number } & ( | { change: 'add' | 'remove' } @@ -91,8 +92,14 @@ export function pendingTerminalLayoutRoot( )?.root } -const leafSet = (root: TerminalPaneLayoutNode | null | undefined): string => - collectLeafIds(root).sort().join('\n') +const leafSet = ( + root: TerminalPaneLayoutNode | null | undefined, + except: ReadonlySet = new Set() +): string => + collectLeafIds(root) + .filter((leafId) => !except.has(leafId)) + .sort() + .join('\n') /** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ export function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { @@ -120,16 +127,26 @@ export function pendingAfterTerminalTopologySlice( ): PendingTerminalPane[] { const tabIds = new Set(slice.tabs.map((tab) => tab.id)) const leafIds = new Set(Object.values(slice.layouts).flatMap(({ root }) => collectLeafIds(root))) + const named = (entry: PendingTerminalPane): boolean => + entry.leafId ? leafIds.has(entry.leafId) : tabIds.has(entry.tabId) const settled = (entry: PendingTerminalPane): boolean => { if (entry.worktreeId !== slice.worktreeId) { return false } if (entry.change === 'add') { - return entry.leafId ? leafIds.has(entry.leafId) : tabIds.has(entry.tabId) + return named(entry) } + // Panes added here that main hasn't named yet don't make a gesture's tree stale. + const unnamed = new Set( + pending.flatMap((other) => + other.change === 'add' && other.tabId === entry.tabId && other.leafId && !named(other) + ? [other.leafId] + : [] + ) + ) if ( entry.change === 'layout' && - leafSet(slice.layouts[entry.tabId]?.root) !== leafSet(entry.root) + leafSet(slice.layouts[entry.tabId]?.root) !== leafSet(entry.root, unnamed) ) { return true } @@ -138,6 +155,37 @@ export function pendingAfterTerminalTopologySlice( return pending.some(settled) ? pending.filter((entry) => !settled(entry)) : pending } +/** Resolves once `ready` holds, re-checked after every store change. */ +export function terminalStoreReady( + subscribe: (listener: () => void) => () => void, + ready: () => boolean +): Promise { + return new Promise((resolve) => { + if (ready()) { + resolve() + return + } + const unsubscribe = subscribe(() => { + if (ready()) { + unsubscribe() + resolve() + } + }) + }) +} + +/** Main refuses a tree naming a pane it hasn't recorded, so a gesture right after a split waits. */ +export function terminalTabPanesNamed( + subscribe: (listener: () => void) => () => void, + getState: () => Pick, + tabId: string +): Promise { + return terminalStoreReady( + subscribe, + () => pendingTerminalLeafIds(getState().pendingTerminalPanes, tabId, 'add').size === 0 + ) +} + /** * Holds `entry` over main's pushes while `send` commits it in main, until the push holding main's * reply is applied. A later change to the same target replaces `entry`; this reply then settles nothing. @@ -145,7 +193,7 @@ export function pendingAfterTerminalTopologySlice( export function commitPendingTerminalChange( store: Pick, entry: PendingTerminalPane, - send: () => Promise | undefined + send: () => Promise | undefined ): void { store.markPendingTerminalPane(entry) void Promise.resolve(send()).then( diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 360c0bd6c23d..33cd1cad4c45 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -16,7 +16,8 @@ import { isPendingTerminalTab, isRuntimeHostedTab, pendingAfterTerminalTopologySlice, - pendingTerminalLayoutRoot + pendingTerminalLayoutRoot, + terminalStoreReady } from './terminal-pending-panes' const OPTIONAL_ROW_FIELDS = [ @@ -189,19 +190,10 @@ export function terminalTopologyApplied( worktreeId: string, publishSeq: number | undefined ): Promise { - const applied = (): boolean => - publishSeq === undefined || - (getState().terminalTopologySeqByWorktree[worktreeId] ?? 0) >= publishSeq - return new Promise((resolve) => { - if (applied()) { - resolve() - return - } - const unsubscribe = subscribe(() => { - if (applied()) { - unsubscribe() - resolve() - } - }) - }) + return terminalStoreReady( + subscribe, + () => + publishSeq === undefined || + (getState().terminalTopologySeqByWorktree[worktreeId] ?? 0) >= publishSeq + ) } From 3ca6e5f351bb015bf4c3d2e7a58878a1f668a5b4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 16:06:25 -0400 Subject: [PATCH 24/75] refactor(ssh): main exports SSH workspaces after its own session writes; imports go through main The window no longer uploads to SSH relays. Main exports a host's workspace after any session write whose share for that host differs from what the two last agreed on, and reports each export back to the window as a push-status event. The window's snapshot import now commits its merge through main in one call (remoteWorkspace:importPeerTopology) with the pull's revision, host observation and outcome; that sets the agreement, so an import and the window's save-back of it never export, and a conflicted pull keeps the host out of exports until a whole pull agrees again. The first-connect seed of an empty host is main's export too. Co-Authored-By: Claude --- config/reliability-gates.jsonc | 47 +- .../remote-workspace-export-test-driver.ts | 122 +++++ src/main/ipc/remote-workspace-export.ts | 171 ++++++ .../ipc/remote-workspace-patch-queue.test.ts | 225 +++----- ...remote-workspace-snapshot-normalization.ts | 16 +- .../ipc/remote-workspace-stale-resync.test.ts | 7 +- .../ipc/remote-workspace-two-desktops.test.ts | 285 ++++++++++ src/main/ipc/remote-workspace.test.ts | 360 +++++++------ src/main/ipc/remote-workspace.ts | 186 ++----- .../ssh-host-partition-session-export.test.ts | 45 +- src/preload/api/remote-workspace-bridge.ts | 14 +- src/preload/api/workspace-session-api.ts | 15 +- ...space-unplaced-upload-suppression.test.tsx | 496 ------------------ .../app-shell/use-app-session-persistence.ts | 152 +----- ...mote-workspace-target-sync-test-harness.ts | 18 +- .../ipc-events/remote-workspace-ipc-bridge.ts | 6 +- ...remote-workspace-push-status-event.test.ts | 111 ++++ .../src/hooks/remote-workspace-push-status.ts | 51 +- ...pshot-apply-deferred-session-write.test.ts | 3 +- .../hooks/remote-workspace-snapshot-apply.ts | 42 +- ...pace-snapshot-duplicate-tab-repair.test.ts | 3 +- ...-snapshot-fresh-client-tab-seeding.test.ts | 3 +- ...e-workspace-snapshot-import-commit.test.ts | 174 ++++++ ...kspace-snapshot-local-tab-survival.test.ts | 3 +- ...ace-snapshot-unplaced-tab-adoption.test.ts | 6 +- .../remote-workspace-target-sync-types.ts | 12 +- .../remote-workspace-target-sync.test.ts | 122 +---- .../src/hooks/remote-workspace-target-sync.ts | 67 +-- ...gent-status-connection-attribution.test.ts | 3 +- .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + ...workspace-session-host-persistence.test.ts | 22 +- .../lib/workspace-session-host-persistence.ts | 44 +- ...ce-session-ssh-partition-ownership.test.ts | 4 +- ...hidden-ssh-host-session-round-trip.test.ts | 2 +- ...host-purge-session-partition-split.test.ts | 4 +- src/shared/remote-workspace-types.ts | 26 + 36 files changed, 1485 insertions(+), 1384 deletions(-) create mode 100644 src/main/ipc/remote-workspace-export-test-driver.ts create mode 100644 src/main/ipc/remote-workspace-export.ts create mode 100644 src/main/ipc/remote-workspace-two-desktops.test.ts delete mode 100644 src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx create mode 100644 src/renderer/src/hooks/remote-workspace-push-status-event.test.ts create mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 346fe9ee7d1b..486b120bb393 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1717,14 +1717,14 @@ "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Drives the real Store against a temp profile, the real remoteWorkspace:setForConnectedTargets handler, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", + "coverageNotes": "Drives the real Store against a temp profile, main's real session-write export trigger, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/12721", "https://github.com/stablyai/orca/issues/18173", "https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts" ], "invariant": "Every workspace the `ssh:` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:`, which is the repair's own input.", - "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", + "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Agree with the host, write the runtime-authored tabs into `ssh:`, and assert main's export carries them to the host snapshot rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts" ], @@ -13733,7 +13733,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, an incoming snapshot revokes replace-session upload authority before preparation yields, already-captured uploads revalidate that exact authority after local persistence settles, and main rejects their applied revision when a newer host snapshot arrives before IPC admission or while queued. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, main exports only under the revision and host observation the window last agreed on and rejects an export when a newer host snapshot arrives before admission or while queued, and with two desktops on one relay an import exports nothing while a local split exports once. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-3107", "https://github.com/stablyai/orca/pull/12664", @@ -13752,14 +13752,14 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", @@ -13814,7 +13814,7 @@ "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts", - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", + "src/main/ipc/remote-workspace-two-desktops.test.ts", "src/main/ipc/remote-workspace-cache.test.ts", "src/main/ipc/remote-workspace.test.ts", "src/main/ipc/remote-workspace-patch-queue.test.ts", @@ -14152,13 +14152,10 @@ ] }, { - "file": "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", + "file": "src/main/ipc/remote-workspace-two-desktops.test.ts", "assertions": [ - "an unsolicited snapshot synchronously revokes its target's upload authority before preparation awaits", - "a session write while snapshot capture is pending cannot replace the host's newly cached tabs", - "an upload captured before snapshot arrival is revalidated after its pending local write and cannot overwrite the incoming revision", - "same-lineage local writes remain uploadable when an earlier overlapping result advances the renderer's acknowledged revision", - "a transient unavailable result retains its observed host token and revision so the next local edit retries" + "with two desktops on one relay, a local split exports exactly once and the peer's import plus its save-back export nothing", + "a desktop whose import conflicted exports nothing until a whole pull agrees again" ] }, { @@ -14249,13 +14246,13 @@ "summary": "Three main-process remote-workspace files and 17 tests passed, including token-A rejection after same-revision observations before admission and while queued, eviction/refetch fail-closed behavior, same-client overlap at bases 7 then 8, revision-zero compatibility, reset-relay fallback, and bounded cache lineage." }, { - "date": "2026-08-29", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.44, - "summary": "Three renderer remote-workspace files and 28 tests passed, including acknowledged observation-token propagation, incoming-lineage upload cancellation, same-lineage overlapping upload continuation, transient-unavailable retry authority, and latest-only snapshot arrival fencing." + "durationSeconds": 2, + "summary": "Three files and 22 tests passed after main took over SSH workspace export, including two desktops on one relay where an import and its save-back export nothing, a local split exports once, and a conflicted import keeps that desktop out of exports." }, { "date": "2026-08-29", @@ -14267,13 +14264,13 @@ "summary": "Two focused files and 26 tests passed, including pane-scoped rendering and recovery, sibling-error retention, split-close pane-id churn cleanup, current-transport multi-error clearing, dismissal re-admission, multi-line deduplication, and 8-message/24-line/4,000-character bounds." }, { - "date": "2026-08-29", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 10.7, - "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." + "durationSeconds": 9, + "summary": "Fourteen direct-SSH files and 160 tests passed after main took over SSH workspace export, including stale-push fencing, revision-zero seeding through main, and the existing retry, authority, hydration, split-pane and detach contracts." }, { "date": "2026-08-23", @@ -14312,13 +14309,13 @@ "summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory." }, { - "date": "2026-07-28", + "date": "2026-10-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/main/ipc/remote-workspace-two-desktops.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 15.8, - "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." + "durationSeconds": 9, + "summary": "Fourteen direct-SSH files and 160 tests passed after main took over SSH workspace export, including stale-push fencing, revision-zero seeding through main, and the existing retry, authority, hydration, split-pane and detach contracts." }, { "date": "2026-07-28", diff --git a/src/main/ipc/remote-workspace-export-test-driver.ts b/src/main/ipc/remote-workspace-export-test-driver.ts new file mode 100644 index 000000000000..54ca2d424611 --- /dev/null +++ b/src/main/ipc/remote-workspace-export-test-driver.ts @@ -0,0 +1,122 @@ +import { ipcMain } from 'electron' +import { vi } from 'vitest' +import type { Store } from '../persistence' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceExportAuthority, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent +} from '../../shared/remote-workspace-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { registerRemoteWorkspaceHandlers } from './remote-workspace' + +export const EMPTY_WORKSPACE_SESSION: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} +} + +export type RemoteWorkspaceExportDriver = { + handlers: Map unknown> + /** Every push-status report main sent, in order. */ + pushes: RemoteWorkspacePushStatusEvent[] + /** A window's pull of `targetId` with nothing to import. */ + agree: ( + targetId: string, + authority: RemoteWorkspaceExportAuthority, + outcome?: RemoteWorkspacePeerImport['outcome'] + ) => void + importPeer: (pull: RemoteWorkspacePeerImport) => void + /** A session write by anything other than an import, patched onto the one partition. */ + write: (patch: Partial) => void + readSession: () => WorkspaceSessionState + /** Resolves once `count` more push reports have arrived. */ + nextPushes: (count?: number) => Promise +} + +/** + * Registers the remote-workspace handlers against a one-partition store fake whose session writes + * reach main's export trigger, and records the push reports main sends the window. + */ +export function createRemoteWorkspaceExportDriver( + store: Partial, + /** The window's `remoteWorkspace:changed` listener. */ + onChanged: (event: RemoteWorkspaceChangedEvent) => void = () => {} +): RemoteWorkspaceExportDriver { + const handlers = new Map unknown>() + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + let session = EMPTY_WORKSPACE_SESSION + const writeListeners = new Set<() => void>() + const pushes: RemoteWorkspacePushStatusEvent[] = [] + const waiters = new Set<() => void>() + const window = { + isDestroyed: () => false, + webContents: { + send: ( + channel: string, + event: RemoteWorkspacePushStatusEvent | RemoteWorkspaceChangedEvent + ) => { + if (channel === 'remoteWorkspace:changed' && 'snapshot' in event) { + onChanged(event) + } else if (channel === 'remoteWorkspace:pushStatus' && 'authority' in event) { + pushes.push(event) + for (const waiter of waiters) { + waiter() + } + } + } + } + } + const fakeStore = { + getSshTarget: () => undefined, + ...store, + getWorkspaceSession: (hostId?: string | null) => + !hostId || hostId === 'local' ? session : EMPTY_WORKSPACE_SESSION, + patchWorkspaceSession: (patch: Partial) => { + session = { ...session, ...patch } + for (const listener of writeListeners) { + listener() + } + }, + onWorkspaceSessionWritten: (listener: () => void) => { + writeListeners.add(listener) + return () => writeListeners.delete(listener) + } + } + registerRemoteWorkspaceHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every Store member the remote-workspace handlers read. + fakeStore as unknown as Store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window fake exposes only the webContents.send the handlers call. + () => window as never, + { readMachineName: () => 'Build server' } + ) + const importPeer = (pull: RemoteWorkspacePeerImport): void => { + void handlers.get('remoteWorkspace:importPeerTopology')?.(null, pull) + } + return { + handlers, + pushes, + agree: (targetId, authority, outcome = 'synced') => + importPeer({ targetId, ...authority, outcome, patches: [] }), + importPeer, + write: (patch) => fakeStore.patchWorkspaceSession(patch), + readSession: () => session, + nextPushes: (count = 1) => { + const target = pushes.length + count + return new Promise((resolve) => { + const check = (): void => { + if (pushes.length >= target) { + waiters.delete(check) + resolve(pushes.slice(target - count, target)) + } + } + waiters.add(check) + check() + }) + } + } +} diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts new file mode 100644 index 000000000000..9e7d184e8abd --- /dev/null +++ b/src/main/ipc/remote-workspace-export.ts @@ -0,0 +1,171 @@ +import type { Store } from '../persistence' +import type { SshTarget } from '../../shared/ssh-types' +import type { + RemoteWorkspaceExportAuthority, + RemoteWorkspaceObservedPatchResult, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent, + RemoteWorkspaceSession +} from '../../shared/remote-workspace-types' +import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' +import { getActiveMultiplexer, getSshConnectionStore } from './ssh' +import { + createWorktreeOwnerResolver, + createWorktreeTargetResolver, + exportSessionForTarget, + persistedSessionForTarget, + type WorktreeOwnerResolver +} from './remote-workspace-target-session-export' +import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' +import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' +import { + cachedRemoteWorkspaceSnapshotAuthorizesRevision, + getCachedRemoteWorkspaceSnapshot +} from './remote-workspace-snapshot-cache' +import { remoteWorkspaceSessionsMatch } from './remote-workspace-snapshot-normalization' + +/** What this desktop and a host agree the host holds; null when the host's copy is unknown. */ +type HostAgreement = RemoteWorkspaceExportAuthority & { session: RemoteWorkspaceSession | null } + +export type RemoteWorkspaceExports = { + /** Records a window's pull; the import's own writes must already be in the store. */ + recordPull: (pull: Omit) => void + /** Run after every session write: exports to each agreed host whose share of it changed. */ + exportChanged: () => void +} + +/** + * Exports this desktop's session to its SSH hosts. An export goes only when what this desktop holds + * for a host differs from what the two last agreed on, so an import (which sets the agreement) and + * the mirror apply it causes never export, while a local change exports once. + */ +export function createRemoteWorkspaceExports( + store: Store, + sendPushStatus: (event: RemoteWorkspacePushStatusEvent) => void +): RemoteWorkspaceExports { + const agreements = new Map() + let exportQueued = false + + const readWorktreeOwners = (): WorktreeOwnerResolver => + createWorktreeOwnerResolver(createRepoRowExecutionHostLookup(store.getRepos())) + const sessionForTarget = ( + resolveWorktreeOwner: WorktreeOwnerResolver, + targetId: string + ): RemoteWorkspaceSession => + exportSessionForTarget( + createWorktreeTargetResolver(resolveWorktreeOwner), + targetId, + persistedSessionForTarget(store, targetId, resolveWorktreeOwner) + ) + + const exportTo = async ( + target: SshTarget, + session: RemoteWorkspaceSession, + agreement: HostAgreement + ): Promise => { + if ( + agreements.get(target.id) !== agreement || + (agreement.session && remoteWorkspaceSessionsMatch(session, agreement.session)) + ) { + return + } + let result: RemoteWorkspaceObservedPatchResult | null + try { + result = await patchUnderAgreement(target, session, agreement) + } catch (error) { + sendPushStatus({ + targetId: target.id, + authority: agreement, + result: null, + error: error instanceof Error ? error.message : 'Workspace upload failed' + }) + return + } + // An import that landed meanwhile is newer than this export's outcome. + if (agreements.get(target.id) === agreement) { + if (result?.ok) { + const { revision, hostObservationToken } = result.snapshot + agreements.set(target.id, { revision, hostObservationToken, session }) + } else if (result?.reason === 'stale-revision') { + // The host moved on; the window's next pull agrees again. + agreements.delete(target.id) + } + } + sendPushStatus({ targetId: target.id, authority: agreement, result }) + } + + const exportAll = async (): Promise => { + const targets = + getSshConnectionStore() + ?.listTargets() + .filter((target) => agreements.has(target.id) && getActiveMultiplexer(target.id)) ?? [] + if (targets.length === 0) { + return + } + // One catalog read and one ownership resolution per worktree, shared by every target. + const resolveWorktreeOwner = readWorktreeOwners() + await Promise.all( + targets.map((target) => { + const session = sessionForTarget(resolveWorktreeOwner, target.id) + // Each target has its own revision stream; one slow relay must not block another. + return queueRemoteWorkspacePatch(target.id, async () => { + const agreement = agreements.get(target.id) + return agreement ? exportTo(target, session, agreement) : undefined + }) + }) + ) + } + + const exportChanged = (): void => { + // A write burst in one turn exports once. + if (exportQueued) { + return + } + exportQueued = true + queueMicrotask(() => { + exportQueued = false + void exportAll() + }) + } + + const recordPull: RemoteWorkspaceExports['recordPull'] = ({ + targetId, + revision, + hostObservationToken, + outcome + }) => { + if (outcome === 'conflict') { + agreements.delete(targetId) + return + } + // An import agrees on what this desktop now holds, so it never exports back. A host never + // written (revision 0) had nothing to import: agree on its own copy, and anything this desktop + // holds beyond it seeds the host. + const session = + revision === 0 + ? (getCachedRemoteWorkspaceSnapshot(targetId)?.session ?? null) + : sessionForTarget(readWorktreeOwners(), targetId) + agreements.set(targetId, { revision, hostObservationToken, session }) + exportChanged() + } + + return { recordPull, exportChanged } +} + +/** Patches only while the host is still at the revision and observation this desktop agreed on. */ +async function patchUnderAgreement( + target: SshTarget, + session: RemoteWorkspaceSession, + agreement: HostAgreement +): Promise { + const current = getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) + if ( + !current || + current.hostObservationToken !== agreement.hostObservationToken || + !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, agreement.revision) + ) { + const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current + return latest ? { ok: false, reason: 'stale-revision', snapshot: latest } : null + } + return patchRemoteWorkspaceSession(target, session) +} diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index d82ba5e845a5..5d38c7eeb4b2 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -36,9 +36,12 @@ vi.mock('./remote-workspace-events', () => ({ import { _resetRemoteWorkspaceCachesForTests, - handleRemoteWorkspaceNotification, - registerRemoteWorkspaceHandlers + handleRemoteWorkspaceNotification } from './remote-workspace' +import { + createRemoteWorkspaceExportDriver, + type RemoteWorkspaceExportDriver +} from './remote-workspace-export-test-driver' import { CLIENT_ID } from './remote-workspace-client-identity' import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' import { @@ -73,16 +76,20 @@ function patchSession(params: Record): RemoteWorkspaceSession { return (params.patch as { session: RemoteWorkspaceSession }).session } -describe('remoteWorkspace:setForConnectedTargets patch queue', () => { - const handlers = new Map unknown>() +describe('main export patch queue', () => { + let driver: RemoteWorkspaceExportDriver const muxByTargetId = new Map }>() const getRepoMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. const KNOWN_REPO_IDS = ['repo-target-1', 'repo-target-2', 'repo-reset', 'repo-newer'] - const store = { + const store: Partial = { getRepo: getRepoMock, - getRepos: () => KNOWN_REPO_IDS.map((repoId) => getRepoMock(repoId)).filter(Boolean) - } as unknown as Store + getRepos: () => + KNOWN_REPO_IDS.flatMap((repoId) => { + const repo = getRepoMock(repoId) + return repo ? [repo] : [] + }) + } const target: SshTarget = { id: 'target-1', @@ -94,12 +101,8 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { beforeEach(() => { _resetRemoteWorkspaceCachesForTests() - handlers.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) vi.mocked(ipcMain.removeHandler).mockReset() getSshConnectionStoreMock.mockReset() getSshConnectionStoreMock.mockReturnValue({ @@ -123,22 +126,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { getActiveMultiplexerMock.mockImplementation((targetId: string) => muxByTargetId.get(targetId)) registerRemoteWorkspaceNotificationHandlerMock.mockClear() - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + driver = createRemoteWorkspaceExportDriver(store) }) - async function callSetForConnectedTargets(args: { - session: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - function observeSnapshot(targetId: string, value: RemoteWorkspaceSnapshot): string { return rememberRemoteWorkspaceSnapshot(targetId, value).hostObservationToken } @@ -151,7 +141,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { return cached.hostObservationToken } - it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { + it('serializes overlapping exports for the same target so they use fresh base revisions', async () => { let currentRevision = 7 let releaseFirstPatch!: () => void const firstPatchCanFinish = new Promise((resolve) => { @@ -204,43 +194,32 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + const pushes = driver.nextPushes(2) + driver.write(sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a')) await vi.waitFor(() => expect(patchBaseRevisions).toEqual([7])) - const second = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b')) await new Promise((resolve) => setTimeout(resolve, 0)) expect(patchBaseRevisions).toEqual([7]) releaseFirstPatch() - await expect(Promise.all([first, second])).resolves.toMatchObject([ - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 8, hostObservationToken: observationToken } - } + await expect(pushes).resolves.toMatchObject([ + { + targetId: 'target-1', + result: { + ok: true, + snapshot: { revision: 8, hostObservationToken: observationToken } } - ], - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 9, hostObservationToken: observationToken } - } + }, + { + targetId: 'target-1', + authority: { revision: 8 }, + result: { + ok: true, + snapshot: { revision: 9, hostObservationToken: observationToken } } - ] + } ]) expect(patchBaseRevisions).toEqual([7, 8]) }) @@ -272,16 +251,13 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { snapshot: remoteSnapshot, sourceClientId: 'other-client' }) - const result = await callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) + const result = await driver.nextPushes() expect(result).toMatchObject([ { @@ -296,7 +272,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(request).not.toHaveBeenCalled() }) - it('rejects a renderer upload when a host snapshot arrives while it is queued', async () => { + it('drops a queued export when a host snapshot arrives while the first one is in flight', async () => { const remoteSnapshot = snapshot( { activeWorktreePath: '/other-device', @@ -344,21 +320,12 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/first', 'first-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-target-1::/remote/first', 'first-local-tab')) await vi.waitFor(() => expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) ) - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.write(sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab')) await new Promise((resolve) => setTimeout(resolve, 0)) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { @@ -367,14 +334,16 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) releasePatch() - await expect(Promise.all([first, queued])).resolves.toMatchObject([ - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }], - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }] + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } } ]) + // The stale reply ended the agreement; the queued export waits for the window's next pull. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(driver.pushes).toHaveLength(1) expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) }) - it('rejects a queued upload after a same-revision host observation replaces its lineage', async () => { + it('rejects a queued export after a same-revision host observation replaces its lineage', async () => { const baseline = snapshot( { activeWorktreePath: '/baseline', @@ -422,12 +391,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) await blockerDidStart - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + const queued = driver.nextPushes() + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) await new Promise((resolve) => setTimeout(resolve, 0)) handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { snapshot: replacement, @@ -469,14 +435,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { }) muxByTargetId.set('target-1', { request }) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - ).resolves.toMatchObject([ + driver.agree('target-1', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab')) + await expect(driver.nextPushes()).resolves.toMatchObject([ { targetId: 'target-1', result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } } @@ -485,7 +446,7 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { expect(request.mock.calls.map(([method]) => method)).toEqual(['workspace.get']) }) - it('patches independent hydrated targets concurrently', async () => { + it('patches independent agreed targets concurrently', async () => { const secondTarget: SshTarget = { id: 'target-2', label: 'Target 2', @@ -557,33 +518,28 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { const firstObservationToken = observeSnapshot('target-1', previousSnapshot) const secondObservationToken = observeSnapshot('target-2', previousSnapshot) - const resultPromise = callSetForConnectedTargets({ - session: { - ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - tabsByWorktree: { - 'repo-target-1::/remote/workspace-a': [ - { - id: 'tab-a', - type: 'terminal', - title: 'Shell A', - worktreeId: 'repo-target-1::/remote/workspace-a' - } as never - ], - 'repo-target-2::/remote/workspace-b': [ - { - id: 'tab-b', - type: 'terminal', - title: 'Shell B', - worktreeId: 'repo-target-2::/remote/workspace-b' - } as never - ] - } - }, - hydratedTargetIds: ['target-1', 'target-2'], - expectedRevisionsByTargetId: { 'target-1': 7, 'target-2': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': firstObservationToken, - 'target-2': secondObservationToken + driver.agree('target-1', { revision: 7, hostObservationToken: firstObservationToken }) + driver.agree('target-2', { revision: 7, hostObservationToken: secondObservationToken }) + const resultPromise = driver.nextPushes(2) + driver.write({ + ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), + tabsByWorktree: { + 'repo-target-1::/remote/workspace-a': [ + { + id: 'tab-a', + type: 'terminal', + title: 'Shell A', + worktreeId: 'repo-target-1::/remote/workspace-a' + } as never + ], + 'repo-target-2::/remote/workspace-b': [ + { + id: 'tab-b', + type: 'terminal', + title: 'Shell B', + worktreeId: 'repo-target-2::/remote/workspace-b' + } as never + ] } }) @@ -595,9 +551,10 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) releaseFirstPatch() + // The fast target reports first: neither waited on the other. await expect(resultPromise).resolves.toMatchObject([ - { targetId: 'target-1', result: { ok: true } }, - { targetId: 'target-2', result: { ok: true } } + { targetId: 'target-2', result: { ok: true } }, + { targetId: 'target-1', result: { ok: true } } ]) }) @@ -678,14 +635,11 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-reset::/remote/workspace', 'tab-reset'), - hydratedTargetIds: ['target-reset'], - expectedRevisionsByTargetId: { 'target-reset': 7 }, - expectedHostObservationTokensByTargetId: { 'target-reset': observationToken } - }) - ).resolves.toMatchObject([{ targetId: 'target-reset', result: { ok: true } }]) + driver.agree('target-reset', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-reset::/remote/workspace', 'tab-reset')) + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-reset', result: { ok: true } } + ]) expect(patchBaseRevisions).toEqual([7, 0]) }) @@ -760,14 +714,9 @@ describe('remoteWorkspace:setForConnectedTargets patch queue', () => { ) ) - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-newer::/remote/workspace', 'tab-local'), - hydratedTargetIds: ['target-newer'], - expectedRevisionsByTargetId: { 'target-newer': 7 }, - expectedHostObservationTokensByTargetId: { 'target-newer': observationToken } - }) - ).resolves.toMatchObject([ + driver.agree('target-newer', { revision: 7, hostObservationToken: observationToken }) + driver.write(sessionWithTab('repo-newer::/remote/workspace', 'tab-local')) + await expect(driver.nextPushes()).resolves.toMatchObject([ { targetId: 'target-newer', result: { ok: false, reason: 'stale-revision' } } ]) expect(patchBaseRevisions).toEqual([7]) diff --git a/src/main/ipc/remote-workspace-snapshot-normalization.ts b/src/main/ipc/remote-workspace-snapshot-normalization.ts index 749d83cc0663..e35e2c8b6272 100644 --- a/src/main/ipc/remote-workspace-snapshot-normalization.ts +++ b/src/main/ipc/remote-workspace-snapshot-normalization.ts @@ -96,11 +96,13 @@ export function remoteWorkspaceSessionMatchesSnapshot( snapshot: RemoteWorkspaceSnapshot | undefined, session: RemoteWorkspaceSession ): boolean { - if (!snapshot) { - return false - } - return isDeepStrictEqual( - normalizeRemoteSession(snapshot.session), - normalizeRemoteSession(session) - ) + return snapshot !== undefined && remoteWorkspaceSessionsMatch(snapshot.session, session) +} + +/** Equal once empty optional projection fields and absent ones read the same. */ +export function remoteWorkspaceSessionsMatch( + a: RemoteWorkspaceSession, + b: RemoteWorkspaceSession +): boolean { + return isDeepStrictEqual(normalizeRemoteSession(a), normalizeRemoteSession(b)) } diff --git a/src/main/ipc/remote-workspace-stale-resync.test.ts b/src/main/ipc/remote-workspace-stale-resync.test.ts index b87a9a9893f3..d27bf042cfff 100644 --- a/src/main/ipc/remote-workspace-stale-resync.test.ts +++ b/src/main/ipc/remote-workspace-stale-resync.test.ts @@ -65,7 +65,12 @@ function snapshot(revision: number, tabId: string): RemoteWorkspaceSnapshot { describe('workspace.stale resync', () => { const sent: RemoteWorkspaceChangedEvent[] = [] const request = vi.fn() - const store = { getRepo: vi.fn(), getWorkspaceSession: vi.fn() } as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resync reads no store member; registration only subscribes to session writes. + const store = { + getRepo: vi.fn(), + getWorkspaceSession: vi.fn(), + onWorkspaceSessionWritten: vi.fn(() => () => {}) + } as unknown as Store beforeEach(() => { sent.length = 0 diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts new file mode 100644 index 000000000000..9bcd3ca8fb62 --- /dev/null +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -0,0 +1,285 @@ +/** + * Blocking (plan §4.1a "SSH no export loop"): two desktops share one relay. An import, and the + * window's save-back of what it applied, export nothing; a local split on either desktop exports + * exactly once; a conflicted import keeps that desktop out of exports. + * + * Each desktop is its own module graph (own snapshot cache, patch queue and CLIENT_ID), as two + * processes would be. The relay is an in-memory workspace with revisions. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { importRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' +import type * as RemoteWorkspaceModule from './remote-workspace' +import type * as ExportDriverModule from './remote-workspace-export-test-driver' + +type Relay = { + revision: number + session: RemoteWorkspaceSession + patches: { clientId: string; baseRevision: number }[] + listeners: ((snapshot: RemoteWorkspaceSnapshot, clientId: string) => void)[] +} + +const { relay } = vi.hoisted(() => { + const relay: Relay = { + revision: 0, + session: { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }, + patches: [], + listeners: [] + } + return { relay } +}) + +const TARGET: SshTarget = { + id: 'target-1', + label: 'Build box', + host: 'build.example.com', + port: 22, + username: 'dev' +} +const WORKTREE_PATH = '/srv/app' +const WORKTREE_ID = `repo-1::${WORKTREE_PATH}` + +type RelayPatchParams = { + baseRevision: number + clientId: string + patch: { session: RemoteWorkspaceSession } +} + +function relaySnapshot(): RemoteWorkspaceSnapshot { + return { + namespace: 'workspace', + revision: relay.revision, + updatedAt: relay.revision, + schemaVersion: 1, + session: structuredClone(relay.session) + } +} + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() } +})) +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: () => () => {} +})) +vi.mock('./ssh', () => ({ + getSshConnectionStore: () => ({ + listTargets: () => [TARGET], + getTarget: () => TARGET + }), + getActiveMultiplexer: () => ({ + request: async (method: string, { baseRevision, clientId, patch }: RelayPatchParams) => { + if (method === 'workspace.get') { + return relaySnapshot() + } + relay.patches.push({ clientId, baseRevision }) + if (baseRevision !== relay.revision) { + return { + ok: false, + reason: 'stale-revision', + snapshot: relaySnapshot() + } + } + relay.revision += 1 + relay.session = structuredClone(patch.session) + const snapshot = relaySnapshot() + for (const listener of relay.listeners) { + listener(snapshot, clientId) + } + return { ok: true, snapshot } + } + }) +})) + +type Desktop = { + driver: ExportDriverModule.RemoteWorkspaceExportDriver + clientId: string + /** Set to make this desktop's next imports conflicted (unplaceable host tabs). */ + conflicted: boolean + imports: number +} + +function tab(id: string): WorkspaceSessionState['tabsByWorktree'][string][number] { + return { + id, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function withSplit( + session: WorkspaceSessionState, + tabId: string, + direction: 'vertical' | 'horizontal' = 'vertical' +): WorkspaceSessionState { + return { + ...session, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [tabId]: { + root: { + type: 'split', + direction, + first: { type: 'leaf', leafId: `${tabId}-a` }, + second: { type: 'leaf', leafId: `${tabId}-b` } + }, + activeLeafId: `${tabId}-a`, + expandedLeafId: null + } + } + } +} + +async function bootDesktop(): Promise { + vi.resetModules() + const remoteWorkspace: typeof RemoteWorkspaceModule = await import('./remote-workspace') + const { CLIENT_ID } = await import('./remote-workspace-client-identity') + const remoteWorkspaceCache = await import('./remote-workspace-snapshot-cache') + const { createRemoteWorkspaceExportDriver }: typeof ExportDriverModule = + await import('./remote-workspace-export-test-driver') + remoteWorkspace._resetRemoteWorkspaceCachesForTests() + const repo: Repo = { + id: 'repo-1', + path: '/srv', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + connectionId: TARGET.id + } + const desktop: Desktop = { + clientId: CLIENT_ID, + conflicted: false, + imports: 0, + // The window's importer: today's projection of the host snapshot, committed through main, + // then saved back once the window has applied it. + driver: createRemoteWorkspaceExportDriver( + { getRepos: () => [repo] }, + (event: RemoteWorkspaceChangedEvent) => { + if (event.sourceClientId === CLIENT_ID) { + return + } + const imported = importRemoteWorkspaceSession(event.snapshot.session, { + resolveWorktreeId: (path) => (path === WORKTREE_PATH ? WORKTREE_ID : null), + executionHostId: `ssh:${TARGET.id}` + }) + const patch = { + tabsByWorktree: imported.tabsByWorktree, + terminalLayoutsByTabId: imported.terminalLayoutsByTabId + } + desktop.imports += 1 + desktop.driver.importPeer({ + targetId: TARGET.id, + revision: event.snapshot.revision, + hostObservationToken: event.snapshot.hostObservationToken, + outcome: desktop.conflicted ? 'conflict' : 'synced', + patches: [{ hostId: `ssh:${TARGET.id}`, patch }] + }) + desktop.driver.write(patch) + } + ) + } + relay.listeners.push((snapshot, sourceClientId) => + remoteWorkspace.handleRemoteWorkspaceNotification(TARGET.id, 'workspace.changed', { + snapshot, + sourceClientId + }) + ) + // Connect: the window pulls and agrees with the host. + await desktop.driver.handlers.get('remoteWorkspace:get')?.(null, { targetId: TARGET.id }) + const observed = remoteWorkspaceCache.getCachedRemoteWorkspaceSnapshot(TARGET.id) + if (!observed) { + throw new Error('connect did not observe the host') + } + desktop.driver.agree(TARGET.id, observed) + return desktop +} + +async function settle(): Promise { + for (let turn = 0; turn < 5; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('two desktops, one relay', () => { + let a: Desktop + let b: Desktop + + beforeEach(async () => { + relay.revision = 3 + relay.session = { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: { [WORKTREE_PATH]: [] }, + terminalLayoutsByTabId: {} + } + relay.patches = [] + relay.listeners = [] + a = await bootDesktop() + b = await bootDesktop() + await settle() + }) + + it('exports a local split once, and the peer import plus its save-back exports nothing', async () => { + expect(relay.patches).toEqual([]) + + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + expect(b.imports).toBe(1) + + b.driver.write(withSplit(b.driver.readSession(), 'tab-a', 'horizontal')) + await settle() + + expect(relay.patches).toEqual([ + { clientId: a.clientId, baseRevision: 3 }, + { clientId: b.clientId, baseRevision: 4 } + ]) + expect(a.imports).toBe(1) + expect(relay.revision).toBe(5) + }) + + it('keeps a desktop whose import conflicted out of exports', async () => { + b.conflicted = true + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + expect(b.imports).toBe(1) + + b.driver.write(withSplit(b.driver.readSession(), 'tab-a', 'horizontal')) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + }) +}) diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index cc542920dd83..677883f9442c 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -52,10 +52,11 @@ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock })) +import { _resetRemoteWorkspaceCachesForTests } from './remote-workspace' import { - _resetRemoteWorkspaceCachesForTests, - registerRemoteWorkspaceHandlers -} from './remote-workspace' + createRemoteWorkspaceExportDriver, + type RemoteWorkspaceExportDriver +} from './remote-workspace-export-test-driver' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { @@ -76,6 +77,8 @@ const baseSession = { terminalLayoutsByTabId: {} } as WorkspaceSessionState +type PatchParams = { patch: { session: RemoteWorkspaceSession } } + const targets: SshTarget[] = [ { id: 'target-1', @@ -164,29 +167,56 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => { }) }) -describe('remoteWorkspace:setForConnectedTargets', () => { - const handlers = new Map unknown>() +describe('main exports a session write to the hosts it agrees with', () => { const requestByTargetId = new Map>() const muxByTargetId = new Map }>() const getRepoMock = vi.fn() - const getWorkspaceSessionMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. - const getReposMock = vi.fn(() => [getRepoMock('repo-target-1')].filter(Boolean)) - const store = { - getRepo: getRepoMock, - getRepos: getReposMock, - getWorkspaceSession: getWorkspaceSessionMock - } as unknown as Store + const getReposMock = vi.fn(() => { + const repo = getRepoMock('repo-target-1') + return repo ? [repo] : [] + }) + let driver: RemoteWorkspaceExportDriver + + const sessionWithTab: WorkspaceSessionState = { + activeRepoId: 'repo-target-1', + activeWorktreeId: 'repo-target-1::/repo', + activeTabId: 'tab-store', + tabsByWorktree: { + 'repo-target-1::/repo': [ + { + id: 'tab-store', + title: 'Store shell', + ptyId: 'pty-store', + worktreeId: 'repo-target-1::/repo', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: {} + } + + const emptyRemoteSession: RemoteWorkspaceSession = { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + } + + function patchRequests(targetId: string): unknown[][] { + return (requestByTargetId.get(targetId)?.mock.calls ?? []).filter( + ([method]) => method === 'workspace.patch' + ) + } beforeEach(() => { _resetRemoteWorkspaceCachesForTests() - handlers.clear() requestByTargetId.clear() muxByTargetId.clear() vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) vi.mocked(ipcMain.removeHandler).mockReset() getSshConnectionStoreMock.mockReset() getSshConnectionStoreMock.mockReturnValue({ @@ -195,8 +225,6 @@ describe('remoteWorkspace:setForConnectedTargets', () => { }) getRepoMock.mockReset() getReposMock.mockClear() - getWorkspaceSessionMock.mockReset() - getWorkspaceSessionMock.mockReturnValue(baseSession) getRepoMock.mockImplementation((repoId: string) => repoId === 'repo-target-1' ? ({ @@ -213,7 +241,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { getActiveMultiplexerMock.mockImplementation((targetId: string) => { let mux = muxByTargetId.get(targetId) if (!mux) { - const request = vi.fn().mockImplementation((method: string) => { + const request = vi.fn().mockImplementation((method: string, params: PatchParams) => { if (method === 'workspace.get') { return Promise.resolve( snapshot({ @@ -226,12 +254,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { } return Promise.resolve({ ok: true, - snapshot: snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) + snapshot: snapshot(params.patch.session, 8) }) }) mux = { request } @@ -241,209 +264,204 @@ describe('remoteWorkspace:setForConnectedTargets', () => { return mux }) registerRemoteWorkspaceNotificationHandlerMock.mockClear() - - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + driver = createRemoteWorkspaceExportDriver({ getRepo: getRepoMock, getRepos: getReposMock }) }) - async function callSetForConnectedTargets(args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - async function observeTarget(targetId: string): Promise { - const handler = handlers.get('remoteWorkspace:get') - if (!handler) { - throw new Error('remoteWorkspace:get handler was never registered') - } - const observed = await handler(null, { targetId }) + const observed = await driver.handlers.get('remoteWorkspace:get')?.(null, { targetId }) if (!observed || typeof observed !== 'object' || !('hostObservationToken' in observed)) { throw new Error(`remoteWorkspace:get did not observe ${targetId}`) } return observed as RemoteWorkspaceObservedSnapshot } - it('reads the repo catalog once per publish, not once per worktree', async () => { + async function agreeWith(targetId: string): Promise { + const { revision, hostObservationToken } = await observeTarget(targetId) + driver.agree(targetId, { revision, hostObservationToken }) + } + + it('reads the repo catalog once per export, not once per worktree', async () => { // `store.getRepos()` re-hydrates every repo row. The export asks "is this worktree mine?" once // per worktree, so reading the catalog inside that callback multiplied hydration by the // worktree count — 413 on the session that surfaced this. + await agreeWith('target-1') + await Promise.resolve() + getReposMock.mockClear() const worktrees = Object.fromEntries( Array.from({ length: 12 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await observeTarget('target-1') - getReposMock.mockClear() - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': observed.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': observed.hostObservationToken - } - }) + driver.write({ ...baseSession, tabsByWorktree: worktrees }) + await driver.nextPushes() expect(getReposMock).toHaveBeenCalledTimes(1) }) - it('resolves each worktree ownership once for the whole publish, not once per target', async () => { + it('resolves each worktree ownership once for the whole export, not once per target', async () => { // Ownership is a function of the repo catalog alone; only the final `=== targetId` differs, so // exporting to N targets used to repeat the identical resolution N times per worktree key. + for (const target of targets) { + await agreeWith(target.id) + } + await Promise.resolve() + getReposMock.mockClear() + resolveWorktreeExecutionHostCalls.count = 0 const worktrees = Object.fromEntries( Array.from({ length: 6 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await Promise.all(targets.map((target) => observeTarget(target.id))) - getReposMock.mockClear() - resolveWorktreeExecutionHostCalls.count = 0 - await callSetForConnectedTargets({ - hydratedTargetIds: targets.map((target) => target.id), - expectedRevisionsByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].revision]) - ), - expectedHostObservationTokensByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].hostObservationToken]) - ) - }) + driver.write({ ...baseSession, tabsByWorktree: worktrees }) + await driver.nextPushes() expect(getReposMock).toHaveBeenCalledTimes(1) - // 6 worktree keys resolved once each, regardless of how many targets are published to. + // 6 worktree keys resolved once each, regardless of how many targets are exported to. expect(resolveWorktreeExecutionHostCalls.count).toBe(6) }) - it('skips the session and repo-catalog reads when no hydrated target is connected', async () => { - // A hydrated but disconnected target leaves nothing to project onto, so hoisting the catalog - // read must not make the idle path pay for a full repo hydration it never used before. + it('skips the session and repo-catalog reads when no agreed target is connected', async () => { + // A disconnected target leaves nothing to project onto, so a session write must not pay for a + // full repo hydration it never uses. + driver.agree('target-1', { revision: 7, hostObservationToken: 'token' }) getActiveMultiplexerMock.mockReturnValue(undefined) + await Promise.resolve() getReposMock.mockClear() - getWorkspaceSessionMock.mockClear() - await expect( - callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': 'token' } - }) - ).resolves.toEqual([]) + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(getReposMock).not.toHaveBeenCalled() - expect(getWorkspaceSessionMock).not.toHaveBeenCalled() + expect(driver.pushes).toEqual([]) }) - it('does not write without an explicit non-empty hydrated target set', async () => { - await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 } - }) - ).resolves.toEqual([]) - - expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + it('exports nothing to a host this desktop has no valid agreement with', async () => { + driver.importPeer({ + targetId: 'target-1', + revision: -1, + hostObservationToken: 'token', + outcome: 'synced', + patches: [] + }) + driver.importPeer({ + targetId: 'target-1', + revision: 7, + hostObservationToken: '', + outcome: 'synced', + patches: [] + }) + + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(getActiveMultiplexerMock).not.toHaveBeenCalled() + expect(driver.pushes).toEqual([]) }) - it('writes only to explicitly hydrated connected targets', async () => { - const observation = await observeTarget('target-1') - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1', 'missing-target'], - expectedRevisionsByTargetId: { 'target-1': 7, 'missing-target': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken, - 'missing-target': 'unreachable-target-observation' - } - }) + it('writes only to agreed connected targets', async () => { + await agreeWith('target-1') + driver.agree('missing-target', { revision: 7, hostObservationToken: 'unreachable' }) + + driver.write(sessionWithTab) + const [push] = await driver.nextPushes() - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) - expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1') + expect(push).toMatchObject({ targetId: 'target-1', result: { ok: true } }) expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2') - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ kind: 'replace-session' }) - }) - ) + expect(patchRequests('target-1')).toEqual([ + [ + 'workspace.patch', + expect.objectContaining({ patch: expect.objectContaining({ kind: 'replace-session' }) }) + ] + ]) expect(requestByTargetId.get('target-2')).toBeUndefined() }) - it('can export from the persisted store session when no session argument is provided', async () => { - getWorkspaceSessionMock.mockReturnValue({ - activeRepoId: 'repo-target-1', - activeWorktreeId: 'repo-target-1::/repo', - activeTabId: 'tab-store', - tabsByWorktree: { - 'repo-target-1::/repo': [ - { - id: 'tab-store', - title: 'Store shell', - ptyId: 'pty-store', - worktreeId: 'repo-target-1::/repo' - } as never - ] - }, - terminalLayoutsByTabId: {} - }) + it('exports the persisted store session', async () => { + await agreeWith('target-1') - const observation = await observeTarget('target-1') - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken - } - }) + driver.write(sessionWithTab) + await driver.nextPushes() - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ - session: expect.objectContaining({ - activeWorktreePath: '/repo', - activeTabId: 'tab-store' + expect(patchRequests('target-1')).toEqual([ + [ + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ + session: expect.objectContaining({ + activeWorktreePath: '/repo', + activeTabId: 'tab-store' + }) }) }) - }) - ) + ] + ]) }) - it('does not invalidate an upload authority when an unchanged snapshot is polled', async () => { + it('does not invalidate an agreement when an unchanged snapshot is polled', async () => { const first = await observeTarget('target-1') const second = await observeTarget('target-1') expect(second.hostObservationToken).toBe(first.hostObservationToken) + driver.agree('target-1', first) - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': first.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': first.hostObservationToken - } + driver.write(sessionWithTab) + + await expect(driver.nextPushes()).resolves.toMatchObject([ + { targetId: 'target-1', result: { ok: true } } + ]) + }) + + it('exports nothing for an import, and once for the next local change', async () => { + const observed = await observeTarget('target-1') + driver.importPeer({ + ...observed, + targetId: 'target-1', + outcome: 'synced', + patches: [{ patch: sessionWithTab }] }) + // The window saves back what it applied from the import. + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toEqual([]) + + driver.write({ ...sessionWithTab, activeTabId: null }) + await driver.nextPushes() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it('keeps a conflicted target out of exports until a whole pull agrees again', async () => { + const observed = await observeTarget('target-1') + driver.agree('target-1', observed) + driver.agree('target-1', observed, 'conflict') + + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toEqual([]) - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) + driver.agree('target-1', observed) + driver.write({ ...sessionWithTab, activeTabId: null }) + await driver.nextPushes() + expect(patchRequests('target-1')).toHaveLength(1) + }) + + it.each([ + ['seeds an empty host from a desktop that has tabs for it', true], + ['leaves an empty host alone when this desktop has nothing for it', false] + ])('%s', async (_name, hasTabs) => { + const request = vi.fn(async (method: string, params: Partial) => + method === 'workspace.get' + ? snapshot(emptyRemoteSession, 0) + : { ok: true, snapshot: snapshot(params.patch?.session ?? emptyRemoteSession, 1) } + ) + muxByTargetId.set('target-1', { request }) + requestByTargetId.set('target-1', request) + driver.write(hasTabs ? sessionWithTab : baseSession) + const observed = await observeTarget('target-1') + expect(observed.revision).toBe(0) + + driver.agree('target-1', observed) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toHaveLength(hasTabs ? 1 : 0) + expect(driver.pushes).toMatchObject( + hasTabs ? [{ targetId: 'target-1', authority: { revision: 0 }, result: { ok: true } }] : [] + ) }) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 43441b381bee..d9eecc99139a 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -5,31 +5,23 @@ import { REMOTE_WORKSPACE_CHANGED_NOTIFICATION, REMOTE_WORKSPACE_STALE_NOTIFICATION, type RemoteWorkspaceChangedEvent, - type RemoteWorkspaceObservedPatchResult, - type RemoteWorkspaceObservedSnapshot + type RemoteWorkspaceObservedSnapshot, + type RemoteWorkspacePeerImport, + type RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' -import { - createWorktreeOwnerResolver, - createWorktreeTargetResolver, - exportSessionForTarget, - persistedSessionForTarget -} from './remote-workspace-target-session-export' +import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { createRemoteWorkspaceExports } from './remote-workspace-export' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { CLIENT_ID, type RemoteWorkspaceClientNameSource } from './remote-workspace-client-identity' import { listRemoteWorkspaceConnectedClients } from './remote-workspace-connected-clients' import { clearRemoteWorkspacePatchTails, - getRemoteWorkspacePatchTailCount, - queueRemoteWorkspacePatch + getRemoteWorkspacePatchTailCount } from './remote-workspace-patch-queue' -import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' +import { getRemoteSnapshot } from './remote-workspace-relay-sync' import { - cachedRemoteWorkspaceSnapshotAuthorizesRevision, clearRemoteWorkspaceSnapshotCache, - getCachedRemoteWorkspaceSnapshot, getRemoteWorkspaceSnapshotCacheSize, rememberLocallyPatchedRemoteWorkspaceSnapshot, rememberRemoteWorkspaceSnapshot @@ -42,6 +34,7 @@ import { let mainWindowGetter: (() => BrowserWindow | null) | null = null let unregisterRemoteWorkspaceNotifications: (() => void) | null = null +let unsubscribeSessionWrites: (() => void) | null = null export function _resetRemoteWorkspaceCachesForTests(): void { clearRemoteWorkspaceSnapshotCache() @@ -59,51 +52,19 @@ export function _getRemoteWorkspaceCacheSizesForTests(): { } } -function getExplicitHydratedTargetIds(value: unknown): Set | null { - if ( - !Array.isArray(value) || - value.length === 0 || - value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0) - ) { - return null - } - return new Set(value) -} - -function getExpectedTargetRevisions( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const revisions = new Map() - for (const targetId of targetIds) { - const revision = (value as Record)[targetId] - if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) { - return null - } - revisions.set(targetId, revision) - } - return revisions -} - -function getExpectedHostObservationTokens( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const tokens = new Map() - for (const targetId of targetIds) { - const token = (value as Record)[targetId] - if (typeof token !== 'string' || token.length === 0 || token.length > 128) { - return null - } - tokens.set(targetId, token) - } - return tokens +/** A malformed pull records nothing, so the target stays out of exports until a valid one. */ +function isValidPeerImport(pull: RemoteWorkspacePeerImport): boolean { + return ( + typeof pull.targetId === 'string' && + pull.targetId.length > 0 && + Number.isSafeInteger(pull.revision) && + pull.revision >= 0 && + typeof pull.hostObservationToken === 'string' && + pull.hostObservationToken.length > 0 && + pull.hostObservationToken.length <= 128 && + (pull.outcome === 'synced' || pull.outcome === 'conflict') && + Array.isArray(pull.patches) + ) } function sendRemoteWorkspaceChanged( @@ -122,6 +83,13 @@ function sendRemoteWorkspaceChanged( } } +function sendRemoteWorkspacePushStatus(event: RemoteWorkspacePushStatusEvent): void { + const win = mainWindowGetter?.() + if (win && !win.isDestroyed()) { + win.webContents.send('remoteWorkspace:pushStatus', event) + } +} + export function handleRemoteWorkspaceNotification( targetId: string, method: string, @@ -167,8 +135,11 @@ export function registerRemoteWorkspaceHandlers( unregisterRemoteWorkspaceNotifications = registerRemoteWorkspaceNotificationHandler( handleRemoteWorkspaceNotification ) + const exports = createRemoteWorkspaceExports(store, sendRemoteWorkspacePushStatus) + unsubscribeSessionWrites?.() + unsubscribeSessionWrites = store.onWorkspaceSessionWritten(exports.exportChanged) ipcMain.removeHandler('remoteWorkspace:get') - ipcMain.removeHandler('remoteWorkspace:setForConnectedTargets') + ipcMain.removeHandler('remoteWorkspace:importPeerTopology') ipcMain.removeHandler('remoteWorkspace:listEnabledConnectedTargets') ipcMain.removeHandler('remoteWorkspace:listConnectedClients') ipcMain.removeHandler('remoteWorkspace:clientId') @@ -181,91 +152,20 @@ export function registerRemoteWorkspaceHandlers( return getRemoteSnapshot(target) }) + // The window imports a host's snapshot through main, so the import's writes are main's commit + // and the agreement they set keeps them from exporting back. ipcMain.handle( - 'remoteWorkspace:setForConnectedTargets', - async ( - _event, - args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - } - ) => { - const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) - if (!hydratedTargetIds) { - // Why: an omitted hydration set used to broadcast one session to every - // SSH target, overwriting unrelated remote workspace snapshots. - return [] - } - const expectedRevisions = getExpectedTargetRevisions( - args.expectedRevisionsByTargetId, - hydratedTargetIds - ) - if (!expectedRevisions) { - return [] + 'remoteWorkspace:importPeerTopology', + (_event, pull: RemoteWorkspacePeerImport | undefined) => { + if (!pull || !isValidPeerImport(pull)) { + return } - const expectedHostObservationTokens = getExpectedHostObservationTokens( - args.expectedHostObservationTokensByTargetId, - hydratedTargetIds - ) - if (!expectedHostObservationTokens) { - return [] + for (const { hostId, patch } of pull.patches) { + if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { + store.patchWorkspaceSession(patch, hostId) + } } - const targets = - getSshConnectionStore() - ?.listTargets() - .filter( - (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) - ) ?? [] - - if (targets.length === 0) { - // Nothing to project onto, so skip the session and repo-catalog reads entirely. - return [] - } - - // One repo read, and ownership resolutions shared across targets: neither depends on the - // target. The publish fallback's catalog attribution reads the same lookup for the same - // reason — building it per target re-hydrates every repo row once per connected host. - const resolveWorktreeOwner = createWorktreeOwnerResolver( - createRepoRowExecutionHostLookup(store.getRepos()) - ) - const resolveWorktreeTarget = createWorktreeTargetResolver(resolveWorktreeOwner) - const results = await Promise.all( - targets.map(async (target) => { - // Why: each target has its own revision stream. Keep same-target - // writes queued, but do not let one slow relay block others. - const session = exportSessionForTarget( - resolveWorktreeTarget, - target.id, - args.session ?? persistedSessionForTarget(store, target.id, resolveWorktreeOwner) - ) - const result = await queueRemoteWorkspacePatch(target.id, async () => { - const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) - const expectedRevision = expectedRevisions.get(target.id) - const expectedHostObservationToken = expectedHostObservationTokens.get(target.id) - if ( - !current || - expectedRevision === undefined || - expectedHostObservationToken === undefined || - current.hostObservationToken !== expectedHostObservationToken || - !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, expectedRevision) - ) { - const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current - return latest - ? ({ ok: false, reason: 'stale-revision', snapshot: latest } as const) - : null - } - return patchRemoteWorkspaceSession(target, session) - }) - return result ? { targetId: target.id, result } : null - }) - ) - return results.filter( - (entry): entry is { targetId: string; result: RemoteWorkspaceObservedPatchResult } => - entry !== null - ) + exports.recordPull(pull) } ) diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts index 1ec5167f866b..bf5ff494392a 100644 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ b/src/main/ipc/ssh-host-partition-session-export.test.ts @@ -1,10 +1,8 @@ import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' /** - * What the remote-workspace export publishes when the renderer omits `session`, against the real - * `Store`. + * What main's remote-workspace export publishes after a session write, against the real `Store`. * - * The shipping debounced writer takes that fallback on every session write, and it used to read the - * 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to + * The export reads main's persisted session, and it used to read the 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to * `ssh:` was projected as an explicit empty tab list. The upload is a * `replace-session` patch, which turns that absence into deletion on the host (#12721, #18173). * @@ -174,13 +172,6 @@ function createStrandedStore(): InstanceType { ...getDefaultWorkspaceSession(), tabsByWorktree: { [WORKTREE_ID]: [] } }) - store.setWorkspaceSession( - { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } - }, - SSH_HOST_ID - ) store.setWorkspaceSession( { ...getDefaultWorkspaceSession(), @@ -195,11 +186,12 @@ function createStrandedStore(): InstanceType { return store } -async function publishToConnectedTarget(store: InstanceType): Promise { +/** The window agrees with the host, then the runtime writes its tab into `ssh:`. */ +async function publishRuntimeWrite(store: InstanceType): Promise { registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) const get = ipcHandlers.get('remoteWorkspace:get') - const set = ipcHandlers.get('remoteWorkspace:setForConnectedTargets') - if (!get || !set) { + const importPeerTopology = ipcHandlers.get('remoteWorkspace:importPeerTopology') + if (!get || !importPeerTopology) { throw new Error('remote workspace handlers were never registered') } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the remote-workspace get handler answers with this revision/token pair; the IPC return type is unknown. @@ -207,19 +199,28 @@ async function publishToConnectedTarget(store: InstanceType): Prom revision: number hostObservationToken: string } - await set(null, { - // The shipping debounced writer omits `session` and relies on the main-side fallback. - hydratedTargetIds: [TARGET_ID], - expectedRevisionsByTargetId: { [TARGET_ID]: observed.revision }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: observed.hostObservationToken } + await importPeerTopology(null, { + targetId: TARGET_ID, + ...observed, + outcome: 'synced', + patches: [] }) + const revisionBefore = hostSnapshot.revision + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } + }, + SSH_HOST_ID + ) + await vi.waitFor(() => expect(hostSnapshot.revision).toBe(revisionBefore + 1)) } -describe('remoteWorkspace:setForConnectedTargets session fallback', () => { +describe('main export reads the target ssh partition', () => { it('publishes tabs the runtime persisted into the target ssh partition', async () => { const store = createStrandedStore() - await publishToConnectedTarget(store) + await publishRuntimeWrite(store) expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]?.map((tab) => tab.id)).toEqual([ 'tab-runtime' @@ -231,7 +232,7 @@ describe('remoteWorkspace:setForConnectedTargets session fallback', () => { // publishing one for a populated worktree is what destroyed the host's copy on every launch. const store = createStrandedStore() - await publishToConnectedTarget(store) + await publishRuntimeWrite(store) expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([]) }) diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts index 2a7ca528d211..a5a7bab8ad0a 100644 --- a/src/preload/api/remote-workspace-bridge.ts +++ b/src/preload/api/remote-workspace-bridge.ts @@ -1,11 +1,13 @@ import { ipcRenderer } from 'electron' -import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspacePushStatusEvent +} from '../../shared/remote-workspace-types' import type { PreloadApi } from '../api-types' export const remoteWorkspaceApi = { get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + importPeerTopology: (pull) => ipcRenderer.invoke('remoteWorkspace:importPeerTopology', pull), listEnabledConnectedTargets: () => ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), @@ -15,5 +17,11 @@ export const remoteWorkspaceApi = { callback(data) ipcRenderer.on('remoteWorkspace:changed', listener) return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) + }, + onPushStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspacePushStatusEvent) => + callback(data) + ipcRenderer.on('remoteWorkspace:pushStatus', listener) + return () => ipcRenderer.removeListener('remoteWorkspace:pushStatus', listener) } } satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 97e97d3f27f9..2e277419b743 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -12,8 +12,9 @@ import type { import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport, + RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' export type WorkspaceSessionApi = { @@ -51,17 +52,15 @@ export type WorkspaceSessionApi = { } remoteWorkspace: { get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> + /** Commits a pull's session writes in main and records what this desktop and the host agree on. */ + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise listEnabledConnectedTargets: () => Promise listConnectedClients: (args?: { targetIds?: string[] }) => Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> clientId: () => Promise onChanged: (callback: (event: RemoteWorkspaceChangedEvent) => void) => () => void + /** Main's report of each export it ran. */ + onPushStatus: (callback: (event: RemoteWorkspacePushStatusEvent) => void) => () => void } } diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx deleted file mode 100644 index 3b65dbd380f3..000000000000 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ /dev/null @@ -1,496 +0,0 @@ -// @vitest-environment happy-dom - -/** - * The data-safety end of STA-3593. Hydrating a target authorises workspace uploads - * (`use-app-session-persistence.ts`), an upload is a `replace-session` patch - * (`remote-workspace-relay-sync.ts`), and the host applies that by wholesale replacement. So a - * client that hydrated on a snapshot whose tab rows it could not place would upload its incomplete - * projection and DELETE the host tabs it failed to adopt. - * - * That is the asymmetry the design turns on: a suppressed upload is deferred and recoverable, a - * wiped host snapshot is not. This asserts the upload itself never names an unplaced target — - * through the real persistence gate, not the hydrated flag — so it still bites if the gating moves. - */ -import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' -import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' -import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' -import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -import { useAppStore } from '@/store' -import { useAppSessionPersistence } from './use-app-session-persistence' -import * as workspaceSessionHostPersistence from '../lib/workspace-session-host-persistence' - -const TARGET_ID = 'target-a' -const REPO_ROOT = '/srv/proj' -const HOST_PATH = `${REPO_ROOT}/alpha` -const WORKTREE_ID = `repo-a::${HOST_PATH}` -/** Long enough that any deferred retry or debounce would have fired if one existed. */ -const SETTLE_MS = 12_000 -/** Longer than the post-apply session-write suppression in remote-workspace-snapshot-apply.ts. */ -const WRITE_SUPPRESSION_MS = 1_500 -const DEBOUNCE_MS = 300 - -const owner: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -function snapshot( - revision = 4, - hostObservationToken = `observation-${revision}` -): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken, - session: { - activeWorktreePath: HOST_PATH, - activeTabId: 'T1', - tabsByWorktreePath: { - [HOST_PATH]: [0, 1].map((index) => ({ - id: `T${index + 1}`, - worktreePath: HOST_PATH, - ptyId: `pty-T${index + 1}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {} - } - } -} - -type UploadArgs = { - hydratedTargetIds?: string[] - expectedRevisionsByTargetId?: Record - expectedHostObservationTokensByTargetId?: Record - session?: unknown -} -type UploadResponse = { targetId: string; result: RemoteWorkspaceObservedPatchResult }[] -const uploads = vi.fn(async (_args: UploadArgs): Promise => []) - -type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -function installWindowApi(sessionPatch = vi.fn(async () => {})): void { - ;(window as unknown as { api: unknown }).api = { - session: { patch: sessionPatch, set: vi.fn(async () => {}) }, - remoteWorkspace: { setForConnectedTargets: uploads }, - app: { stageBeforeUnloadSync: vi.fn() }, - ui: { onWindowCloseRequested: vi.fn(() => () => {}) } - } -} - -function seedStore(withHostCatalog: boolean): void { - useAppStore.setState({ - ...useAppStore.getInitialState(), - workspaceSessionReady: true, - hydrationSucceeded: true, - repos: [ - { - id: 'repo-a', - path: REPO_ROOT, - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID, - executionHostId: `ssh:${TARGET_ID}` - } as never - ], - worktreesByRepo: withHostCatalog - ? { - 'repo-a': [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repo-a', - path: HOST_PATH, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - : {}, - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: {}, - tabsByWorktree: {}, - reconnectPersistedTerminals: (async () => {}) as never - }) -} - -function preparationInput( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number -): DirectSshPreparationInput { - return { - ...authority, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: `ssh:${TARGET_ID}` }], - authorityRequirement: 'required', - reason, - snapshotRevision - } -} - -function createSync( - capturePreparationInput = async ( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number - ): Promise => preparationInput(authority, reason, snapshotRevision) -) { - return createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: { - get: vi.fn(async () => snapshot()), - setForConnectedTargets: uploads as never - }, - getCurrentAuthority: () => owner, - isPreparationTokenCurrent: () => true, - capturePreparationInput, - prepareOnly: async (input) => ({ - status: 'degraded' as const, - token: { - authority: owner, - catalogRevision: 1, - repoFingerprint: 'fp', - authorityRequirement: 'required' as const, - snapshotRevision: input.snapshotRevision ?? null, - outcome: 'degraded' as const - }, - repoOutcomes: { - complete: 0, - 'non-authoritative': 1, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - }, - lineageOutcome: 'degraded' as const - }), - finalizeHydratedTerminals: () => 0 - }) -} - -// The initial full write must settle before measured upload responses are installed. -async function renderSettledPersistence() { - const pendingWrites: Promise[] = [] - const persistByHost = workspaceSessionHostPersistence.patchWorkspaceSessionByHost - const observeWrite = vi.spyOn(workspaceSessionHostPersistence, 'patchWorkspaceSessionByHost') - observeWrite.mockImplementation((...args) => { - const result = persistByHost(...args) - pendingWrites.push(result.written) - return result - }) - try { - installWindowApi() - const persistence = renderHook(() => useAppSessionPersistence()) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(pendingWrites).toHaveLength(1) - await Promise.all(pendingWrites) - expect(uploads).not.toHaveBeenCalled() - return persistence - } finally { - await Promise.allSettled(pendingWrites) - observeWrite.mockRestore() - } -} - -/** A local edit the persistence subscriber must want to write, then let it settle. */ -async function touchSessionAndSettle(marker: string): Promise { - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: marker }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) -} - -async function flushMicrotasks(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -function uploadedTargetIds(): string[] { - return uploads.mock.calls.flatMap((call) => call[0]?.hydratedTargetIds ?? []) -} - -function authorizeUploadsAtRevision(revision: number): void { - useAppStore.setState({ - remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID]), - remoteWorkspaceSyncStatusByTargetId: { - [TARGET_ID]: { - phase: 'synced', - direction: 'pull', - revision, - hostObservationToken: `observation-${revision}` - } - } - }) -} - -let nextCaseTime = Date.now() - -beforeEach(() => { - vi.useFakeTimers({ now: nextCaseTime }) - uploads.mockReset() - uploads.mockResolvedValue([]) - installWindowApi() -}) - -afterEach(async () => { - try { - cleanup() - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - expect(isDirectSshRemoteWorkspaceApplyInProgress()).toBe(false) - } finally { - // The shared apply deadline must not outlive a rollback of this fixture's clock. - nextCaseTime = Date.now() - vi.useRealTimers() - } -}) - -describe('uploads from a client that could not place the host tabs', () => { - it('cancels a captured upload when a snapshot arrives during its local write', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const pendingLocalWrite = deferred() - const sessionPatch = vi.fn(() => pendingLocalWrite.promise) - installWindowApi(sessionPatch) - const persistence = renderHook(() => useAppSessionPersistence()) - - useAppStore.setState({ activeTabId: 'before-snapshot' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalled() - - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - pendingLocalWrite.resolve() - await Promise.resolve() - await Promise.resolve() - - expect( - uploadedTargetIds(), - 'an upload captured before the incoming revision overwrote its cached tabs' - ).not.toContain(TARGET_ID) - - sync.stop() - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - persistence.unmount() - }) - - it('excludes an incoming snapshot target while preparation is pending', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const persistence = renderHook(() => useAppSessionPersistence()) - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await touchSessionAndSettle('capture-pending') - - expect( - uploadedTargetIds(), - 'the cached incoming revision was overwritten before its tabs could be applied' - ).not.toContain(TARGET_ID) - - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - sync.stop() - persistence.unmount() - }) - - it('issues no upload for a target whose host tabs it could not place', async () => { - seedStore(false) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - // Deliberately not asserting the placement verdict first: the oracle is the upload, and a - // precondition on the verdict would fail ahead of it and hide whether the upload gate holds. - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await vi.advanceTimersByTimeAsync(10_000) - await pendingApply - expect( - useAppStore.getState().tabsByWorktree[WORKTREE_ID], - 'the host named two terminals and this client placed neither' - ).toBeUndefined() - - // Before the client has settled: it is not authoritative for this target yet. - await touchSessionAndSettle('mid-chain') - expect( - uploadedTargetIds(), - 'a client that adopted none of the host tabs uploaded over them' - ).not.toContain(TARGET_ID) - - // Still no upload once everything has settled: not adopting is not permission to overwrite the host. - await vi.advanceTimersByTimeAsync(SETTLE_MS) - expect( - useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]?.phase, - 'a client that placed none of the host tabs reported a healthy sync' - ).not.toBe('synced') - await touchSessionAndSettle('settled') - expect( - uploadedTargetIds(), - 'settling authorised a replace-session upload built from an incomplete projection' - ).not.toContain(TARGET_ID) - - sync.stop() - persistence.unmount() - }) - - it('does upload for a target whose host tabs it did place', async () => { - seedStore(true) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - await sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - // Adoption is the observable outcome; the apply reports nothing back by design. - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ - 'T1', - 'T2' - ]) - - await touchSessionAndSettle('placed') - - // Without this the suppression assertions above would pass on a harness that never uploads. - expect(uploadedTargetIds()).toContain(TARGET_ID) - expect(uploads).toHaveBeenCalledWith( - expect.objectContaining({ - expectedRevisionsByTargetId: { [TARGET_ID]: 4 }, - expectedHostObservationTokensByTargetId: { - [TARGET_ID]: 'observation-4' - } - }) - ) - - sync.stop() - persistence.unmount() - }) - - it('keeps a later same-lineage upload after the earlier result advances the revision', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - const secondLocalWrite = deferred() - let localWriteCount = 0 - const sessionPatch = vi.fn(() => { - localWriteCount += 1 - return localWriteCount === 2 ? secondLocalWrite.promise : Promise.resolve() - }) - const firstUpload = deferred() - uploads.mockImplementationOnce(() => firstUpload.promise) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(9, 'observation-7') } - } - ]) - installWindowApi(sessionPatch) - - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: 'first-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(uploads).toHaveBeenCalledOnce() - - useAppStore.setState({ activeTabId: 'second-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalledTimes(2) - expect(uploads).toHaveBeenCalledOnce() - - firstUpload.resolve([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - secondLocalWrite.resolve() - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(uploads.mock.calls[1][0]).toMatchObject({ - expectedRevisionsByTargetId: { [TARGET_ID]: 7 }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: 'observation-7' } - }) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 9, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) - - it('retains transient upload authority so the next local edit retries', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: false, reason: 'unavailable', message: 'temporary relay failure' } - } - ]) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - - await touchSessionAndSettle('transient-failure') - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'offline', - revision: 7, - hostObservationToken: 'observation-7' - }) - - await touchSessionAndSettle('retry-after-transient-failure') - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) -}) diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6574438265de..ee36a2c89467 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -4,7 +4,6 @@ import { isDirectSshRemoteWorkspaceApplyInProgress, onDirectSshRemoteWorkspaceApplyWindowClosed } from '../hooks/remote-workspace-snapshot-apply' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -37,84 +36,13 @@ import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_ABORTED_EVENT, ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' -import type { AppState } from '../store/types' -import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' -import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 -type RemoteWorkspaceUploadAuthority = { - targetId: string - revision: number - updatedAt?: number - hostObservationToken: string -} - -function captureRemoteWorkspaceUploadAuthorities( - state: AppState -): RemoteWorkspaceUploadAuthority[] { - return Array.from(state.remoteWorkspaceHydratedTargetIds).flatMap((targetId) => { - const syncStatus = state.remoteWorkspaceSyncStatusByTargetId[targetId] - const revision = syncStatus?.revision - const hostObservationToken = syncStatus?.hostObservationToken - if ( - syncStatus?.phase === 'conflict' || - typeof revision !== 'number' || - !Number.isSafeInteger(revision) || - revision < 0 || - typeof hostObservationToken !== 'string' || - hostObservationToken.length === 0 - ) { - return [] - } - return [ - { - targetId, - revision, - updatedAt: syncStatus.updatedAt, - hostObservationToken - } - ] - }) -} - -function remoteWorkspaceUploadAuthorityIsCurrent( - state: AppState, - authority: RemoteWorkspaceUploadAuthority -): boolean { - const status = state.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - return ( - state.remoteWorkspaceHydratedTargetIds.has(authority.targetId) && - status?.phase !== 'conflict' && - status?.hostObservationToken === authority.hostObservationToken - ) -} - -function captureUploadedDirectSshLayoutEdits( - pendingLayoutEdits: AppState['pendingDirectSshLayoutEditsByTabId'], - targetId: string, - result: RemoteWorkspaceObservedPatchResult | undefined -): Record { - if (!result?.ok) { - return {} - } - return Object.fromEntries( - Object.entries(pendingLayoutEdits).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) -} - /** - * Writes durable renderer session state to disk: the debounced per-host writer, the remote - * workspace upload chain, and the synchronous shutdown checkpoint. + * Writes durable renderer session state to disk: the debounced per-host writer and the + * synchronous shutdown checkpoint. Main exports what it holds to SSH hosts. */ export function useAppSessionPersistence(): void { useEffect(() => registerUpdaterBeforeUnloadBypass(), []) @@ -126,79 +54,9 @@ export function useAppSessionPersistence(): void { shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, onPersistError: (error) => console.warn('[session] Session patch failed:', error), - persist: ({ patch }) => { - const state = useAppStore.getState() - // Why: route each host's worktree-scoped slice to its own partition; the upload chain below orders after the local write. - const { localWrite, written } = patchWorkspaceSessionByHost( - window.api.session, - patch, - state - ) - const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) - const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId - if (uploadAuthorities.length > 0) { - void (async () => { - try { - await localWrite - const currentState = useAppStore.getState() - const currentAuthorities = uploadAuthorities.filter((authority) => - remoteWorkspaceUploadAuthorityIsCurrent(currentState, authority) - ) - if (currentAuthorities.length === 0) { - return - } - const hydratedTargetIds = currentAuthorities.map(({ targetId }) => targetId) - const expectedRevisionsByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, revision }) => [targetId, revision]) - ) - const expectedHostObservationTokensByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, hostObservationToken }) => [ - targetId, - hostObservationToken - ]) - ) - const results = await window.api.remoteWorkspace?.setForConnectedTargets({ - hydratedTargetIds, - expectedRevisionsByTargetId, - expectedHostObservationTokensByTargetId - }) - const resultState = useAppStore.getState() - const currentAuthorityByTargetId = new Map( - currentAuthorities.map((authority) => [authority.targetId, authority]) - ) - for (const { targetId, result } of results ?? []) { - const authority = currentAuthorityByTargetId.get(targetId) - if (authority && remoteWorkspaceUploadAuthorityIsCurrent(resultState, authority)) { - if (result?.ok) { - resultState.acknowledgeDirectSshLayoutEdits( - captureUploadedDirectSshLayoutEdits(pendingLayoutEdits, targetId, result) - ) - } - applyRemoteWorkspacePushStatus(resultState, targetId, result, authority) - } - } - } catch (err) { - const errorState = useAppStore.getState() - for (const authority of uploadAuthorities) { - if (!remoteWorkspaceUploadAuthorityIsCurrent(errorState, authority)) { - continue - } - const currentStatus = - errorState.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - errorState.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - direction: 'push', - revision: currentStatus?.revision ?? authority.revision, - updatedAt: currentStatus?.updatedAt ?? authority.updatedAt, - hostObservationToken: authority.hostObservationToken, - message: err instanceof Error ? err.message : 'Workspace upload failed' - }) - } - } - })() - } - return written - } + // Why: route each host's worktree-scoped slice to its own partition. + persist: ({ patch }) => + patchWorkspaceSessionByHost(window.api.session, patch, useAppStore.getState()) }) }, []) diff --git a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts index 3884704d2a1e..ba6576448fc5 100644 --- a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts +++ b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest' import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport } from '../../../../shared/remote-workspace-types' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' import type { AppState } from '../../store/types' @@ -135,15 +135,9 @@ export function appState(overrides: Record = {}): AppState { export function createHarness( state: AppState, - get: (args: { targetId: string }) => Promise, - patchResult: RemoteWorkspaceObservedPatchResult = { ok: true, snapshot: snapshot(1) } + get: (args: { targetId: string }) => Promise ) { - const setForConnectedTargets = vi.fn(async () => [ - { - targetId: owner.targetId, - result: patchResult - } - ]) + const importPeerTopology = vi.fn(async (_pull: RemoteWorkspacePeerImport) => {}) let current = true let catalogRevision = 1 const stateListeners = new Set<(current: AppState, previous: AppState) => void>() @@ -191,7 +185,7 @@ export function createHarness( return () => stateListeners.delete(listener) } }, - remoteWorkspace: { get, setForConnectedTargets }, + remoteWorkspace: { get, importPeerTopology }, getCurrentAuthority: () => (current ? owner : null), isPreparationTokenCurrent: (candidate) => current && candidate.catalogRevision === catalogRevision, @@ -201,7 +195,7 @@ export function createHarness( }) return { sync, - setForConnectedTargets, + importPeerTopology, publishState, capturePreparationInput, prepareOnly, diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts index 161d6eaf576a..5a05d75e183e 100644 --- a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts @@ -1,4 +1,5 @@ import { useAppStore } from '../../store' +import { applyRemoteWorkspacePushStatusEvent } from '../remote-workspace-push-status' import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' export function registerRemoteWorkspaceIpcBridge( @@ -45,6 +46,9 @@ export function registerRemoteWorkspaceIpcBridge( }) }) })() - }) + }), + window.api.remoteWorkspace.onPushStatus((event) => + applyRemoteWorkspacePushStatusEvent(useAppStore.getState(), event) + ) ) } diff --git a/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts b/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts new file mode 100644 index 000000000000..093090e570f4 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-push-status-event.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RemoteWorkspacePushStatusEvent } from '../../../shared/remote-workspace-types' +import { i18n } from '@/i18n/i18n' +import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' +import { appState, snapshot } from './__tests__/remote-workspace-target-sync-test-harness' +import { applyRemoteWorkspacePushStatusEvent } from './remote-workspace-push-status' + +const TOKEN = snapshot(4).hostObservationToken + +function hydratedState(overrides: Parameters[0] = {}) { + return appState({ + remoteWorkspaceHydratedTargetIds: new Set(['target-a']), + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'synced', revision: 4, hostObservationToken: TOKEN } + }, + ...overrides + }) +} + +function pushed(result: RemoteWorkspacePushStatusEvent['result']): RemoteWorkspacePushStatusEvent { + return { + targetId: 'target-a', + authority: { revision: 4, hostObservationToken: TOKEN }, + result + } +} + +describe('applyRemoteWorkspacePushStatusEvent', () => { + it('acknowledges a pending layout edit the host now holds, and only that one', () => { + const held = { targetId: 'target-a', root: null } + const otherRoot = { + targetId: 'target-a', + root: { type: 'leaf' as const, leafId: '11111111-1111-4111-8111-111111111111' } + } + const state = hydratedState({ + pendingDirectSshLayoutEditsByTabId: { 'tab-a': held, 'tab-b': otherRoot } + }) + const uploaded = snapshot(5) + uploaded.session.terminalLayoutsByTabId = { + 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-b': { root: null, activeLeafId: null, expandedLeafId: null } + } + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: true, snapshot: uploaded })) + + expect(state.acknowledgeDirectSshLayoutEdits).toHaveBeenCalledWith({ 'tab-a': held }) + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith( + 'target-a', + expect.objectContaining({ phase: 'synced', direction: 'push', revision: 5 }) + ) + }) + + it.each([ + ['stale-revision', 'Workspace changed on another device'], + ['unavailable', 'Remote workspace sync unavailable'] + ] as const)('localizes the %s export fallback', async (reason, message) => { + const previousLanguage = i18n.language + await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) + try { + const state = hydratedState() + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: false, reason })) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( + 'target-a', + expect.objectContaining({ message: `[${message}]` }) + ) + } finally { + await i18n.changeLanguage(previousLanguage) + } + }) + + it('reports a thrown export as an error', () => { + const state = hydratedState() + + applyRemoteWorkspacePushStatusEvent(state, { ...pushed(null), error: 'socket closed' }) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith( + 'target-a', + expect.objectContaining({ phase: 'error', direction: 'push', message: 'socket closed' }) + ) + }) + + it.each([ + ['mid-pull', { remoteWorkspaceHydratedTargetIds: new Set() }], + [ + 'conflicted', + { + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'conflict' as const, revision: 4, hostObservationToken: TOKEN } + } + } + ], + [ + 'observing another host', + { + remoteWorkspaceSyncStatusByTargetId: { + 'target-a': { phase: 'synced' as const, revision: 4, hostObservationToken: 'other' } + } + } + ] + ])('ignores a report while the window is %s', (_name, overrides) => { + const acknowledgeDirectSshLayoutEdits = vi.fn() + const state = hydratedState({ ...overrides, acknowledgeDirectSshLayoutEdits }) + + applyRemoteWorkspacePushStatusEvent(state, pushed({ ok: true, snapshot: snapshot(5) })) + + expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalled() + expect(acknowledgeDirectSshLayoutEdits).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-push-status.ts b/src/renderer/src/hooks/remote-workspace-push-status.ts index 4a3792114817..0008286da87a 100644 --- a/src/renderer/src/hooks/remote-workspace-push-status.ts +++ b/src/renderer/src/hooks/remote-workspace-push-status.ts @@ -1,5 +1,9 @@ -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' +import type { + RemoteWorkspaceObservedPatchResult, + RemoteWorkspacePushStatusEvent +} from '../../../shared/remote-workspace-types' import { translate } from '@/i18n/i18n' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import type { AppState } from '../store/types' export type RemoteWorkspacePushAuthority = { @@ -73,3 +77,48 @@ export function applyRemoteWorkspacePushStatus( }) } } + +/** + * Main's report of an export it ran. Ignored once the window no longer holds that host's agreement: + * mid-pull, after a conflict, or against another host observation. + */ +export function applyRemoteWorkspacePushStatusEvent( + store: AppState, + { targetId, authority, result, error }: RemoteWorkspacePushStatusEvent +): void { + const status = store.remoteWorkspaceSyncStatusByTargetId[targetId] + if ( + !store.remoteWorkspaceHydratedTargetIds.has(targetId) || + status?.phase === 'conflict' || + status?.hostObservationToken !== authority.hostObservationToken + ) { + return + } + if (error !== undefined) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'error', + direction: 'push', + revision: status.revision ?? authority.revision, + updatedAt: status.updatedAt, + hostObservationToken: authority.hostObservationToken, + message: error + }) + return + } + if (result?.ok) { + // An edit is acknowledged once the host holds its exact layout. + store.acknowledgeDirectSshLayoutEdits( + Object.fromEntries( + Object.entries(store.pendingDirectSshLayoutEditsByTabId).filter(([tabId, edit]) => { + const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] + return ( + edit.targetId === targetId && + uploaded && + terminalLayoutNodeEqual(edit.root, uploaded.root) + ) + }) + ) + ) + } + applyRemoteWorkspacePushStatus(store, targetId, result ?? undefined, authority) +} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts index 020d7d43c923..4e485b172dee 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts @@ -158,7 +158,8 @@ describe('session writes deferred by a direct-SSH apply', () => { finalizeHydratedTerminals: () => { onApplied() return 0 - } + }, + importPeerTopology: async () => {} }) expect(result).toBe('applied') } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 9dbe57cf0bc8..1d4a39b3fd82 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -1,9 +1,17 @@ -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' +import type { + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport +} from '../../../shared/remote-workspace-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { buildWorkspaceSessionHostPatches } from '../lib/workspace-session-host-persistence' import type { AppState } from '../store/types' import { admitDirectSshSnapshotApplyToken, @@ -79,6 +87,8 @@ type RemoteWorkspaceSnapshotApplyInput = { isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean waitForWorkspaceSessionReady: (signal?: AbortSignal) => Promise finalizeHydratedTerminals: (authority: DirectSshAuthority) => number + /** Main commits the import and records it as agreed with the host, so it never exports back. */ + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise /** * Host paths still carrying terminal tabs when this apply gave up placing them. `unverifiable`, * never proof the rows are not ours, so the caller owns getting back to a placed picture — this @@ -89,6 +99,17 @@ type RemoteWorkspaceSnapshotApplyInput = { export type RemoteWorkspaceSnapshotApplyResult = 'applied' | 'stale' | 'failed' +/** The fields the merge rewrote; the rest of its result is the window's own session. */ +function mergedFields( + merged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionPatch { + const before = new Map(Object.entries(current)) + return Object.fromEntries( + Object.entries(merged).filter(([field, value]) => value !== before.get(field)) + ) +} + function currentRecoveryTabIds( state: AppState, authority: DirectSshAuthority, @@ -122,6 +143,7 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ isPreparationTokenCurrent, waitForWorkspaceSessionReady, finalizeHydratedTerminals, + importPeerTopology, onUnplacedTabWorktreePaths }: RemoteWorkspaceSnapshotApplyInput): Promise { const { authority } = token @@ -175,8 +197,9 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ onUnplacedTerminalTabs: (worktreePath) => unplacedTabWorktreePaths.push(worktreePath) }) } + const current = buildWorkspaceSessionPayload(state) const merged = mergeDirectSshRemoteWorkspaceSession( - buildWorkspaceSessionPayload(state), + current, remoteSession, worktreeIds, state.tabsByWorktree, @@ -203,6 +226,14 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ try { const currentStore = store.getState() const replaceWorkspaceKeys = [...worktreeIds] + // Sent before the window's own apply; IPC order puts it ahead of any save that apply causes. + importPeerTopology({ + targetId: authority.targetId, + revision: snapshot.revision, + hostObservationToken: snapshot.hostObservationToken, + outcome: hasUnplacedTerminalTabs ? 'conflict' : 'synced', + patches: buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore) + }).catch((error: unknown) => console.warn('[remote-workspace] import commit failed:', error)) currentStore.hydrateWorkspaceSession(merged, { directSshAuthority: authority, replaceWorkspaceKeys @@ -224,15 +255,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ // The host listed tabs on paths this client cannot place, so adopting zero of them is not // the host's picture. `conflict` is the phase that says exactly that, and it is load-bearing // twice over: - // - use-app-session-persistence.ts filters conflicted targets out of uploads, and an - // upload is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale + // - main keeps conflicted targets out of exports (remote-workspace-export.ts), and an + // export is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale // replaces the host snapshot - it would delete the very tabs we failed to adopt; // - workspace-terminal-host-authority.ts treats `offline`/`error` on an un-hydrated target // as `none`, its bounded floor, which authorises seeding AND sleeping-agent resume. // `conflict` is deliberately not in that set: the unplaced paths stay `unverifiable`, // and only the worktrees this apply did place read `none`. // Hydration is cleared, not merely withheld: the set is add-only, so a target that synced - // cleanly before would otherwise keep uploading from this incomplete picture (STA-3593). + // cleanly before would otherwise keep reading as hydrated from this incomplete picture + // (STA-3593). currentStore.clearRemoteWorkspaceHydrated(authority.targetId) currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { phase: 'conflict', diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts index b9e70bf86c2d..32331c619f5b 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts @@ -95,7 +95,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts index 58a5a02a0bfa..e46f37155f8c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts @@ -94,7 +94,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts new file mode 100644 index 000000000000..9f0d9c11a898 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts @@ -0,0 +1,174 @@ +/** + * The window commits a host snapshot's import through main in one call, before applying it, so + * main records the agreement that keeps the import from exporting back. + */ +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport +} from '../../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' +import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' +import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const TARGET_ID = 'ssh-target-1' +const PATH = '/srv/proj/bug-cats' +const WORKTREE_ID = `repoA::${PATH}` + +const authority: DirectSshAuthority = { + targetId: TARGET_ID, + providerEpoch: 'provider-epoch-1' as SshProviderEpoch, + connectionGeneration: 1 +} + +function token(snapshotRevision: number): DirectSshSnapshotApplyToken { + return { + authority, + catalogRevision: 0, + repoFingerprint: 'fp', + authorityRequirement: 'required', + snapshotRevision, + outcome: 'complete' + } +} + +function snapshot(revision: number, tabIds: readonly string[]): RemoteWorkspaceObservedSnapshot { + return { + namespace: 'workspace', + revision, + updatedAt: revision, + schemaVersion: 1, + hostObservationToken: `observation-${revision}`, + session: { + activeWorktreePath: PATH, + activeTabId: tabIds[0] ?? null, + tabsByWorktreePath: { + [PATH]: tabIds.map((tabId, index) => ({ + id: tabId, + worktreePath: PATH, + ptyId: `pty-${tabId}`, + title: `Terminal ${index + 1}`, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: {}, + activeWorktreePathsOnShutdown: [], + activeTabIdByWorktreePath: { [PATH]: tabIds[0] ?? null }, + remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), + lastVisitedAtByWorktreePath: { [PATH]: revision }, + defaultTerminalTabsAppliedByWorktreePath: { [PATH]: true } + } + } satisfies RemoteWorkspaceObservedSnapshot +} + +type TestStore = ReturnType + +function seedCatalog(store: TestStore): void { + store.setState({ + worktreesByRepo: { + repoA: [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repoA', + path: PATH, + hostId: `ssh:${TARGET_ID}` + } as never) + ] + }, + repos: [ + { + id: 'repoA', + path: '/srv/proj', + displayName: 'Proj', + badgeColor: '#000', + addedAt: 0, + connectionId: TARGET_ID + } as never + ], + // Why: only the IPC-backed reconnect is stubbed; hydration bookkeeping stays real because it is + // the very signal under test. + reconnectPersistedTerminals: (async () => {}) as never, + setRemoteWorkspaceSyncStatus: (() => {}) as never + }) +} + +async function importOf( + store: TestStore, + snap: RemoteWorkspaceObservedSnapshot +): Promise { + const importPeerTopology = vi.fn(async (_pull: RemoteWorkspacePeerImport) => {}) + await applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot: snap, + token: token(snap.revision), + arrival: 1, + isArrivalCurrent: () => true, + isPreparationTokenCurrent: () => true, + waitForWorkspaceSessionReady: async () => true, + finalizeHydratedTerminals: () => 0, + importPeerTopology + }) + expect(importPeerTopology).toHaveBeenCalledOnce() + return importPeerTopology.mock.calls[0][0] +} + +describe('direct SSH snapshot import commit', () => { + it('sends the merge to the worktree partition with the pull it came from', async () => { + const store = createTestStore() + seedCatalog(store) + + const pull = await importOf(store, snapshot(6, ['host-tab'])) + + expect(pull).toMatchObject({ + targetId: TARGET_ID, + revision: 6, + hostObservationToken: 'observation-6', + outcome: 'synced' + }) + const sshPatch = pull.patches.find(({ hostId }) => hostId === `ssh:${TARGET_ID}`)?.patch + expect(sshPatch?.tabsByWorktree?.[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['host-tab']) + // Only what the merge rewrote; editor and browser state stay the window's own writes. + const mergedFields = new Set([ + 'activeRepoId', + 'activeWorktreeId', + 'activeWorkspaceKey', + 'activeTabId', + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeWorktreeIdsOnShutdown', + 'activeTabIdByWorktree', + 'remoteSessionIdsByTabId', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' + ]) + for (const { patch } of pull.patches) { + expect(Object.keys(patch).filter((field) => !mergedFields.has(field))).toEqual([]) + } + }) + + it('reports a conflict when the window cannot place every host tab', async () => { + vi.useFakeTimers() + try { + const store = createTestStore() + seedCatalog(store) + store.setState({ worktreesByRepo: {} }) + const pending = importOf(store, snapshot(7, ['host-tab'])) + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ revision: 7, outcome: 'conflict' }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts index 69660e1e6ea4..fcda90721f0c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts @@ -98,7 +98,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts index 7d8fc937cc91..429b4b8409af 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts @@ -209,7 +209,8 @@ async function applySnapshot( isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) // Exercise the real placement deadline without spending ten wall-clock seconds per snapshot. await vi.advanceTimersByTimeAsync(10_000) @@ -251,7 +252,8 @@ describe('a host snapshot whose terminal tabs cannot be placed locally', () => { isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => {} }) await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts index 40d5b488c415..04f8247818a7 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts @@ -1,8 +1,7 @@ import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot + RemoteWorkspaceObservedSnapshot, + RemoteWorkspacePeerImport } from '../../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' import type { DirectSshPreparationInput, @@ -13,12 +12,7 @@ import type { RemoteWorkspaceSnapshotPlacementStore } from './remote-workspace-s export type RemoteWorkspaceApi = { get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> + importPeerTopology: (pull: RemoteWorkspacePeerImport) => Promise } export type RemoteWorkspaceTargetSyncDeps = { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 65f7d2c02041..20c73f521e0d 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { - RemoteWorkspaceObservedPatchResult, RemoteWorkspaceObservedSnapshot, RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' import type { SshProviderEpoch } from '../../../shared/ssh-types' -import { i18n } from '@/i18n/i18n' -import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' -import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator' import { appState, createHarness, @@ -21,84 +17,25 @@ import { } from './__tests__/remote-workspace-target-sync-test-harness' describe('createRemoteWorkspaceTargetSync', () => { - it('captures local tabs before get when deciding a revision-zero upload', async () => { + it('records a revision-zero pull with main, which seeds the empty host', async () => { const state = appState({ tabsByWorktree: { 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] } }) - const pendingGet = deferred() - const harness = createHarness(state, () => pendingGet.promise) - - const pending = harness.sync.syncAfterConnect(token()) - await flush() - state.tabsByWorktree = {} - pendingGet.resolve(snapshot(0)) - await pending - - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - expect(harness.setForConnectedTargets).toHaveBeenCalledWith( - expect.objectContaining({ - hydratedTargetIds: ['target-a'], - expectedRevisionsByTargetId: { 'target-a': 0 } - }) - ) - }) - - it('acknowledges the captured blank-host edit when a newer null-root edit arrives', async () => { - const firstEdit = { targetId: 'target-a', root: null } - const newerEdit = { targetId: 'target-a', root: null } - const acknowledgeDirectSshLayoutEdits = vi.fn() - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - }, - pendingDirectSshLayoutEditsByTabId: { 'tab-a': firstEdit }, - acknowledgeDirectSshLayoutEdits - }) const harness = createHarness(state, async () => snapshot(0)) - const uploaded = snapshot(1) - uploaded.session.terminalLayoutsByTabId = { - 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null } - } - harness.setForConnectedTargets.mockImplementationOnce(async () => { - state.pendingDirectSshLayoutEditsByTabId = { 'tab-a': newerEdit } - return [{ targetId: owner.targetId, result: { ok: true, snapshot: uploaded } }] - }) await harness.sync.syncAfterConnect(token()) - const acknowledged = acknowledgeDirectSshLayoutEdits.mock.calls[0]?.[0]?.['tab-a'] - expect(acknowledged).toBe(firstEdit) - expect(state.pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(newerEdit) - }) - - it.each([ - ['stale-revision', 'Workspace changed on another device'], - ['unavailable', 'Remote workspace sync unavailable'] - ] as const)('localizes the %s upload fallback', async (reason, message) => { - const previousLanguage = i18n.language - await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) - try { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const harness = createHarness(state, async () => snapshot(0), { - ok: false, - reason - }) - - await harness.sync.syncAfterConnect(token()) - - expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( - 'target-a', - expect.objectContaining({ message: `[${message}]` }) - ) - } finally { - await i18n.changeLanguage(previousLanguage) - } + expect(state.markRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') + expect(harness.importPeerTopology).toHaveBeenCalledOnce() + expect(harness.importPeerTopology).toHaveBeenCalledWith({ + targetId: 'target-a', + revision: 0, + hostObservationToken: snapshot(0).hostObservationToken, + outcome: 'synced', + patches: [] + }) }) it('publishes nothing from a snapshot response after its authority turns stale', async () => { @@ -612,45 +549,6 @@ describe('createRemoteWorkspaceTargetSync', () => { } }) - it('does not publish a revision-zero push after a newer snapshot arrives', async () => { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const pendingPush = - deferred<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]>() - const pendingCapture = deferred() - const harness = createHarness(state, async () => snapshot(0)) - harness.setForConnectedTargets.mockImplementationOnce(() => pendingPush.promise) - - const first = harness.sync.syncAfterConnect(token()) - await flush() - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - - harness.capturePreparationInput.mockImplementationOnce(() => pendingCapture.promise) - const second = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(85)) - await flush() - pendingPush.resolve([{ targetId: 'target-a', result: { ok: true, snapshot: snapshot(1) } }]) - await first - - expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalledWith( - 'target-a', - expect.objectContaining({ direction: 'push' }) - ) - - harness.sync.stop() - pendingCapture.resolve({ - ...owner, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'workspace-snapshot', - snapshotRevision: 85 - }) - await second - }) - it('stopping snapshot sync cancels the active placement waiter immediately', async () => { vi.useFakeTimers() const state = appState({ worktreesByRepo: {} }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 05d027ece2e7..358edab2563c 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -1,7 +1,6 @@ import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' -import { buildWorkspaceSessionPayload } from '../lib/workspace-session' import type { DirectSshPreparationToken, DirectSshSnapshotApplyToken @@ -11,8 +10,6 @@ import { resolveExactDirectSshTargetWorktreeIds } from './remote-workspace-snaps import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' import { createRemoteWorkspaceSnapshotArrivalCoordinator } from './remote-workspace-snapshot-arrival-coordinator' import { createDeferredSnapshotPlacementRetries } from './remote-workspace-deferred-placement-retry' -import { applyRemoteWorkspacePushStatus } from './remote-workspace-push-status' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { waitForRemoteWorkspaceSessionReady } from './remote-workspace-session-readiness' import type { RemoteWorkspaceTargetSync, @@ -79,6 +76,7 @@ export function createRemoteWorkspaceTargetSync( waitForWorkspaceSessionReady: (signal) => waitForRemoteWorkspaceSessionReady(deps.store, signal), finalizeHydratedTerminals: deps.finalizeHydratedTerminals, + importPeerTopology: deps.remoteWorkspace.importPeerTopology, onUnplacedTabWorktreePaths: (worktreePaths) => { unplacedTabWorktreePaths = worktreePaths } @@ -178,49 +176,30 @@ export function createRemoteWorkspaceTargetSync( } return } - deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) - if (!hasLocalTabs) { - deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'idle', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken, - message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') - }) - return - } - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return + const agreed = { + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + hostObservationToken: snapshot.hostObservationToken } - const pendingLayoutEdits = deps.store.getState().pendingDirectSshLayoutEditsByTabId - const results = await deps.remoteWorkspace.setForConnectedTargets({ - session: buildWorkspaceSessionPayload(deps.store.getState()), - hydratedTargetIds: [authority.targetId], - expectedRevisionsByTargetId: { [authority.targetId]: snapshot.revision }, - expectedHostObservationTokensByTargetId: { - [authority.targetId]: snapshot.hostObservationToken - } + deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) + deps.store.getState().setRemoteWorkspaceSyncStatus( + authority.targetId, + hasLocalTabs + ? { phase: 'pulling', direction: 'pull', ...agreed } + : { + phase: 'idle', + ...agreed, + message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') + } + ) + // An empty host has nothing to import; main seeds it from this desktop and reports the push. + await deps.remoteWorkspace.importPeerTopology({ + targetId: authority.targetId, + revision: snapshot.revision, + hostObservationToken: snapshot.hostObservationToken, + outcome: 'synced', + patches: [] }) - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return - } - const result = results.find((entry) => entry.targetId === authority.targetId)?.result - if (result?.ok) { - const currentState = deps.store.getState() - currentState.acknowledgeDirectSshLayoutEdits( - Object.fromEntries( - Object.entries(pendingLayoutEdits ?? {}).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === authority.targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) - ) - } - applyRemoteWorkspacePushStatus(deps.store.getState(), authority.targetId, result, snapshot) } const syncAfterConnect = (token: DirectSshPreparationToken): Promise => diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts index d8bfbd54d4c4..ae3057a646b2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts @@ -113,7 +113,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onChanged: (cb: typeof onChangedListenerRef.current) => { onChangedListenerRef.current = cb return () => {} - } + }, + onPushStatus: () => () => {} } }) ) diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 9c204026b7aa..af6bced327a9 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -24,6 +24,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'pty.onExit', 'rateLimits.onUpdate', 'remoteWorkspace.onChanged', + 'remoteWorkspace.onPushStatus', 'repos.onChanged', 'runtime.onBrowserDriverChanged', 'runtime.onBrowserRemoteViewersChanged', @@ -198,6 +199,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ssh.onStateChanged', 'ui.onSystemResumed', 'remoteWorkspace.onChanged', + 'remoteWorkspace.onPushStatus', 'ui.onTerminalZoom', 'agentStatus.onSet', 'agentStatus.onClear', diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index e6dbc3f352e7..13e645f02f4e 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -215,7 +215,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { [folderKey]: 'runtime:env-1' } } - ).written + ) expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) expect(patch).toHaveBeenCalledWith( @@ -259,7 +259,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { [folderKey]: 'runtime:stale-env' } } - ).written + ) expect(patch).toHaveBeenCalledTimes(1) expect(patch.mock.calls[0][0]).toEqual( @@ -306,7 +306,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] } } - ).written + ) expect(patch).toHaveBeenCalledWith(expect.objectContaining({ tabsByWorktree: {} })) expect(patch).toHaveBeenCalledWith( @@ -374,7 +374,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { ] } } - ).written + ) expect(patch).toHaveBeenCalledWith( expect.objectContaining({ @@ -484,7 +484,7 @@ describe('fetchWorkspaceSessionFromHosts', () => { }) describe('patchWorkspaceSessionByHost write outcome', () => { - it('rejects `written` when a host partition fails, while the local write still resolves', async () => { + it('rejects when a host partition fails, after the local write resolves', async () => { const folderKey = folderWorkspaceKey('folder-1') const patch = vi.fn(async (_slice: unknown, hostId?: string) => { if (hostId) { @@ -500,8 +500,8 @@ describe('patchWorkspaceSessionByHost write outcome', () => { restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' } } ) - await expect(result.localWrite).resolves.toBeUndefined() - await expect(result.written).rejects.toThrow('runtime down') + await expect(result).rejects.toThrow('runtime down') + expect(patch).toHaveBeenCalledWith(expect.anything()) expect(patch).toHaveBeenCalledWith(expect.anything(), 'runtime:env-1') }) }) @@ -546,7 +546,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { { get: vi.fn(), patch, setSync: vi.fn() }, { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, @@ -562,7 +562,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { { get: vi.fn(), patch, setSync: vi.fn() }, { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } }, @@ -583,7 +583,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { [remoteWorktreeId]: [{ id: 'remote-tab', worktreeId: remoteWorktreeId }] } } - ).written + ) expect(patch).toHaveBeenCalledWith( { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, @@ -603,7 +603,7 @@ describe('patchWorkspaceSessionByHost tab-keyed routing', () => { terminalLayoutsByTabId: { 'remote-tab': parkedLayout } }, { ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } } - ).written + ) expect(patch).toHaveBeenCalledTimes(1) expect(patch).toHaveBeenCalledWith( diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 909acdd4d69f..540e3173a0d0 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -66,6 +66,11 @@ type DurableSessionApi = SessionApi & { flush: () => Promise } +export type WorkspaceSessionHostPatch = { + patch: WorkspaceSessionPatch + hostId?: ExecutionHostId +} + export type WorkspaceSessionHostSnapshot = { state: WorkspaceSessionState hostId?: ExecutionHostId @@ -231,33 +236,40 @@ function splitWorkspaceSessionForWrite( return slices } -/** Patch path of the debounced session writer: split the partial patch by owner host and patch - * each partition. `localWrite` orders the SSH remote-workspace upload; `written` settles once - * every partition did and rejects if any failed, so the writer re-queues those fields. */ +/** A partial patch split by owner host, local first. */ +export function buildWorkspaceSessionHostPatches( + patch: WorkspaceSessionPatch, + state: HostPersistenceState +): WorkspaceSessionHostPatch[] { + const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') + return [ + { patch: slices[LOCAL_EXECUTION_HOST_ID] ?? patch }, + ...nonLocalHostSessionEntries(slices).map(([hostId, slice]) => ({ hostId, patch: slice })) + ] +} + +/** Patch path of the debounced session writer: patch each owner partition. Settles once every + * partition did and rejects if any failed, so the writer re-queues those fields. */ export function patchWorkspaceSessionByHost( api: SessionApi, patch: WorkspaceSessionPatch, state: HostPersistenceState -): { localWrite: Promise; written: Promise } { - const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch') - const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch - const localWrite = api.patch(local) - const hostWrites = nonLocalHostSessionEntries(slices).map(([hostId, slice]) => - api.patch(slice, hostId).catch((err: unknown) => { - console.warn(`[session] host partition patch failed for ${hostId}:`, err) - throw err - }) +): Promise { + const writes = buildWorkspaceSessionHostPatches(patch, state).map(({ hostId, patch: slice }) => + hostId + ? api.patch(slice, hostId).catch((err: unknown) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + throw err + }) + : api.patch(slice) ) // Why allSettled: every partition finishes before a retry rewrites the same fields. - const written = Promise.allSettled([localWrite, ...hostWrites]).then((results) => { + return Promise.allSettled(writes).then((results) => { const failure = results.find((result) => result.status === 'rejected') if (failure) { throw failure.reason } }) - // Why: a host failure must not surface as an unhandled rejection on the upload chain's promise. - localWrite.catch(() => {}) - return { localWrite, written } } /** Persist a fresh full snapshot to every owning host partition, then force the diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts index 371b4f2b77b6..a179c6c31d4f 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts @@ -95,7 +95,7 @@ describe('ssh folder workspace partition ownership', () => { }, { tabsByWorktree: { [FOLDER_KEY]: [tab('tab-desktop', FOLDER_KEY)] } }, state - ).written + ) // Main applies a patch field-wise ({ ...current, ...patch }), so a `tabsByWorktree` written to // `ssh:` WITHOUT the folder row replaces the row main put there. @@ -313,7 +313,7 @@ describe('a bare workspace id two partitions both hold', () => { contestedHostWorkspaceSessions: read.contestedHostWorkspaceSessions, contestedPrimaryHostBySessionKey: read.contestedPrimaryHostBySessionKey } - ).written + ) // Main applies a patch field-wise, so an `ssh:` write carrying only the sibling would // erase the declined row from the one partition that still holds it. Declining to show a row diff --git a/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts b/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts index 651e30bb9776..aeface581b6a 100644 --- a/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts +++ b/src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts @@ -89,7 +89,7 @@ async function saveDebouncedPatch(api: ReturnType) terminalLayoutsByTabId: payload.terminalLayoutsByTabId }, state - ).written + ) } async function saveQuitSnapshot(api: ReturnType) { diff --git a/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts b/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts index d0befefa537a..1e9dbc93b28f 100644 --- a/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts +++ b/src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts @@ -74,14 +74,14 @@ describe('purge stops routing session writes to the removed runtime partition', } const patch = { activeWorktreeIdsOnShutdown: [WT_A] } as unknown as WorkspaceSessionPatch - await patchWorkspaceSessionByHost(api, patch, store.getState()).written + await patchWorkspaceSessionByHost(api, patch, store.getState()) const runtimeWritesBefore = api.patch.mock.calls.filter(([, hostId]) => hostId === RUNTIME_A) expect(runtimeWritesBefore).toHaveLength(1) store.getState().purgeStaleRuntimeHostState(['env-a']) api.patch.mockClear() - await patchWorkspaceSessionByHost(api, patch, store.getState()).written + await patchWorkspaceSessionByHost(api, patch, store.getState()) const runtimeWritesAfter = api.patch.mock.calls.filter(([, hostId]) => hostId === RUNTIME_A) expect(runtimeWritesAfter).toHaveLength(0) // The local write still happens. diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 9544b709f9ca..bad6a4c27f63 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,4 +1,6 @@ +import type { ExecutionHostId } from './execution-host' import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { WorkspaceSessionPatch } from './workspace-session-state-types' // Transient client-local fields are omitted, not merely unset: `recovery` is // this client's in-flight heal, stamped with this machine's clock, so the type @@ -65,6 +67,30 @@ export type RemoteWorkspaceObservedPatchResult = message?: string } +/** The CAS inputs an export to a host runs under: the host revision and observation this desktop last agreed on. */ +export type RemoteWorkspaceExportAuthority = { + revision: number + hostObservationToken: string +} + +/** A window's pull from one host: the session writes it imports and whether the import is whole. */ +export type RemoteWorkspacePeerImport = RemoteWorkspaceExportAuthority & { + targetId: string + /** `conflict`: the window could not place every host tab, so this desktop must not export. */ + outcome: 'synced' | 'conflict' + /** The import's session patch per owning partition; empty when the host had nothing to import. */ + patches: { hostId?: ExecutionHostId; patch: WorkspaceSessionPatch }[] +} + +/** Main's report of one export it ran, for the window's sync status. */ +export type RemoteWorkspacePushStatusEvent = { + targetId: string + authority: RemoteWorkspaceExportAuthority + result: RemoteWorkspaceObservedPatchResult | null + /** Set instead of a result when the export threw. */ + error?: string +} + export const REMOTE_WORKSPACE_CHANGED_NOTIFICATION = 'workspace.changed' /** From e8777027d0ab20fbbbe2e149a0dfc120eae077b4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 16:34:55 -0400 Subject: [PATCH 25/75] refactor(terminal): main resolves a worktree's one home partition The same owner rule main's push projects, exposed for writes that land in one partition. Identical to C6's hunk so the branches merge cleanly. --- src/main/runtime/orca-runtime.ts | 6 ++++++ .../runtime-workspace-session-controller.ts | 7 +++++++ .../runtime/terminal-topology-owners.test.ts | 19 ++++++++++++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f04b4b49f6c1..d3800e7df0e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,7 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' +import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import { TerminalTopologyPublisher } from './terminal-topology-publisher' @@ -38,6 +39,11 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { return this.terminalTopology.snapshot() } + /** Where main writes a worktree's terminal layout: the partition its push projects. */ + getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { + return this.workspaceSessions.getTerminalTopologyHomeHostId(worktreeId) + } + /** The publishSeq of the push holding every topology write made so far, for a reply to name. */ settleTerminalTopology(worktreeId?: string): number { return this.terminalTopology.settle(worktreeId) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index c75103f1b263..67ae2cd8964c 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -151,6 +151,13 @@ export class RuntimeWorkspaceSessionController { return worktreeIds } + /** The one `local`/`ssh:` partition a worktree's terminal rows live in; null when unresolved or another server's. */ + getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { + const store = this.deps.getStore() + const hostId = store ? this.tryGetPreferredHostId(worktreeId, store) : null + return hostId && isTerminalOwnerPartition(hostId) ? hostId : null + } + /** * Worktrees with terminal rows in their home partition. `runtime:` homes are another server's; * a home that can't be resolved (a missing or ambiguous folder) maps to null. diff --git a/src/main/runtime/terminal-topology-owners.test.ts b/src/main/runtime/terminal-topology-owners.test.ts index 81a096a2665f..9bbc6a0d0079 100644 --- a/src/main/runtime/terminal-topology-owners.test.ts +++ b/src/main/runtime/terminal-topology-owners.test.ts @@ -30,7 +30,7 @@ function sessionWith(worktreeIds: string[]): WorkspaceSessionState { return session } -function ownersFor(sessions: Map) { +function controllerFor(sessions: Map) { const repos = [ { id: 'repo-local' }, { id: 'repo-ssh', connectionId: 'c1' }, @@ -49,7 +49,7 @@ function ownersFor(sessions: Map) { getWorkspaceSession: (hostId: ExecutionHostId) => sessions.get(hostId) ?? getDefaultWorkspaceSession() } as never - const controller = new RuntimeWorkspaceSessionController({ + return new RuntimeWorkspaceSessionController({ getStore: () => store, resolveFolderConnectionId: (workspace: FolderWorkspace) => { if (workspace.id === 'mixed') { @@ -59,7 +59,10 @@ function ownersFor(sessions: Map) { }, hasRuntimeOwnedPtyCandidate: () => false }) - return controller.getTerminalTopologyOwners() +} + +function ownersFor(sessions: Map) { + return controllerFor(sessions).getTerminalTopologyOwners() } describe('terminal topology owners', () => { @@ -111,4 +114,14 @@ describe('terminal topology owners', () => { ]) ) }) + + it('resolves one home for a layout write, and none for a runtime or ambiguous worktree', () => { + const controller = controllerFor(new Map([['local', sessionWith([SSH_WT])]])) + + expect(controller.getTerminalTopologyHomeHostId(LOCAL_WT)).toBe('local') + expect(controller.getTerminalTopologyHomeHostId(SSH_WT)).toBe('ssh:c1') + expect(controller.getTerminalTopologyHomeHostId(SSH_FOLDER)).toBe('ssh:c1') + expect(controller.getTerminalTopologyHomeHostId(RUNTIME_WT)).toBeNull() + expect(controller.getTerminalTopologyHomeHostId(AMBIGUOUS_FOLDER)).toBeNull() + }) }) From 0d918b7a1d980b60e7f195fda5a8b39ecb1d9dd8 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 16:39:51 -0400 Subject: [PATCH 26/75] refactor(terminal): sleeping records commit through one channel to the worktree's home Records and wakes go to main in one call, sent with the write: a window's IPC reaches main in order, so the quit capture lands before the quit stage (probed on Electron 43: 2000/2000 rounds; a microtask-deferred send lost 2000/2000). That drops the batch queue and the stage's extra argument. Main writes only each worktree's home partition, as setLayout does; a wake reads the worktree from main's own stored record. --- src/main/ipc/renderer-shutdown-checkpoint.ts | 6 -- src/main/ipc/session.ts | 63 ++++++--------- .../terminal-sleep-wake-commit.test.ts | 63 +++++++-------- .../terminal-topology-commit.ts | 21 +++-- src/preload/api/app-api.ts | 3 - src/preload/api/session-bridge.ts | 4 +- src/preload/api/workspace-session-api.ts | 6 +- .../app-shell/use-app-session-persistence.ts | 8 +- ...gent-status-live-freshness-request.test.ts | 1 + .../slices/store-cascades-test-harness.ts | 5 +- .../store-sleep-pane-hibernation.test.ts | 4 +- .../terminal-sleeping-record-commits.test.ts | 81 ++++++++----------- .../terminal-sleeping-record-commits.ts | 58 ++++--------- .../preload-api/web-workspace-session-api.ts | 3 +- 14 files changed, 129 insertions(+), 197 deletions(-) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 941540431e20..82fcbee0d4e2 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -1,17 +1,13 @@ import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../shared/execution-host' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' -import type { TerminalSleepingRecordChanges } from '../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { Store } from '../persistence' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' -import { commitTerminalSleepingRecords } from './session' type StageBeforeUnloadSyncArgs = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial - /** Changes the window had not sent yet, including the quit capture. */ - sleepingRecords?: TerminalSleepingRecordChanges } export type ShutdownCheckpointResult = { ok: boolean } @@ -53,8 +49,6 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => { let ok = true try { - // Before the stage, in the same handler, so the quit capture can't be lost to ordering. - commitTerminalSleepingRecords(store, args.sleepingRecords) for (const { state, hostId } of args.sessions) { if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { store.stageWorkspaceSessionBeforeUnload(state, hostId) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 25112683c95d..7765e5df69f6 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -8,39 +8,8 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import type { TerminalSleepingRecordChanges } from '../../shared/terminal-topology-slice' import { sleepingAgentSessionsByPaneKeySchema } from '../../shared/workspace-session-sleeping-agents' import { sleepLeaf, wakeLeaf } from '../persistence/terminal-topology/terminal-topology-commit' -import { isTerminalOwnerPartition } from '../persistence/terminal-topology/terminal-topology-membership' - -/** Commits a window's own sleeping-agent record changes; main's next topology push carries them. */ -export function commitTerminalSleepingRecords( - store: Store, - changes: { sleep?: unknown; wake?: unknown } | undefined -): void { - const parsed: TerminalSleepingRecordChanges = { - sleep: sleepingAgentSessionsByPaneKeySchema.safeParse(changes?.sleep).data ?? {}, - wake: Array.isArray(changes?.wake) - ? changes.wake.filter((paneKey) => typeof paneKey === 'string') - : [] - } - if (Object.keys(parsed.sleep).length === 0 && parsed.wake.length === 0) { - return - } - for (const hostId of store.getWorkspaceSessionHostIds()) { - if (!isTerminalOwnerPartition(hostId) || isFrozenOrcadSourceSessionPartition(store, hostId)) { - continue - } - const session = store.getWorkspaceSession(hostId) - const next = sleepLeaf(wakeLeaf(session, parsed.wake), parsed.sleep) - if (next !== session) { - store.patchWorkspaceSession( - { sleepingAgentSessionsByPaneKey: next.sleepingAgentSessionsByPaneKey }, - hostId - ) - } - } -} export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void { // Why: renderer saves would change a fenced host's frozen source partition. @@ -99,13 +68,31 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic } ) - ipcMain.handle('session:terminal-sleep-leaves', (_event, sleep: unknown) => { - commitTerminalSleepingRecords(store, { sleep }) - }) - - ipcMain.handle('session:terminal-wake-leaves', (_event, wake: unknown) => { - commitTerminalSleepingRecords(store, { wake }) - }) + // A window's own sleeping-agent changes, written in each worktree's home; main's push carries them. + ipcMain.handle( + 'session:commit-terminal-sleeping-records', + (_event, changes: { sleep?: unknown; wake?: unknown } | undefined) => { + const sleep = sleepingAgentSessionsByPaneKeySchema.safeParse(changes?.sleep).data ?? {} + const wake = Array.isArray(changes?.wake) + ? changes.wake.filter((paneKey) => typeof paneKey === 'string') + : [] + for (const hostId of store.getWorkspaceSessionHostIds()) { + if (isFenced(hostId)) { + continue + } + const isHome = (worktreeId: string): boolean => + runtime.getTerminalTopologyHomeHostId(worktreeId) === hostId + const session = store.getWorkspaceSession(hostId) + const next = sleepLeaf(wakeLeaf(session, wake, isHome), sleep, isHome) + if (next !== session) { + store.patchWorkspaceSession( + { sleepingAgentSessionsByPaneKey: next.sleepingAgentSessionsByPaneKey }, + hostId + ) + } + } + } + ) // Pull-after-listen: a window subscribes to pushes first, then reads what it missed. ipcMain.handle('session:get-terminal-topology-slices', () => runtime.getTerminalTopologySlices()) diff --git a/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts index 599347119e0a..2892a79028ec 100644 --- a/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts +++ b/src/main/persistence/terminal-topology/terminal-sleep-wake-commit.test.ts @@ -43,6 +43,7 @@ const TAB = 'tab-agent' const LEAF = '11111111-1111-4111-8111-111111111111' const PANE_KEY = `${TAB}:${LEAF}` const OTHER_PANE_KEY = `tab-elsewhere:${LEAF}` +const COMMIT = 'session:commit-terminal-sleeping-records' const directories: string[] = [] const stores: Store[] = [] @@ -138,21 +139,30 @@ async function setup() { describe('sleepLeaf and wakeLeaf', () => { const session = sessionWithAgentTab(getDefaultWorkspaceSession()) + const home = (): boolean => true it('a partition takes only the records whose tab it holds', () => { - const next = sleepLeaf(session, { - [PANE_KEY]: record(PANE_KEY, 1), - [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) - }) + const next = sleepLeaf( + session, + { [PANE_KEY]: record(PANE_KEY, 1), [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) }, + home + ) expect(next.sleepingAgentSessionsByPaneKey).toEqual({ [PANE_KEY]: record(PANE_KEY, 1) }) - expect(sleepLeaf(session, { [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) })).toBe(session) + expect(sleepLeaf(session, { [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) }, home)).toBe(session) }) it('an unchanged record or an absent wake leaves the session as it was', () => { - const held = sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 1) }) - expect(sleepLeaf(held, { [PANE_KEY]: record(PANE_KEY, 1) })).toBe(held) - expect(wakeLeaf(held, [OTHER_PANE_KEY])).toBe(held) - expect(wakeLeaf(held, [PANE_KEY]).sleepingAgentSessionsByPaneKey).toEqual({}) + const held = sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 1) }, home) + expect(sleepLeaf(held, { [PANE_KEY]: record(PANE_KEY, 1) }, home)).toBe(held) + expect(wakeLeaf(held, [OTHER_PANE_KEY], home)).toBe(held) + expect(wakeLeaf(held, [PANE_KEY], home).sleepingAgentSessionsByPaneKey).toEqual({}) + }) + + it("a partition that is not the worktree's home is left alone, even holding the tab", () => { + const held = sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 1) }, home) + const notHome = (): boolean => false + expect(sleepLeaf(session, { [PANE_KEY]: record(PANE_KEY, 2) }, notHome)).toBe(session) + expect(wakeLeaf(held, [PANE_KEY], notHome)).toBe(held) }) }) @@ -160,46 +170,35 @@ describe('sleep and wake commits', () => { it('a sleep commit arrives in the next slice, and a wake removes it', async () => { const { invoke, nextPush } = await setup() - invoke('session:terminal-sleep-leaves', { [PANE_KEY]: record(PANE_KEY, 1) }) + invoke(COMMIT, { sleep: { [PANE_KEY]: record(PANE_KEY, 1) }, wake: [] }) expect((await nextPush())?.sleeping).toEqual({ [PANE_KEY]: record(PANE_KEY, 1) }) - invoke('session:terminal-wake-leaves', [PANE_KEY]) + invoke(COMMIT, { sleep: {}, wake: [PANE_KEY] }) expect((await nextPush())?.sleeping).toEqual({}) }) it('drops a malformed record and a record whose tab main does not hold', async () => { const { store, invoke } = await setup() - invoke('session:terminal-sleep-leaves', { - [PANE_KEY]: { ...record(PANE_KEY, 1), agent: 'not-an-agent' }, - [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) + invoke(COMMIT, { + sleep: { + [PANE_KEY]: { ...record(PANE_KEY, 1), agent: 'not-an-agent' }, + [OTHER_PANE_KEY]: record(OTHER_PANE_KEY, 1) + } }) expect(store.getWorkspaceSession().sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) }) - it('the quit stage commits sleep records before staging the session', async () => { - const { directory, store } = await setup() - const quitRecord = record(PANE_KEY, 2) - const stage = store.stageWorkspaceSessionBeforeUnload.bind(store) - let heldAtStage: SleepingAgentSessionRecord | undefined - vi.spyOn(store, 'stageWorkspaceSessionBeforeUnload').mockImplementation((...args) => { - heldAtStage = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] - stage(...args) - }) - // The window's session as it stages it today, quit capture included. - const window = structuredClone(store.getWorkspaceSession()) - window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: quitRecord } + it('a committed quit capture survives a quit stage that carries no window session', async () => { + const { directory, store, invoke } = await setup() + const quitRecord = { ...record(PANE_KEY, 2), origin: 'quit' as const } const event: { returnValue?: unknown } = {} - syncHandlers.get('app:stage-before-unload-sync')!(event, { - sessions: [{ state: window }], - ui: {}, - sleepingRecords: { sleep: { [PANE_KEY]: quitRecord }, wake: [] } - }) + invoke(COMMIT, { sleep: { [PANE_KEY]: quitRecord }, wake: [] }) + syncHandlers.get('app:stage-before-unload-sync')!(event, { sessions: [], ui: {} }) expect(event.returnValue).toEqual({ ok: true }) - expect(heldAtStage).toEqual(quitRecord) stores.splice(stores.indexOf(store), 1) const relaunched = await reopenTopologyStore(store, directory) stores.push(relaunched) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 20412c4f0dd0..76e429e8ab05 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -47,32 +47,41 @@ export function moveLeaf( ) } -/** A sleeping agent's record lives beside its tab, in each partition that holds the tab. */ +/** A sleeping agent's record lands beside its tab, in its worktree's home partition. */ export function sleepLeaf( session: WorkspaceSessionState, - records: Record + records: Record, + isHome: (worktreeId: string) => boolean ): WorkspaceSessionState { const current = session.sleepingAgentSessionsByPaneKey ?? {} const held = Object.entries(records).filter( ([paneKey, record]) => + isHome(record.worktreeId) && session.tabsByWorktree?.[record.worktreeId]?.some( (tab) => tab.id === parsePaneKey(paneKey)?.tabId - ) && !structuralValuesEqual(current[paneKey], record) + ) && + !structuralValuesEqual(current[paneKey], record) ) return held.length > 0 ? { ...session, sleepingAgentSessionsByPaneKey: { ...current, ...Object.fromEntries(held) } } : session } +/** Drops records from their worktree's home; the stored record names the worktree. */ export function wakeLeaf( session: WorkspaceSessionState, - paneKeys: string[] + paneKeys: string[], + isHome: (worktreeId: string) => boolean ): WorkspaceSessionState { const current = session.sleepingAgentSessionsByPaneKey ?? {} - if (!paneKeys.some((paneKey) => Object.hasOwn(current, paneKey))) { + const woken = new Set( + paneKeys.filter( + (paneKey) => Object.hasOwn(current, paneKey) && isHome(current[paneKey].worktreeId) + ) + ) + if (woken.size === 0) { return session } - const woken = new Set(paneKeys) return { ...session, sleepingAgentSessionsByPaneKey: Object.fromEntries( diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 584d80dbdf66..55f82e23568a 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -8,7 +8,6 @@ import type { import type { MacCapturedDigitRowChord } from '../../shared/macos-symbolic-hotkeys' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' -import type { TerminalSleepingRecordChanges } from '../../shared/terminal-topology-slice' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { KeyboardLayoutSnapshot } from '../../shared/keyboard-layout-snapshot' @@ -32,8 +31,6 @@ export type AppApi = { stageBeforeUnloadSync: (args: { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial - /** Committed before the sessions, so the quit capture can't be lost to ordering. */ - sleepingRecords?: TerminalSleepingRecordChanges }) => void /** Resolves once the last staged checkpoint is durably written; rejects if that * write failed, so a reload/restart can abort instead of losing the snapshot. */ diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 5336b4a0b1bc..a610caad7eb1 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -9,8 +9,8 @@ export const sessionApi = { set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), - sleepTerminalLeaves: (records) => ipcRenderer.invoke('session:terminal-sleep-leaves', records), - wakeTerminalLeaves: (paneKeys) => ipcRenderer.invoke('session:terminal-wake-leaves', paneKeys), + commitTerminalSleepingRecords: (changes) => + ipcRenderer.invoke('session:commit-terminal-sleeping-records', changes), getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), onTerminalTopologyChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 75f0b7b6e11c..033dfce1c746 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -31,10 +31,8 @@ export type WorkspaceSessionApi = { target: TerminalSurfaceCloseTarget reason?: 'user' | 'cleanup' }) => Promise - /** Commits sleeping-agent records this window wrote, beside their tabs in main. */ - sleepTerminalLeaves: (records: TerminalSleepingRecordChanges['sleep']) => Promise - /** Drops sleeping-agent records this window removed. */ - wakeTerminalLeaves: (paneKeys: TerminalSleepingRecordChanges['wake']) => Promise + /** Sleeping-agent records this window wrote or dropped, committed beside their tabs in main. */ + commitTerminalSleepingRecords: (changes: TerminalSleepingRecordChanges) => Promise /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ getTerminalTopologySlices: () => Promise onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 31b38cfa9cfe..6574438265de 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -41,7 +41,6 @@ import type { AppState } from '../store/types' import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' -import { takeSleepingRecordChanges } from '../store/terminals/terminal-sleeping-record-commits' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 @@ -246,12 +245,7 @@ export function useAppSessionPersistence(): void { // the user could only complete with SIGKILL, which loses strictly more (#15352). isDegradableShutdownInProgress: () => isIntentionalAppRestartInProgress() || isWindowCloseCheckpointInProgress(), - // The quit capture's sleeping records ride the synchronous stage, not a later async send. - stageBeforeUnloadSync: (args) => - window.api.app.stageBeforeUnloadSync({ - ...args, - sleepingRecords: takeSleepingRecordChanges() - }) + stageBeforeUnloadSync: (args) => window.api.app.stageBeforeUnloadSync(args) }) const shutdownCheckpoint = createShutdownCheckpointGuard( shutdownCheckpointPersist.run, diff --git a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts index 233a7c0121b6..207befd616b9 100644 --- a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts +++ b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts @@ -39,6 +39,7 @@ function setup(existing: AgentStatusEntry) { requestFreshness, transactAgentStatuses: vi.fn() } as unknown as AgentStatusRuntime + runtime.setCommittingSleepingRecords = runtime.set return { requestFreshness, actions: createAgentStatusLiveActions(runtime) } } diff --git a/src/renderer/src/store/slices/store-cascades-test-harness.ts b/src/renderer/src/store/slices/store-cascades-test-harness.ts index 761852c48da7..73c182b4b9b3 100644 --- a/src/renderer/src/store/slices/store-cascades-test-harness.ts +++ b/src/renderer/src/store/slices/store-cascades-test-harness.ts @@ -15,7 +15,7 @@ export type StoreCascadesMockApi = { settings: { get: Mock; set: Mock } runtimeEnvironments: { call: Mock } cache: { getGitHub: Mock; setGitHub: Mock } - session: { sleepTerminalLeaves: Mock; wakeTerminalLeaves: Mock } + session: { commitTerminalSleepingRecords: Mock } } /** window.api double shared by the store cascade suites; installs itself on globalThis. */ @@ -54,8 +54,7 @@ export function createStoreCascadesMockApi(): StoreCascadesMockApi { setGitHub: vi.fn().mockResolvedValue(undefined) }, session: { - sleepTerminalLeaves: vi.fn().mockResolvedValue(undefined), - wakeTerminalLeaves: vi.fn().mockResolvedValue(undefined) + commitTerminalSleepingRecords: vi.fn().mockResolvedValue(undefined) } } diff --git a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts index 0b012e7ac1db..05450cba8c3e 100644 --- a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts +++ b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts @@ -271,10 +271,10 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() // Main got the sleep, then the rollback's record. - expect(mockApi.session.sleepTerminalLeaves.mock.calls.at(-2)?.[0]).toEqual({ + expect(mockApi.session.commitTerminalSleepingRecords.mock.calls.at(-2)?.[0].sleep).toEqual({ [targetPaneKey]: expect.objectContaining({ origin: 'worktree-sleep' }) }) - expect(mockApi.session.sleepTerminalLeaves.mock.calls.at(-1)?.[0]).toEqual({ + expect(mockApi.session.commitTerminalSleepingRecords.mock.calls.at(-1)?.[0].sleep).toEqual({ [targetPaneKey]: state.sleepingAgentSessionsByPaneKey[targetPaneKey] }) }) diff --git a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts index a313b9382734..d4b6129744e2 100644 --- a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts +++ b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTestStore, makeTab } from '../slices/store-test-helpers' -import { takeSleepingRecordChanges } from './terminal-sleeping-record-commits' const LEAF = '11111111-1111-4111-8111-111111111111' const PANE = `tab-1:${LEAF}` @@ -8,27 +7,24 @@ const MOVED = `tab-2:${LEAF}` const session = { key: 'session_id' as const, id: 'codex-session-1' } let api: { - sleepTerminalLeaves: ReturnType - wakeTerminalLeaves: ReturnType + commitTerminalSleepingRecords: ReturnType closeTerminalSurface: ReturnType } beforeEach(() => { api = { - sleepTerminalLeaves: vi.fn(async () => {}), - wakeTerminalLeaves: vi.fn(async () => {}), + commitTerminalSleepingRecords: vi.fn(async () => {}), closeTerminalSurface: vi.fn(async () => ({})) } vi.stubGlobal('window', { api: { session: api } }) }) afterEach(() => { - takeSleepingRecordChanges() vi.unstubAllGlobals() }) /** A store whose agent pane already has a committed sleeping record. */ -async function storeWithSleepingAgent() { +function storeWithSleepingAgent() { const store = createTestStore() store.setState({ tabsByWorktree: { @@ -48,16 +44,14 @@ async function storeWithSleepingAgent() { { tabId: 'tab-1', worktreeId: 'wt-1' }, { providerSession: session } ) - await Promise.resolve() - api.sleepTerminalLeaves.mockClear() + api.commitTerminalSleepingRecords.mockClear() return store } describe("this window's sleeping-record commits", () => { - it('a new record sleeps its leaf, in one batch', async () => { - const store = await storeWithSleepingAgent() - const committed = store.getState().sleepingAgentSessionsByPaneKey[PANE] - expect(committed?.providerSession).toEqual(session) + it('a changed record sleeps its leaf, sent with the write', () => { + const store = storeWithSleepingAgent() + expect(store.getState().sleepingAgentSessionsByPaneKey[PANE]?.providerSession).toEqual(session) store .getState() @@ -68,64 +62,55 @@ describe("this window's sleeping-record commits", () => { { updatedAt: 20 }, { tabId: 'tab-1', worktreeId: 'wt-1' } ) - store.getState().captureAllSleepingAgentSessions('periodic') - await Promise.resolve() - expect(api.sleepTerminalLeaves).toHaveBeenCalledTimes(1) - expect(api.sleepTerminalLeaves).toHaveBeenCalledWith({ - [PANE]: store.getState().sleepingAgentSessionsByPaneKey[PANE] + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledTimes(1) + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledWith({ + sleep: { [PANE]: store.getState().sleepingAgentSessionsByPaneKey[PANE] }, + wake: [] }) - expect(api.wakeTerminalLeaves).not.toHaveBeenCalled() }) - it('the periodic capture commits what it captured', async () => { - const store = await storeWithSleepingAgent() + it('the periodic capture commits what it captured', () => { + const store = storeWithSleepingAgent() // A live agent whose record main's last push dropped. store.setState({ sleepingAgentSessionsByPaneKey: {} }) store.getState().captureAllSleepingAgentSessions('periodic') const captured = store.getState().sleepingAgentSessionsByPaneKey[PANE] - await Promise.resolve() expect(captured?.providerSession).toEqual(session) - expect(api.sleepTerminalLeaves).toHaveBeenCalledWith({ [PANE]: captured }) + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledWith({ + sleep: { [PANE]: captured }, + wake: [] + }) + }) + + it('the quit capture is sent before the capture returns, so it precedes the quit stage', () => { + const store = storeWithSleepingAgent() + + store.getState().captureAllSleepingAgentSessions('quit') + + const sent = api.commitTerminalSleepingRecords.mock.calls.at(-1)?.[0] + expect(sent?.sleep[PANE]).toBe(store.getState().sleepingAgentSessionsByPaneKey[PANE]) + expect(sent?.sleep[PANE]?.origin).toBe('quit') }) - it('a removal wakes its leaf', async () => { - const store = await storeWithSleepingAgent() + it('a removal wakes its leaf', () => { + const store = storeWithSleepingAgent() store.getState().clearSleepingAgentSession(PANE) - await Promise.resolve() - expect(api.wakeTerminalLeaves).toHaveBeenCalledWith([PANE]) - expect(api.sleepTerminalLeaves).not.toHaveBeenCalled() + expect(api.commitTerminalSleepingRecords).toHaveBeenCalledWith({ sleep: {}, wake: [PANE] }) }) - it('a pane move and a tab close commit nothing: main moves and closes them itself', async () => { - const store = await storeWithSleepingAgent() + it('a pane move and a tab close commit nothing: main moves and closes them itself', () => { + const store = storeWithSleepingAgent() store.getState().transferAgentPaneAuthority({ fromPaneKey: PANE, toPaneKey: MOVED }) expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toEqual([MOVED]) store.getState().closeTab('tab-2') expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) - await Promise.resolve() - - expect(api.sleepTerminalLeaves).not.toHaveBeenCalled() - expect(api.wakeTerminalLeaves).not.toHaveBeenCalled() - }) - it('the quit stage takes the quit capture synchronously, and nothing is sent after', async () => { - const store = await storeWithSleepingAgent() - - store.getState().captureAllSleepingAgentSessions('quit') - const changes = takeSleepingRecordChanges() - await Promise.resolve() - - expect(changes).toEqual({ - sleep: { [PANE]: store.getState().sleepingAgentSessionsByPaneKey[PANE] }, - wake: [] - }) - expect(changes.sleep[PANE]?.origin).toBe('quit') - expect(api.sleepTerminalLeaves).not.toHaveBeenCalled() + expect(api.commitTerminalSleepingRecords).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts index 744c9e519f6d..5485424480db 100644 --- a/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts +++ b/src/renderer/src/store/terminals/terminal-sleeping-record-commits.ts @@ -3,13 +3,10 @@ import type { TerminalSleepingRecordChanges } from '../../../../shared/terminal- type SleepingRecords = Record -/** Each pane's latest unsent value; null drops its record. */ -const unsent = new Map() - /** - * Runs `write`, then sends main the window's own change to its sleeping-agent records, batched per - * microtask. The store keeps the new value meanwhile; main's next topology push brings back the - * committed one. Not for a mirror apply, a tab close or a pane move: main already holds those. + * Runs `write`, then sends main the window's own change to its sleeping-agent records. Sent at + * once: a window's IPC reaches main in order, so the quit capture lands before the quit stage. + * Not for a mirror apply, a tab close or a pane move: main already holds those. */ export function commitSleepingRecordWrite( get: () => { sleepingAgentSessionsByPaneKey: SleepingRecords }, @@ -21,46 +18,19 @@ export function commitSleepingRecordWrite( if (before === after) { return } - const wasEmpty = unsent.size === 0 - for (const [paneKey, record] of Object.entries(after)) { - if (before[paneKey] !== record) { - unsent.set(paneKey, record) - } - } - for (const paneKey of Object.keys(before)) { - if (!Object.hasOwn(after, paneKey)) { - unsent.set(paneKey, null) - } - } - if (wasEmpty && unsent.size > 0) { - queueMicrotask(sendSleepingRecordChanges) + const changes: TerminalSleepingRecordChanges = { + sleep: Object.fromEntries( + Object.entries(after).filter(([paneKey, record]) => before[paneKey] !== record) + ), + wake: Object.keys(before).filter((paneKey) => !Object.hasOwn(after, paneKey)) } -} - -/** Empties the batch; the quit stage carries it synchronously instead. */ -export function takeSleepingRecordChanges(): TerminalSleepingRecordChanges { - const changes: TerminalSleepingRecordChanges = { sleep: {}, wake: [] } - for (const [paneKey, record] of unsent) { - if (record) { - changes.sleep[paneKey] = record - } else { - changes.wake.push(paneKey) - } + if (Object.keys(changes.sleep).length === 0 && changes.wake.length === 0) { + return } - unsent.clear() - return changes -} - -function sendSleepingRecordChanges(): void { - const { sleep, wake } = takeSleepingRecordChanges() // Why optional: an older preload can linger through an in-place renderer reload. - const session = globalThis.window?.api?.session - const warn = (error: unknown): void => + void Promise.resolve( + globalThis.window?.api?.session?.commitTerminalSleepingRecords?.(changes) + ).catch((error: unknown) => console.warn('[terminal-sleep] main did not commit sleeping records', error) - if (wake.length > 0) { - void Promise.resolve(session?.wakeTerminalLeaves?.(wake)).catch(warn) - } - if (Object.keys(sleep).length > 0) { - void Promise.resolve(session?.sleepTerminalLeaves?.(sleep)).catch(warn) - } + ) } diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 58ba41286bf6..651e13dfd058 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -84,8 +84,7 @@ export function createWebWorkspaceSessionApi(): Partial { // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. closeTerminalSurface: async () => ({}), // Web clients keep sleeping records in their saved session. - sleepTerminalLeaves: async () => {}, - wakeTerminalLeaves: async () => {}, + commitTerminalSleepingRecords: async () => {}, // Why empty: web clients follow the host's session tabs, not main's topology push. getTerminalTopologySlices: async () => [], onTerminalTopologyChanged: () => () => {}, From a0072aa26818672a4da583db14257c4d8ab92df4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 17:01:56 -0400 Subject: [PATCH 27/75] test(ssh): admit main's import commit at the session-patch boundary; parking test passes the import seam Co-Authored-By: Claude --- .../terminal-topology-boundary-ratchet.test.ts | 8 +++++++- tests/e2e/direct-ssh-snapshot-parking.unit.test.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts index 575a6a319066..6c2421333907 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -50,7 +50,13 @@ const ALLOWED_REFERENCES: Record = { setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], // The runtime's session controller, reachable from every OrcaRuntime mixin. setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], - patchWorkspaceSession: ['ipc/session.ts'], + patchWorkspaceSession: [ + // Test support: the store fake behind main's SSH export tests. + 'ipc/remote-workspace-export-test-driver.ts', + // A window's SSH snapshot import, committed with the pull it came from. + 'ipc/remote-workspace.ts', + 'ipc/session.ts' + ], stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], setWorkspaceSessionForWorktree: [ // Headless editor-tab retirement, like the headless mobile-session tab writers below. diff --git a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts index 7ac0ec897a7f..748f3b1ebd7f 100644 --- a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts +++ b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts @@ -154,7 +154,8 @@ async function applySnapshot(snapshot: RemoteWorkspaceObservedSnapshot): Promise isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority) + finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority), + importPeerTopology: async () => {} }) expect(result).toBe('applied') } From 72586d0806353230dffe82847d5bcb7e08a49299 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 17:42:53 -0400 Subject: [PATCH 28/75] fix(terminal): a live agent re-attaches to its pane over an exited process's binding Main now keeps an exited pane's binding (R17), so worktree activation refused to rebind a live, unowned agent to the pane its record names whenever that pane still named a dead process, and minted a second tab for it instead. On main the window had cleared that binding on exit. A held binding now blocks the rebind only while the host's census still lists its PTY live. --- .../worktree-agent-activation-gate.test.ts | 40 ++++++++++++++++++- .../worktree-agent-live-surface-adoption.ts | 20 ++++++---- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 89a15407bc9c..dbf30002de48 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -359,9 +359,41 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) + // Main keeps an exited pane's binding (R17); a PTY the host no longer lists holds nothing. + it('rebinds an unowned PTY to its recorded pane over the binding of an exited process', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' } + const { deps, createTab } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]]) + }) + seedExistingSurface(deps.getState(), { + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + boundPtyId: `${WORKTREE_ID}@@exited-pty` + }) + const bindTerminalLeaf = vi.fn(async () => ({ status: 'bound' as const })) + vi.stubGlobal('window', { api: { session: { bindTerminalLeaf } } }) + + try { + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + } finally { + vi.unstubAllGlobals() + } + + expect(createTab).not.toHaveBeenCalled() + expect(deps.getState().ptyIdsByTabId['tab-live']).toEqual([ptyId]) + expect(bindTerminalLeaf).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WORKTREE_ID, + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + ptyId + }) + }) + it.each<[string, (store: GateTestStore) => void]>([ [ - 'the recorded pane now holds another PTY', + 'the recorded pane now holds another live PTY', (store) => seedExistingSurface(store, { tabId: 'tab-live', @@ -386,10 +418,14 @@ describe('worktree agent activation gate', () => { ] ])('mints a tab for an unowned PTY when %s', async (_case, seed) => { const ptyId = `${WORKTREE_ID}@@live-pty` + const otherPtyId = `${WORKTREE_ID}@@other-pty` const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' } const { deps, createTab } = testDeps({ sessions: [listed(ptyId)], - surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]]) + surfaceOwners: new Map([ + [ptyId, { unowned: true, recorded }], + [otherPtyId, { unowned: true, recorded: null }] + ]) }) seed(deps.getState()) const recordedLayoutBefore = structuredClone(deps.getState().terminalLayoutsByTabId['tab-live']) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 97437aaec71c..85b58dae1148 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -88,14 +88,16 @@ function tabExists(store: LiveSurfaceAdoptionStore, tabId: string): boolean { function bindToRecordedSurface( store: LiveSurfaceAdoptionStore, worktreeId: string, - recorded: LiveTerminalSurfaceOwner + recorded: LiveTerminalSurfaceOwner, + liveSurfaceOwners: LiveTerminalSurfaceOwnerIndex ): boolean { const pane = parsePaneKey(recorded.paneKey) if (!pane || !tabExists(store, recorded.tabId)) { return false } const heldPtyId = store.terminalLayoutsByTabId[recorded.tabId]?.ptyIdsByLeafId?.[pane.leafId] - if (heldPtyId && heldPtyId !== recorded.ptyId) { + // Main keeps an exited pane's binding (R17), so only a PTY the host still lists holds the pane. + if (heldPtyId && heldPtyId !== recorded.ptyId && liveSurfaceOwners.has(heldPtyId)) { return false } return bindLivePtyToExactSurface(store, worktreeId, recorded) @@ -166,11 +168,12 @@ export async function adoptLiveWorkspacePtySurfaces( if (unbound.length === 0) { return { surfaced, declinedPtyIds } } - let surfaceOwners: LiveTerminalSurfaceOwnerIndex | null + // An unreadable census lists nothing, so it proves no PTY unowned. + let surfaceOwners: LiveTerminalSurfaceOwnerIndex try { - surfaceOwners = await listSurfaceOwners(worktreeId) + surfaceOwners = (await listSurfaceOwners(worktreeId)) ?? new Map() } catch { - surfaceOwners = null + surfaceOwners = new Map() } const materializedTabIds = new Set() for (const ptyId of unbound) { @@ -180,7 +183,7 @@ export async function adoptLiveWorkspacePtySurfaces( surfaced = true continue } - const owner = surfaceOwners?.get(ptyId) + const owner = surfaceOwners.get(ptyId) // Why: only the execution host can prove a live PTY is unowned, and minting // on anything weaker forks a running agent onto a second empty surface. if (!owner) { @@ -196,7 +199,10 @@ export async function adoptLiveWorkspacePtySurfaces( continue } surfaced = true - if (owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)) { + if ( + owner.recorded && + bindToRecordedSurface(getState(), worktreeId, owner.recorded, surfaceOwners) + ) { continue } getState().createTab(worktreeId, undefined, undefined, { From 2a62f9e02b234b1886decab00654044024f5c2e4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 17:42:54 -0400 Subject: [PATCH 29/75] fix(ssh): report only exports the current agreement still owns; pin the unavailable retry A pull that lands while an export is in flight supersedes its outcome, so main no longer sends that outcome to the window, where a late stale result could flip a freshly synced target to conflict. Rename the export test driver to the *-test-harness.ts name its neighbours use, which also drops its ratchet row. --- ...> remote-workspace-export-test-harness.ts} | 0 src/main/ipc/remote-workspace-export.ts | 22 ++++++----- .../ipc/remote-workspace-patch-queue.test.ts | 2 +- .../ipc/remote-workspace-two-desktops.test.ts | 4 +- src/main/ipc/remote-workspace.test.ts | 39 ++++++++++++++++++- ...terminal-topology-boundary-ratchet.test.ts | 2 - 6 files changed, 54 insertions(+), 15 deletions(-) rename src/main/ipc/{remote-workspace-export-test-driver.ts => remote-workspace-export-test-harness.ts} (100%) diff --git a/src/main/ipc/remote-workspace-export-test-driver.ts b/src/main/ipc/remote-workspace-export-test-harness.ts similarity index 100% rename from src/main/ipc/remote-workspace-export-test-driver.ts rename to src/main/ipc/remote-workspace-export-test-harness.ts diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts index 9e7d184e8abd..a5cff4d4d2d5 100644 --- a/src/main/ipc/remote-workspace-export.ts +++ b/src/main/ipc/remote-workspace-export.ts @@ -73,6 +73,9 @@ export function createRemoteWorkspaceExports( try { result = await patchUnderAgreement(target, session, agreement) } catch (error) { + if (agreements.get(target.id) !== agreement) { + return + } sendPushStatus({ targetId: target.id, authority: agreement, @@ -81,15 +84,16 @@ export function createRemoteWorkspaceExports( }) return } - // An import that landed meanwhile is newer than this export's outcome. - if (agreements.get(target.id) === agreement) { - if (result?.ok) { - const { revision, hostObservationToken } = result.snapshot - agreements.set(target.id, { revision, hostObservationToken, session }) - } else if (result?.reason === 'stale-revision') { - // The host moved on; the window's next pull agrees again. - agreements.delete(target.id) - } + // A pull that landed meanwhile is newer than this export's outcome. + if (agreements.get(target.id) !== agreement) { + return + } + if (result?.ok) { + const { revision, hostObservationToken } = result.snapshot + agreements.set(target.id, { revision, hostObservationToken, session }) + } else if (result?.reason === 'stale-revision') { + // The host moved on; the window's next pull agrees again. + agreements.delete(target.id) } sendPushStatus({ targetId: target.id, authority: agreement, result }) } diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index 5d38c7eeb4b2..a9b54b29d3fb 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -41,7 +41,7 @@ import { import { createRemoteWorkspaceExportDriver, type RemoteWorkspaceExportDriver -} from './remote-workspace-export-test-driver' +} from './remote-workspace-export-test-harness' import { CLIENT_ID } from './remote-workspace-client-identity' import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' import { diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts index 9bcd3ca8fb62..7cff264dd3c9 100644 --- a/src/main/ipc/remote-workspace-two-desktops.test.ts +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -17,7 +17,7 @@ import type { SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { importRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' import type * as RemoteWorkspaceModule from './remote-workspace' -import type * as ExportDriverModule from './remote-workspace-export-test-driver' +import type * as ExportDriverModule from './remote-workspace-export-test-harness' type Relay = { revision: number @@ -152,7 +152,7 @@ async function bootDesktop(): Promise { const { CLIENT_ID } = await import('./remote-workspace-client-identity') const remoteWorkspaceCache = await import('./remote-workspace-snapshot-cache') const { createRemoteWorkspaceExportDriver }: typeof ExportDriverModule = - await import('./remote-workspace-export-test-driver') + await import('./remote-workspace-export-test-harness') remoteWorkspace._resetRemoteWorkspaceCachesForTests() const repo: Repo = { id: 'repo-1', diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 677883f9442c..a9bc17c242e7 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -56,7 +56,7 @@ import { _resetRemoteWorkspaceCachesForTests } from './remote-workspace' import { createRemoteWorkspaceExportDriver, type RemoteWorkspaceExportDriver -} from './remote-workspace-export-test-driver' +} from './remote-workspace-export-test-harness' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { @@ -426,6 +426,43 @@ describe('main exports a session write to the hosts it agrees with', () => { expect(patchRequests('target-1')).toHaveLength(1) }) + it('keeps the agreement after an unavailable relay, so the next local change retries', async () => { + await agreeWith('target-1') + requestByTargetId + .get('target-1') + ?.mockImplementationOnce(async () => ({ ok: false, reason: 'unavailable' })) + + driver.write(sessionWithTab) + await expect(driver.nextPushes()).resolves.toMatchObject([ + { result: { ok: false, reason: 'unavailable' } } + ]) + driver.write({ ...sessionWithTab, activeTabId: null }) + await expect(driver.nextPushes()).resolves.toMatchObject([{ result: { ok: true } }]) + + expect(patchRequests('target-1')).toHaveLength(2) + }) + + it('reports nothing for an export a newer pull superseded', async () => { + const observed = await observeTarget('target-1') + driver.agree('target-1', observed) + let finishPatch: (result: unknown) => void = () => {} + const request = requestByTargetId.get('target-1') + request?.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPatch = resolve + }) + ) + + driver.write(sessionWithTab) + await vi.waitFor(() => expect(patchRequests('target-1')).toHaveLength(1)) + driver.importPeer({ ...observed, targetId: 'target-1', outcome: 'synced', patches: [] }) + finishPatch({ ok: false, reason: 'stale-revision', snapshot: snapshot(emptyRemoteSession, 9) }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(driver.pushes).toEqual([]) + }) + it('keeps a conflicted target out of exports until a whole pull agrees again', async () => { const observed = await observeTarget('target-1') driver.agree('target-1', observed) diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts index 6c2421333907..6750eae5a214 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -51,8 +51,6 @@ const ALLOWED_REFERENCES: Record = { // The runtime's session controller, reachable from every OrcaRuntime mixin. setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], patchWorkspaceSession: [ - // Test support: the store fake behind main's SSH export tests. - 'ipc/remote-workspace-export-test-driver.ts', // A window's SSH snapshot import, committed with the pull it came from. 'ipc/remote-workspace.ts', 'ipc/session.ts' From 85196e0cddc70442e1e5a91d724f366ef72f2f6c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 18:24:45 -0400 Subject: [PATCH 30/75] fix(ssh): an import that kept local state exports it, as the window's save-back did The window's import outcome gains 'kept-local' when its merge keeps tabs or pending layout edits the host has not seen (and on a never-written host). Main then agrees on the host's own copy instead of what it now holds, so that state uploads once; a plain import still agrees on this desktop's state and never exports back. Replaces the revision-0 special case in main. --- src/main/ipc/remote-workspace-export.ts | 8 +-- .../ipc/remote-workspace-two-desktops.test.ts | 49 ++++++++++++++++-- src/main/ipc/remote-workspace.test.ts | 2 +- src/main/ipc/remote-workspace.ts | 2 +- .../hooks/remote-workspace-snapshot-apply.ts | 51 +++++++++++++++---- ...e-workspace-snapshot-import-commit.test.ts | 31 +++++++++++ .../remote-workspace-target-sync.test.ts | 2 +- .../src/hooks/remote-workspace-target-sync.ts | 2 +- src/shared/remote-workspace-types.ts | 8 ++- 9 files changed, 130 insertions(+), 25 deletions(-) diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts index a5cff4d4d2d5..5f942c7fbda1 100644 --- a/src/main/ipc/remote-workspace-export.ts +++ b/src/main/ipc/remote-workspace-export.ts @@ -142,11 +142,11 @@ export function createRemoteWorkspaceExports( agreements.delete(targetId) return } - // An import agrees on what this desktop now holds, so it never exports back. A host never - // written (revision 0) had nothing to import: agree on its own copy, and anything this desktop - // holds beyond it seeds the host. + // An import agrees on what this desktop now holds, so it never exports back. When the window + // kept local state the host lacks, agree on the host's own copy instead, so that state exports + // once, as the window's save-back did. const session = - revision === 0 + outcome === 'kept-local' ? (getCachedRemoteWorkspaceSnapshot(targetId)?.session ?? null) : sessionForTarget(readWorktreeOwners(), targetId) agreements.set(targetId, { revision, hostObservationToken, session }) diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts index 7cff264dd3c9..365e55306a42 100644 --- a/src/main/ipc/remote-workspace-two-desktops.test.ts +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -107,6 +107,8 @@ type Desktop = { clientId: string /** Set to make this desktop's next imports conflicted (unplaceable host tabs). */ conflicted: boolean + /** Tabs the window created during a download and has not saved yet; its merge keeps them. */ + unsavedTabs: WorkspaceSessionState['tabsByWorktree'][string] imports: number } @@ -165,9 +167,10 @@ async function bootDesktop(): Promise { const desktop: Desktop = { clientId: CLIENT_ID, conflicted: false, + unsavedTabs: [], imports: 0, - // The window's importer: today's projection of the host snapshot, committed through main, - // then saved back once the window has applied it. + // The window's importer: the host snapshot's projection plus the unsaved tabs the merge keeps, + // committed through main, then saved back once the window has applied it. driver: createRemoteWorkspaceExportDriver( { getRepos: () => [repo] }, (event: RemoteWorkspaceChangedEvent) => { @@ -178,8 +181,13 @@ async function bootDesktop(): Promise { resolveWorktreeId: (path) => (path === WORKTREE_PATH ? WORKTREE_ID : null), executionHostId: `ssh:${TARGET.id}` }) + const hostTabs = imported.tabsByWorktree[WORKTREE_ID] ?? [] + const keptTabs = desktop.unsavedTabs.filter( + (local) => !hostTabs.some((host) => host.id === local.id) + ) + desktop.unsavedTabs = [] const patch = { - tabsByWorktree: imported.tabsByWorktree, + tabsByWorktree: { ...imported.tabsByWorktree, [WORKTREE_ID]: [...hostTabs, ...keptTabs] }, terminalLayoutsByTabId: imported.terminalLayoutsByTabId } desktop.imports += 1 @@ -187,7 +195,7 @@ async function bootDesktop(): Promise { targetId: TARGET.id, revision: event.snapshot.revision, hostObservationToken: event.snapshot.hostObservationToken, - outcome: desktop.conflicted ? 'conflict' : 'synced', + outcome: desktop.conflicted ? 'conflict' : keptTabs.length > 0 ? 'kept-local' : 'synced', patches: [{ hostId: `ssh:${TARGET.id}`, patch }] }) desktop.driver.write(patch) @@ -263,6 +271,39 @@ describe('two desktops, one relay', () => { expect(relay.revision).toBe(5) }) + it('uploads a tab created during a download once, and the desktops converge', async () => { + // B stands on the shared worktree and A does not, so each merge keeps a different active + // worktree; that difference alone must not bounce uploads between them. + b.driver.write({ activeRepoId: 'repo-1', activeWorktreeId: WORKTREE_ID }) + await settle() + expect(relay.patches).toEqual([{ clientId: b.clientId, baseRevision: 3 }]) + relay.patches = [] + a.imports = 0 + b.unsavedTabs = [tab('tab-b')] + + a.driver.write( + withSplit( + { + ...a.driver.readSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } + }, + 'tab-a' + ) + ) + await settle() + + expect(relay.patches).toEqual([ + { clientId: a.clientId, baseRevision: 4 }, + { clientId: b.clientId, baseRevision: 5 } + ]) + expect(relay.session.tabsByWorktreePath[WORKTREE_PATH]?.map(({ id }) => id)).toEqual([ + 'tab-a', + 'tab-b' + ]) + expect(a.imports).toBe(1) + expect(b.imports).toBe(1) + }) + it('keeps a desktop whose import conflicted out of exports', async () => { b.conflicted = true a.driver.write( diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index a9bc17c242e7..c96421967bd0 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -493,7 +493,7 @@ describe('main exports a session write to the hosts it agrees with', () => { const observed = await observeTarget('target-1') expect(observed.revision).toBe(0) - driver.agree('target-1', observed) + driver.agree('target-1', observed, 'kept-local') await new Promise((resolve) => setTimeout(resolve, 0)) expect(patchRequests('target-1')).toHaveLength(hasTabs ? 1 : 0) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index d9eecc99139a..fc91bfe2121c 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -62,7 +62,7 @@ function isValidPeerImport(pull: RemoteWorkspacePeerImport): boolean { typeof pull.hostObservationToken === 'string' && pull.hostObservationToken.length > 0 && pull.hostObservationToken.length <= 128 && - (pull.outcome === 'synced' || pull.outcome === 'conflict') && + ['synced', 'kept-local', 'conflict'].includes(pull.outcome) && Array.isArray(pull.patches) ) } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 1d4a39b3fd82..aca9f758e77f 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -11,6 +11,7 @@ import type { DirectSshAuthority } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { buildWorkspaceSessionHostPatches } from '../lib/workspace-session-host-persistence' import type { AppState } from '../store/types' import { @@ -110,6 +111,30 @@ function mergedFields( ) } +/** Whether the merge kept, on the target's worktrees, tabs or layout edits the host does not hold. */ +function mergeKeptLocalState( + merged: WorkspaceSessionState, + remote: WorkspaceSessionState, + worktreeIds: ReadonlySet, + localLayoutTabIds: ReadonlySet +): boolean { + const hostTabIds = new Set( + Object.values(remote.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + return ( + [...worktreeIds].some((worktreeId) => + (merged.tabsByWorktree[worktreeId] ?? []).some((tab) => !hostTabIds.has(tab.id)) + ) || + [...localLayoutTabIds].some( + (tabId) => + !terminalLayoutNodeEqual( + merged.terminalLayoutsByTabId[tabId]?.root, + remote.terminalLayoutsByTabId[tabId]?.root + ) + ) + ) +} + function currentRecoveryTabIds( state: AppState, authority: DirectSshAuthority, @@ -198,6 +223,15 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ }) } const current = buildWorkspaceSessionPayload(state) + const localLayoutTabIds = new Set( + [...worktreeIds].flatMap((id) => + (state.tabsByWorktree[id] ?? []) + .filter( + (tab) => state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId + ) + .map((tab) => tab.id) + ) + ) const merged = mergeDirectSshRemoteWorkspaceSession( current, remoteSession, @@ -206,16 +240,7 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ currentRecoveryTabIds(state, authority, worktreeIds), toSshExecutionHostId(authority.targetId), state.closedTerminalTabTombstonesByTabId, - new Set( - [...worktreeIds].flatMap((id) => - (state.tabsByWorktree[id] ?? []) - .filter( - (tab) => - state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId - ) - .map((tab) => tab.id) - ) - ) + localLayoutTabIds ) if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return 'stale' @@ -231,7 +256,11 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ targetId: authority.targetId, revision: snapshot.revision, hostObservationToken: snapshot.hostObservationToken, - outcome: hasUnplacedTerminalTabs ? 'conflict' : 'synced', + outcome: hasUnplacedTerminalTabs + ? 'conflict' + : mergeKeptLocalState(merged, remoteSession, worktreeIds, localLayoutTabIds) + ? 'kept-local' + : 'synced', patches: buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore) }).catch((error: unknown) => console.warn('[remote-workspace] import commit failed:', error)) currentStore.hydrateWorkspaceSession(merged, { diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts index 9f0d9c11a898..94e13bd49529 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts @@ -157,6 +157,37 @@ describe('direct SSH snapshot import commit', () => { } }) + it('reports kept-local when the merge keeps a tab the host has not seen', async () => { + const store = createTestStore() + seedCatalog(store) + await importOf(store, snapshot(1, ['agent'])) + // Created during the download, so the host's next snapshot still lists only `agent`. + store.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [ + ...(store.getState().tabsByWorktree[WORKTREE_ID] ?? []), + { + id: 'setup', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'setup', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + } + }) + + await expect(importOf(store, snapshot(2, ['agent']))).resolves.toMatchObject({ + outcome: 'kept-local' + }) + await expect(importOf(store, snapshot(3, ['agent', 'setup']))).resolves.toMatchObject({ + outcome: 'synced' + }) + }) + it('reports a conflict when the window cannot place every host tab', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 20c73f521e0d..8925c9263283 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -33,7 +33,7 @@ describe('createRemoteWorkspaceTargetSync', () => { targetId: 'target-a', revision: 0, hostObservationToken: snapshot(0).hostObservationToken, - outcome: 'synced', + outcome: 'kept-local', patches: [] }) }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 358edab2563c..75f67e50fc37 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -197,7 +197,7 @@ export function createRemoteWorkspaceTargetSync( targetId: authority.targetId, revision: snapshot.revision, hostObservationToken: snapshot.hostObservationToken, - outcome: 'synced', + outcome: 'kept-local', patches: [] }) } diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index bad6a4c27f63..4e5c3ce8dcb8 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -76,8 +76,12 @@ export type RemoteWorkspaceExportAuthority = { /** A window's pull from one host: the session writes it imports and whether the import is whole. */ export type RemoteWorkspacePeerImport = RemoteWorkspaceExportAuthority & { targetId: string - /** `conflict`: the window could not place every host tab, so this desktop must not export. */ - outcome: 'synced' | 'conflict' + /** + * `kept-local`: this desktop holds state the host lacks (tabs it never saw, unsent layout edits, + * or anything at all for a host never written), which must export. `conflict`: the window could + * not place every host tab, so this desktop must not export. + */ + outcome: 'synced' | 'kept-local' | 'conflict' /** The import's session patch per owning partition; empty when the host had nothing to import. */ patches: { hostId?: ExecutionHostId; patch: WorkspaceSessionPatch }[] } From 68b18b300ea03bbcb79daa766323e006b4006ad0 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 19:42:31 -0400 Subject: [PATCH 31/75] refactor(terminal): window saves carry presentation only; delete the rebase and binding replay (core C7) Every window session write (session:set, set-sync, patch, the quit stage) now enters mergeRendererPresentationSave: the window's titles, colours, order, focus, buffers and tab groups land, while main keeps which tabs and panes exist, their trees and bindings, creation fields, sleeping records, close records and fences. The window's tab.ptyId is its live attachment and never replaces main's binding. A runtime: partition is fed by its server, so the window's rows and sleeping records stand there, except for a repo main has fenced (a worktree removal), as before. Main's own writers get a plain set. Deleted: the membership rebase, binding replay and its recovery helpers, the save-time retirement sanitizer, the runtime-authored field guard (folded into the window entry), the revision-0 exception and hostAdmittedMembership. A headless pane-layout write now refuses a tree with other panes itself. The sleeping-record commit moves into the commit module, so no IPC file writes the session directly. Co-Authored-By: Claude --- .../ipc/pty-pane-claim-arbitration.test.ts | 2 - .../pty-pane-reservation-settlement.test.ts | 1 - ...ty-runtime-ssh-binding-persistence.test.ts | 1 - .../ipc/pty-spawn-placement-threading.test.ts | 1 - src/main/ipc/pty/runtime/spawn-commit.ts | 1 - .../ipc/renderer-shutdown-checkpoint.test.ts | 19 +- src/main/ipc/renderer-shutdown-checkpoint.ts | 3 +- src/main/ipc/session.test.ts | 56 +++ src/main/ipc/session.ts | 43 +-- ...ence-cohort-and-identity-migration.test.ts | 5 +- ...sistence-host-partitioned-sessions.test.ts | 13 +- ...ersistence-layout-binding-recovery.test.ts | 340 +----------------- ...istence-pty-binding-reconciliation.test.ts | 9 +- ...persistence-split-pane-incarnation.test.ts | 3 +- ...ence-ssh-lease-tombstone-retention.test.ts | 2 +- ...ence-ssh-remote-pty-binding-replay.test.ts | 243 +------------ .../persistence-ssh-remote-pty-leases.test.ts | 250 +------------ ...e-terminal-membership-window-save.test.ts} | 47 ++- ...sistence-worktree-deletion-fencing.test.ts | 13 +- .../ssh-pty-lease-tombstone-retention.ts | 7 +- .../loading-store/pty-binding-persistence.ts | 2 - .../pty-binding-placement-inertness.test.ts | 6 +- .../pty-binding-session-update.ts | 18 +- .../loading-store/session-host-partitions.ts | 29 +- .../session-snapshot-operations.ts | 7 +- .../ssh-lease-recovery-operations.ts | 29 +- .../loading-store/store-domain-composition.ts | 20 +- ...re-runtime-authored-session-writes.test.ts | 23 +- .../terminal-binding-recovery.ts | 115 ------ .../workspace-session-snapshot-publication.ts | 9 - ...ce-session-terminal-binding-replay.test.ts | 212 ----------- ...rkspace-session-terminal-binding-replay.ts | 222 ------------ ...-authored-workspace-session-fields.test.ts | 100 ------ ...ntime-authored-workspace-session-fields.ts | 60 ---- .../terminal-topology/terminal-layout-set.ts | 7 +- .../terminal-leaf-move.test.ts | 3 +- ...erminal-renderer-presentation-save.test.ts | 259 +++++++++++++ .../terminal-renderer-presentation-save.ts | 257 +++++++++++++ .../terminal-sleep-quit-resume.test.ts | 74 +++- ...terminal-topology-boundary-ratchet.test.ts | 6 +- .../terminal-topology-commit.ts | 28 ++ .../terminal-topology-model.test.ts | 35 +- ...wledged-terminal-tab-retirement-fixture.ts | 4 +- ...minal-close-persistence-durability.test.ts | 105 +++--- ...on-terminal-persistence-retirement.test.ts | 162 +-------- ...session-terminal-persistence-retirement.ts | 37 -- .../orca-runtime-close-mobile-session-tab.ts | 2 +- ...time-persist-headless-session-tab-props.ts | 34 +- ...rca-runtime-terminal-close-records.test.ts | 7 +- .../orca-runtime-terminal-retirement.test.ts | 5 +- ...rca-runtime-terminal-surface-close.test.ts | 11 +- .../mobile-session-tabs-part-06.spec.ts | 7 + ...rkspace-session-membership-scaling.test.ts | 123 ------- ...e-session-terminal-membership-authority.ts | 268 -------------- .../ssh-reattach-pane-cardinality.test.ts | 5 +- ...-removal-session-partition-fencing.test.ts | 11 +- ...ault-terminal-tabs-applied-persist.test.ts | 4 +- ...-stale-resume-execution-host-scope.spec.ts | 2 + ...inal-layout-parity-declared-differences.ts | 38 +- tests/e2e/terminal-layout-parity-snapshot.ts | 12 +- tests/e2e/terminal-layout-parity.spec.ts | 152 +++++++- 61 files changed, 1136 insertions(+), 2433 deletions(-) rename src/main/{persistence-host-admitted-terminal-membership.test.ts => persistence-terminal-membership-window-save.test.ts} (75%) delete mode 100644 src/main/persistence/loading-store/terminal-binding-recovery.ts delete mode 100644 src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts delete mode 100644 src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts delete mode 100644 src/main/persistence/runtime-authored-workspace-session-fields.test.ts delete mode 100644 src/main/persistence/runtime-authored-workspace-session-fields.ts create mode 100644 src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts delete mode 100644 src/main/runtime/workspace-session-membership-scaling.test.ts delete mode 100644 src/main/runtime/workspace-session-terminal-membership-authority.ts diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 86fe3381f2bf..933112763940 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -159,7 +159,6 @@ describe('registerPtyHandlers', () => { leafId, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, origin: 'spawn' }) }) @@ -519,7 +518,6 @@ describe('registerPtyHandlers', () => { leafId, ptyId: 'pty-shared', startupCwd: '/tmp', - hostAdmittedMembership: true, origin: 'spawn' }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..fbfe1324671a 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -542,7 +542,6 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-1@@relay-pty', - hostAdmittedMembership: true, origin: 'spawn' }, 'ssh:ssh-1' diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index b66cd30e71fe..a83720f8f0fc 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -202,7 +202,6 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-reattach-ok@@relay-pty', - hostAdmittedMembership: true, origin: 'reattach' }, 'ssh:ssh-reattach-ok' diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts index 77d5763a8ee7..2d31198264bc 100644 --- a/src/main/ipc/pty-spawn-placement-threading.test.ts +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -177,7 +177,6 @@ describe('pty spawn placement threading', () => { leafId: LEAF, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, placement: SPLIT, origin: 'spawn' }) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index c38637371c36..3f3c20bc6d50 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -144,7 +144,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { tabId, leafId, ptyId: ctx.result.id, - hostAdmittedMembership: true, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), ...(expectedSourceBinding ? { expectedSourceBinding } : {}), diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 43491ba31af4..ed1ec159ae65 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -24,6 +24,7 @@ vi.mock('electron', () => ({ } })) +import { getDefaultWorkspaceSession } from '../../shared/constants' import { registerRendererShutdownCheckpointHandler, SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS @@ -41,6 +42,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('stages every shutdown mutation before queueing persistence', () => { const callOrder: string[] = [] const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn((_state, hostId?: string) => { callOrder.push(`session:${hostId ?? 'local'}`) }), @@ -64,12 +66,12 @@ describe('registerRendererShutdownCheckpointHandler', () => { expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith( 1, - localSession, + expect.objectContaining(localSession), undefined ) expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith( 2, - remoteSession, + expect.objectContaining(remoteSession), 'runtime:host-1' ) expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' }) @@ -84,6 +86,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('never stages the source partition of a fenced host', () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), getSshTarget: vi.fn(() => ({ orcadFence: { environmentId: 'env-1' } })), updateUI: vi.fn(), @@ -98,12 +101,16 @@ describe('registerRendererShutdownCheckpointHandler', () => { }) expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledTimes(1) - expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledWith({}, undefined) + expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledWith( + expect.any(Object), + undefined + ) expect(event.returnValue).toEqual({ ok: true }) }) it('reports a staging failure so the renderer can retry', () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(() => { throw new Error('disk full') @@ -121,6 +128,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('does not queue persistence when staging is incomplete', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) @@ -141,6 +149,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('stages synchronously without waiting on the durable write', () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) @@ -157,6 +166,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('holds the checkpoint open until the durable write settles', async () => { let resolveFlush!: () => void const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn( @@ -184,6 +194,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('reports a failed durable write instead of a successful checkpoint', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.reject(new Error('disk full'))) @@ -200,6 +211,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('fails the checkpoint when the durable write outlives its deadline', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn( @@ -224,6 +236,7 @@ describe('registerRendererShutdownCheckpointHandler', () => { it('reports success before any checkpoint is staged', async () => { const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 82fcbee0d4e2..da5091493811 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -4,6 +4,7 @@ import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { Store } from '../persistence' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { stageRendererSessionBeforeUnload } from '../persistence/terminal-topology/terminal-renderer-presentation-save' type StageBeforeUnloadSyncArgs = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] @@ -51,7 +52,7 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { try { for (const { state, hostId } of args.sessions) { if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { - store.stageWorkspaceSessionBeforeUnload(state, hostId) + stageRendererSessionBeforeUnload(store, state, hostId) } } store.updateUI(args.ui) diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index 32f4ac8d6c65..f01eada9a4c9 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -25,14 +25,40 @@ vi.mock('electron', () => ({ } })) +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' import { registerSessionHandlers } from './session' +const WORKTREE = 'repo::/worktree' + +function sessionWithTab(tabId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [ + { + id: tabId, + ptyId: null, + worktreeId: WORKTREE, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } +} + describe('registerSessionHandlers', () => { it('drops renderer writes to a fenced host source partition and keeps every other one', async () => { const store = { getSshTarget: vi.fn((id: string) => id === 'fenced' ? { orcadFence: { environmentId: 'env-1' } } : {} ), + getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()), setWorkspaceSession: vi.fn(), patchWorkspaceSession: vi.fn(), flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) @@ -58,4 +84,34 @@ describe('registerSessionHandlers', () => { await vi.waitFor(() => expect(event.returnValue).toBe(true)) expect(store.setWorkspaceSession).toHaveBeenCalledTimes(2) }) + + it('set, set-sync, patch and the quit stage all keep the tabs main holds', async () => { + const written: WorkspaceSessionState['tabsByWorktree'][] = [] + const record = (session: Partial): void => { + written.push(session.tabsByWorktree ?? {}) + } + const store = { + getSshTarget: vi.fn(() => ({})), + getWorkspaceSession: vi.fn(() => sessionWithTab('main-tab')), + setWorkspaceSession: vi.fn(record), + patchWorkspaceSession: vi.fn(record), + stageWorkspaceSessionBeforeUnload: vi.fn(record), + updateUI: vi.fn(), + flushPendingOrThrowAsync: vi.fn(() => Promise.resolve()) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test touch only the store methods stubbed above and never the runtime. + registerSessionHandlers(store as never, {} as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. + registerRendererShutdownCheckpointHandler(store as never) + const stale = sessionWithTab('stale-tab') + + await handlers.get('session:set')?.({}, stale) + await handlers.get('session:patch')?.({}, { tabsByWorktree: stale.tabsByWorktree }) + const event: { returnValue?: unknown } = {} + handlers.get('session:set-sync')?.(event, stale) + await vi.waitFor(() => expect(event.returnValue).toBe(true)) + handlers.get('app:stage-before-unload-sync')?.({}, { sessions: [{ state: stale }], ui: {} }) + + expect(written).toEqual(Array(4).fill(sessionWithTab('main-tab').tabsByWorktree)) + }) }) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index e5fb3870cd30..328e9079c529 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -19,9 +19,12 @@ import type { import { sleepingAgentSessionsByPaneKeySchema } from '../../shared/workspace-session-sleeping-agents' import { bindLeaf, - sleepLeaf, - wakeLeaf + commitSleepingRecords } from '../persistence/terminal-topology/terminal-topology-commit' +import { + patchRendererSession, + setRendererSession +} from '../persistence/terminal-topology/terminal-renderer-presentation-save' export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void { // Why: renderer saves would change a fenced host's frozen source partition. @@ -44,13 +47,13 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { if (!isFenced(hostId)) { - store.setWorkspaceSession(args, hostId) + setRendererSession(store, args, hostId) } }) ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { if (!isFenced(hostId)) { - store.patchWorkspaceSession(args, hostId) + patchRendererSession(store, args, hostId) } }) @@ -84,25 +87,17 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic ipcMain.handle( 'session:commit-terminal-sleeping-records', (_event, changes: { sleep?: unknown; wake?: unknown } | undefined) => { - const sleep = sleepingAgentSessionsByPaneKeySchema.safeParse(changes?.sleep).data ?? {} - const wake = Array.isArray(changes?.wake) - ? changes.wake.filter((paneKey) => typeof paneKey === 'string') - : [] - for (const hostId of store.getWorkspaceSessionHostIds()) { - if (isFenced(hostId)) { - continue - } - const isHome = (worktreeId: string): boolean => - runtime.getTerminalTopologyHomeHostId(worktreeId) === hostId - const session = store.getWorkspaceSession(hostId) - const next = sleepLeaf(wakeLeaf(session, wake, isHome), sleep, isHome) - if (next !== session) { - store.patchWorkspaceSession( - { sleepingAgentSessionsByPaneKey: next.sleepingAgentSessionsByPaneKey }, - hostId - ) - } - } + commitSleepingRecords( + store, + { + sleep: sleepingAgentSessionsByPaneKeySchema.safeParse(changes?.sleep).data ?? {}, + wake: Array.isArray(changes?.wake) + ? changes.wake.filter((paneKey) => typeof paneKey === 'string') + : [] + }, + (worktreeId) => runtime.getTerminalTopologyHomeHostId(worktreeId), + isFenced + ) } ) @@ -148,7 +143,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic void (async () => { try { if (!isFenced(hostId)) { - store.setWorkspaceSession(args, hostId) + setRendererSession(store, args, hostId) } await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) } catch (error) { diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 02afa7e3da57..a7ac6f13dee4 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -378,8 +378,9 @@ describe('Store.migrateWorktreeIdentity', () => { expect(hostSession.tabsByWorktree[OLD]).toBeUndefined() expect(hostSession.tabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(hostSession.activeWorkspaceKey).toBe(NEW_WORKSPACE_KEY) - expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(9) - expect(hostSession.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(8) + // A legacy close record stays a fence, now naming the renamed worktree. + expect(hostSession.terminalSurfaceTombstonesByPaneKey?.['host-tab:leaf']?.worktreeId).toBe(NEW) }) it('rewrites parentWorktreeId back-references in other lineage entries', async () => { diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 9c3aad9620f5..ef07f6872bef 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -16,6 +16,10 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' +import { + patchRendererSession, + setRendererSession +} from './persistence/terminal-topology/terminal-renderer-presentation-save' import { isTerminalLeafId } from '../shared/stable-pane-id' import { worktreeWorkspaceKey } from '../shared/workspace-scope' @@ -352,13 +356,14 @@ describe('Store host-partitioned workspace sessions', () => { 'runtime:env-b' ) - store.setWorkspaceSession( + setRendererSession( + store, { ...getDefaultWorkspaceSession(), tabsByWorktree: staleTabs }, 'runtime:env-a' ) - store.patchWorkspaceSession({ tabsByWorktree: staleTabs }, 'runtime:env-a') + patchRendererSession(store, { tabsByWorktree: staleTabs }, 'runtime:env-a') - expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toEqual([]) + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() expect( store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.duplicate ).toBe(2) @@ -408,7 +413,7 @@ describe('Store host-partitioned workspace sessions', () => { store.setWorkspaceSession(session, 'runtime:env-b') store.removeWorkspaceSessionStateForWorktree(worktreeId, 'runtime:env-a') - store.setWorkspaceSession(session, 'runtime:env-a') + setRendererSession(store, session, 'runtime:env-a') store.flush() const reloaded = await createStore() diff --git a/src/main/persistence-layout-binding-recovery.test.ts b/src/main/persistence-layout-binding-recovery.test.ts index 7f3998eb58ba..949db8641936 100644 --- a/src/main/persistence-layout-binding-recovery.test.ts +++ b/src/main/persistence-layout-binding-recovery.test.ts @@ -1,16 +1,12 @@ -import { closeTestStores, testState, createStore, makeRepo } from './persistence-test-harness' +import { closeTestStores, testState, createStore } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId } from '../shared/stable-pane-id' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' -import { - TEST_LEAF_1, - TEST_LEAF_2, - TEST_LEAF_LIVE, - TEST_LEAF_EXPIRED -} from './persistence-session-fixtures' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -64,7 +60,7 @@ describe('Store', () => { await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - it('drops legacy leaf-keyed records from mixed-version writes before binding preservation', async () => { + it('drops legacy leaf-keyed records from a mixed-version window save and keeps main binding', async () => { const store = await createStore() store.setWorkspaceSession({ activeRepoId: 'r1', @@ -96,7 +92,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -132,12 +128,7 @@ describe('Store', () => { expect(layout.expandedLeafId).toBeNull() expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'daemon-pty' }) expect(layout.buffersByLeafId).toBeUndefined() - expect(layout.scrollbackRefsByLeafId).toEqual({ - [TEST_LEAF_1]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) - }) - const ref = layout.scrollbackRefsByLeafId?.[TEST_LEAF_1] - expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('Current buffer') - expect(layout.titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Current' }) + expect(layout.titlesByLeafId).toBeUndefined() expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') }) @@ -213,9 +204,9 @@ describe('Store', () => { expect(leafId).not.toBe(TEST_LEAF_2) }) - // An `expired` lease means reattach gave up, not that the remote shell died. Dropping the - // binding here left the pane unable to re-adopt a process that is still running. - it('restores a cleared SSH binding after a lease expired so the pane can reattach', async () => { + // An `expired` lease means reattach gave up, not that the remote shell died; the pane must + // keep main's binding to re-adopt a process that is still running. + it('keeps the SSH binding after a lease expired when a window save clears it', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', @@ -253,7 +244,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -288,7 +279,7 @@ describe('Store', () => { }) }) - it('does not restore cleared SSH bindings after a lease was terminated', async () => { + it('a window save still carrying a binding does not undo main terminating its lease', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', @@ -296,7 +287,7 @@ describe('Store', () => { worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, - state: 'terminated' + state: 'attached' }) store.setWorkspaceSession({ activeRepoId: 'r1', @@ -312,7 +303,7 @@ describe('Store', () => { color: null, sortOrder: 0, createdAt: 1, - ptyId: 'remote-pty' + ptyId: 'ssh:ssh-1@@remote-pty' } ] }, @@ -321,12 +312,14 @@ describe('Store', () => { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } + ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) - store.setWorkspaceSession({ + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -340,7 +333,7 @@ describe('Store', () => { color: null, sortOrder: 0, createdAt: 1, - ptyId: null + ptyId: 'ssh:ssh-1@@remote-pty' } ] }, @@ -349,7 +342,7 @@ describe('Store', () => { root: { type: 'leaf', leafId: TEST_LEAF_1 }, activeLeafId: TEST_LEAF_1, expandedLeafId: null, - ptyIdsByLeafId: {} + ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } } } }) @@ -358,299 +351,4 @@ describe('Store', () => { expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) - - it('does not let an expired lease for another tab suppress a matching pty id', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - worktreeId: 'wt1', - tabId: 'tab-expired', - leafId: TEST_LEAF_EXPIRED, - state: 'expired' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab-live', - tabsByWorktree: { - wt1: [ - { - id: 'tab-live', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab-live', - tabsByWorktree: { - wt1: [ - { - id: 'tab-live', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ - [TEST_LEAF_LIVE]: 'remote-pty' - }) - }) - - it('does not let an expired lease for another SSH target suppress the same tab binding', async () => { - const store = await createStore() - store.addRepo(makeRepo({ id: 'repo-live', connectionId: 'ssh-live' })) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-expired', - ptyId: 'remote-pty', - worktreeId: 'repo-live::/wt', - tabId: 'tab-live', - leafId: TEST_LEAF_LIVE, - state: 'expired' - }) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-live', - ptyId: 'remote-pty', - worktreeId: 'repo-live::/wt', - tabId: 'tab-live', - leafId: TEST_LEAF_LIVE, - state: 'detached' - }) - store.setWorkspaceSession({ - activeRepoId: 'repo-live', - activeWorktreeId: 'repo-live::/wt', - activeTabId: 'tab-live', - tabsByWorktree: { - 'repo-live::/wt': [ - { - id: 'tab-live', - worktreeId: 'repo-live::/wt', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_LIVE]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'repo-live', - activeWorktreeId: 'repo-live::/wt', - activeTabId: 'tab-live', - tabsByWorktree: { - 'repo-live::/wt': [ - { - id: 'tab-live', - worktreeId: 'repo-live::/wt', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - 'tab-live': { - root: { type: 'leaf', leafId: TEST_LEAF_LIVE }, - activeLeafId: TEST_LEAF_LIVE, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree['repo-live::/wt'][0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId['tab-live'].ptyIdsByLeafId).toEqual({ - [TEST_LEAF_LIVE]: 'remote-pty' - }) - }) - - it('does not treat contextless expired leases as wildcards for contextual bindings', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - state: 'expired' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree.wt1[0].ptyId).toBe('remote-pty') - expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'remote-pty' - }) - }) - - it('does not treat layout-level leases missing worktree context as contextual matches', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'expired' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty' } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'remote-pty' - }) - }) }) diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index c4fbf61f07b4..013e33711f6b 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -148,7 +149,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -250,7 +251,7 @@ describe('Store', () => { expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' }) ]) - store.setWorkspaceSession({ + setRendererSession(store, { ...admitted, tabsByWorktree: { ...admitted.tabsByWorktree, @@ -278,7 +279,7 @@ describe('Store', () => { [TEST_LEAF_1]: 'Fresh pane title' }) - store.setWorkspaceSession({ + setRendererSession(store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { wt1: [ @@ -424,7 +425,7 @@ describe('Store', () => { [TEST_LEAF_2]: 'pty-split' }) - store.setWorkspaceSession(staleRendererSession, hostId) + setRendererSession(store, staleRendererSession, hostId) expect( store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId ).toMatchObject({ diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index b628c28384bb..9ad634e3d0a3 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -10,6 +10,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' @@ -327,7 +328,7 @@ describe('Store', () => { const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId)) delete rendererSnapshot.terminalPtyIncarnationsByPaneKey delete rendererSnapshot.terminalTopologyRevisionByRepoId - store.setWorkspaceSession(rendererSnapshot, hostId) + setRendererSession(store, rendererSnapshot, hostId) expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( 'inc-live' diff --git a/src/main/persistence-ssh-lease-tombstone-retention.test.ts b/src/main/persistence-ssh-lease-tombstone-retention.test.ts index 1e52c6ad41c8..6bc860402503 100644 --- a/src/main/persistence-ssh-lease-tombstone-retention.test.ts +++ b/src/main/persistence-ssh-lease-tombstone-retention.test.ts @@ -71,7 +71,7 @@ describe('operator-closed SSH lease tombstones', () => { store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') - // `isRestorablePtyBinding` still consults this row to refuse replaying that binding. + // A persisted binding still names the pty, so the row stays. expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) ]) diff --git a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts index 77054a2eafb0..06953176005b 100644 --- a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts +++ b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts @@ -4,8 +4,9 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { TEST_LEAF_1 } from './persistence-session-fixtures' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -59,7 +60,7 @@ describe('Store', () => { await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - it('retains an SSH host binding when a stale renderer clears its pty map', async () => { + it('keeps the SSH host binding when a stale window save clears its pty map', async () => { const store = await createStore() const hostId = 'ssh:ssh-1' const session = { @@ -98,7 +99,8 @@ describe('Store', () => { leafId: TEST_LEAF_1, state: 'detached' }) - store.setWorkspaceSession( + setRendererSession( + store, { ...session, tabsByWorktree: { @@ -118,56 +120,8 @@ describe('Store', () => { }) }) - it('does not replay a scoped SSH binding from a different host partition', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-2@@foreign' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-2@@foreign' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) - }) - - it('retains a runtime host binding when no death evidence exists', async () => { + // Another server feeds the window its runtime: rows, so the window's save stands there. + it('takes the window rows as sent for a runtime host partition', async () => { const store = await createStore() const hostId = 'runtime:env-1' const session: WorkspaceSessionState = { @@ -198,7 +152,8 @@ describe('Store', () => { } } store.setWorkspaceSession(session, hostId) - store.setWorkspaceSession( + setRendererSession( + store, { ...session, tabsByWorktree: { @@ -210,187 +165,9 @@ describe('Store', () => { }, hostId ) - expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'runtime-pty' - }) + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) // `expired` is the client admitting it lost its route; the remote shell may still be running, // so the pane keeps the binding it needs to reattach. - it('restores a host binding after its SSH lease expires', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-1@@expired' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@expired' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'expired', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'expired' - }) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBe('ssh:ssh-1@@expired') - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'ssh:ssh-1@@expired' - }) - }) - - it('does not resurrect a host binding after its SSH lease was terminated', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-1@@expired' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@expired' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'expired', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'terminated' - }) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) - }) - - it('retains surviving leaves while ignoring bindings for removed leaves', async () => { - const store = await createStore() - const hostId = 'runtime:env-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'runtime-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 } - }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { - [TEST_LEAF_1]: 'runtime-pty-1', - [TEST_LEAF_2]: 'runtime-pty-2' - } - } - } - } - store.setWorkspaceSession(session, hostId) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { - ...session.terminalLayoutsByTabId.tab1!, - root: { type: 'leaf', leafId: TEST_LEAF_2 }, - activeLeafId: TEST_LEAF_2, - ptyIdsByLeafId: {} - } - } - }, - hostId - ) - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_2]: 'runtime-pty-2' - }) - }) }) diff --git a/src/main/persistence-ssh-remote-pty-leases.test.ts b/src/main/persistence-ssh-remote-pty-leases.test.ts index ea0d8fe350dd..a1773888ba54 100644 --- a/src/main/persistence-ssh-remote-pty-leases.test.ts +++ b/src/main/persistence-ssh-remote-pty-leases.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { getDefaultPersistedState } from '../shared/constants' import { sshRemotePtyLeaseAllowsReattach } from '../shared/ssh-types' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -101,7 +102,7 @@ describe('Store', () => { await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - it('merges missing prior layout bindings into partial renderer snapshots', async () => { + it('keeps every binding main holds when a window save names only some', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', @@ -156,7 +157,7 @@ describe('Store', () => { } }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', @@ -196,251 +197,6 @@ describe('Store', () => { }) }) - // A partial renderer map is only repaired when a lease says the omitted sibling still belongs to - // this host. `expired` says the client lost its route, not that the sibling died — repair it. - // `terminated` is the operator-close state and must stay refused. - it.each([ - ['expired', { [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' }], - ['terminated', { [TEST_LEAF_1]: 'remote-pty-1' }] - ] as const)( - 'repairs a partial renderer snapshot for a %s sibling lease only when it is not terminated', - async (siblingState, expected) => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-1', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'detached' - }) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-2', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_2, - state: siblingState - }) - const layout = { - root: { - type: 'split' as const, - direction: 'horizontal' as const, - first: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, - second: { type: 'leaf' as const, leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null - } - const tabs = { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: null - } - ] - } - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: tabs, - terminalLayoutsByTabId: { - tab1: { - ...layout, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1', [TEST_LEAF_2]: 'remote-pty-2' } - } - } - }) - - // The renderer republishes only the leaf it still knows about. - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: tabs, - terminalLayoutsByTabId: { - tab1: { ...layout, ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual( - expected - ) - } - ) - - it('does not restore layout bindings for leaves removed from the incoming layout', async () => { - const store = await createStore() - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'detached' - }) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'remote-pty-2', - tabId: 'tab1', - leafId: TEST_LEAF_2, - state: 'detached' - }) - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { - [TEST_LEAF_1]: 'remote-pty-1', - [TEST_LEAF_2]: 'remote-pty-2' - } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'remote-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'remote-pty-1' } - } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'remote-pty-1' - }) - }) - - it('does not restore missing layout bindings without a live SSH lease', async () => { - const store = await createStore() - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'local-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { - [TEST_LEAF_1]: 'local-pty-1', - [TEST_LEAF_2]: 'local-pty-2' - } - } - } - }) - - store.setWorkspaceSession({ - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'local-pty-1' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_1 }, - second: { type: 'leaf', leafId: TEST_LEAF_2 }, - ratio: 0.5 - }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty-1' } - } - } - }) - - expect(store.getWorkspaceSession().terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'local-pty-1' - }) - }) - it('clears workspace bindings before removing SSH remote PTY leases for a target', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-terminal-membership-window-save.test.ts similarity index 75% rename from src/main/persistence-host-admitted-terminal-membership.test.ts rename to src/main/persistence-terminal-membership-window-save.test.ts index 83a6898c6671..4ae149f9c2b3 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-terminal-membership-window-save.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../shared/constants' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './runtime/mobile-session-terminal-persistence-retirement' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' vi.mock('electron', () => ({ @@ -47,7 +48,7 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) } -describe('host-admitted terminal membership survives a stale renderer replay', () => { +describe('terminal membership main holds survives a stale window save', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-')) }) @@ -57,7 +58,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( rmSync(testState.dir, { recursive: true, force: true }) }) - it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => { + it('keeps a host-created tab when the window saves its pre-create tab list', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -67,19 +68,18 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') // The renderer's debounced writer flushes a snapshot taken before the create. - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') }) - it('keeps a host-admitted tab in a second worktree of the same repo', async () => { + it('keeps a host-created tab in a second worktree of the same repo', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -87,10 +87,9 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, - ptyId: 'host-pty-other', - hostAdmittedMembership: true + ptyId: 'host-pty-other' }) - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) expect(persistedTabIds(store.getWorkspaceSession(), OTHER_WORKTREE)).toContain('host-tab-other') }) @@ -104,23 +103,21 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe( true ) - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe( true ) }) - // Polarity: without the flag the renderer still owns membership, so a renderer - // spawn racing its own writer must not freeze the tab list. - it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => { + // A window's own spawn is main's too: its save no longer authors membership for any repo. + it('keeps a window-spawned tab when the window saves a tab list from before the spawn', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -130,15 +127,16 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( leafId: TEST_LEAF_2, ptyId: 'renderer-second-pty' }) - store.setWorkspaceSession(rendererSession()) + setRendererSession(store, rendererSession()) - expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual([ + 'renderer-tab', + 'renderer-second-tab' + ]) }) - // Closing must still work afterwards. Closes are host-driven: the retirement is - // computed from the store's own session (see stageTerminalSurfaceRetirements), - // which is what outranks the fence this create just raised. - it('still lets the authoritative retirement path close the host-admitted tab', async () => { + // Closes are main's: the retirement is computed from the store's own session. + it('still lets the retirement path close the host-created tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) await store.persistPtyBinding({ @@ -146,8 +144,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( tabId: 'host-tab', leafId: TEST_LEAF_2, ptyId: 'host-pty', - incarnationId: 'host-incarnation', - hostAdmittedMembership: true + incarnationId: 'host-incarnation' }) store.setWorkspaceSession( @@ -159,8 +156,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( incarnationId: 'host-incarnation' }) ) - // A stale renderer replay must not resurrect it either. - store.setWorkspaceSession(rendererSession()) + // A stale window save must not resurrect it either. + setRendererSession(store, rendererSession()) expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) }) diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 40a4c50e9f44..e53ffe1eea87 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -9,6 +9,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' @@ -183,8 +184,8 @@ describe('Store', () => { expect(store.getWorkspaceSession().terminalLayoutsByTabId['old-tab']).toBeUndefined() expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(2) - store.setWorkspaceSession(stale) - expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) + setRendererSession(store, stale) + expect(store.getWorkspaceSession().tabsByWorktree.wt1).toBeUndefined() await store.persistPtyBinding({ worktreeId: 'wt1', @@ -213,9 +214,9 @@ describe('Store', () => { store.removeWorktreeMeta(worktreeId) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(1) - store.setWorkspaceSession(stale) + setRendererSession(store, stale) - expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) + expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toBeUndefined() }) it('advances deletion authority when only a legacy retirement fence existed', async () => { @@ -258,7 +259,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { repo: 1 } }) - store.setWorkspaceSession({ + setRendererSession(store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [worktreeB]: [makeTerminalTab({ id: 'stale-tab', worktreeId: worktreeB })] @@ -378,7 +379,7 @@ describe('Store', () => { ptyId: 'daemon-pty' }) - store.setWorkspaceSession({ + setRendererSession(store, { activeRepoId: 'r1', activeWorktreeId: 'wt1', activeTabId: 'tab1', diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts index 261162a38258..3702576ec4d4 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts @@ -58,10 +58,9 @@ function boundRelayPtyIds( * the id the row answers no question any reader asks. Reattach refuses it * (`sshRemotePtyLeaseAllowsReattach`), pane recovery matches on `expired` only, the orphan sweep * already classes it neither routed nor expired, and `ssh:reset` / `ssh:terminateSessions` skip it - * outright — every one of those behaves identically on an absent row. The one reader that can still - * observe it is `isRestorablePtyBinding`, and only through a binding whose pty id matches, which is - * exactly what the reachability test rules out. A `pendingKill` is an undelivered stop, so those - * rows stay until the replay retires them. + * outright — every one of those behaves identically on an absent row. A row a persisted binding still + * names is kept all the same, which is what the reachability test checks. A `pendingKill` is an + * undelivered stop, so those rows stay until the replay retires them. * * The reachability test is not redundant with the scrub: a lease freezes its `tabId`, so a pane * broken out into a new tab leaves a binding the scrub's tab-qualified match no longer reaches. diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 1db1595d9fb5..7488df42ac39 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -54,8 +54,6 @@ export type PersistPtyBindingArgs = { startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean /** * Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer * and must be able to mint the surface it is binding. A reattach is the opposite: the pane diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index ab97fdf82f7d..86379aa9c928 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -50,8 +50,7 @@ const SCENARIOS: Scenario[] = [ leafId: TEST_LEAF_1, ptyId: 'pty-new', incarnationId: 'inc-new', - startupCwd: '/fixture/local/sub', - hostAdmittedMembership: true + startupCwd: '/fixture/local/sub' }, placements: (leaf) => [ [NEW_TAB, 'agrees'], @@ -96,8 +95,7 @@ const SCENARIOS: Scenario[] = [ worktreeId: FOLDER_WORKTREE, tabId: 'tab-folder', leafId: TEST_LEAF_1, - ptyId: 'pty-folder', - hostAdmittedMembership: true + ptyId: 'pty-folder' }, placements: () => [ [NEW_TAB, 'agrees'], diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 12e8a98a50c8..facd7e7d0758 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -34,25 +34,14 @@ export function applyPtyBinding( const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false - let hostAdmittedTabCreated = false const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { + if (!reconciledIncarnation && !terminalMembershipChanged) { return } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) session.terminalTopologyRevisionByRepoId = { ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 } } if (args.incarnationId) { @@ -78,7 +67,6 @@ export function applyPtyBinding( ) } else { terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. const minted = createMinimalPersistedTerminalTab({ ...args, diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 74c34ec1a68e..5e39b60cf914 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -1,6 +1,5 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, @@ -12,7 +11,6 @@ import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-s import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' -import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { @@ -23,11 +21,6 @@ import { import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' -import { - preserveMissingWorkspaceSessionTerminalBindings, - sshTargetIdForWorkspaceSessionHost -} from './workspace-session-terminal-binding-replay' -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' type SessionHostPartitionOperationsRuntime = Pick< StoreRuntimeState, @@ -38,7 +31,6 @@ const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperat type SessionHostPartitionOperationsContext = { runtime: SessionHostPartitionOperationsRuntime scheduling: WriteSchedulingOperations - bindingRecovery: TerminalBindingRecoveryOperations } export class SessionHostPartitionOperations { @@ -46,10 +38,9 @@ export class SessionHostPartitionOperations { constructor( runtime: SessionHostPartitionOperationsRuntime, - scheduling: WriteSchedulingOperations, - bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations ) { - this[sessionHostPartitionOperationsContext] = { runtime, scheduling, bindingRecovery } + this[sessionHostPartitionOperationsContext] = { runtime, scheduling } } getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { @@ -193,22 +184,6 @@ export function setHostWorkspaceSession( hostId: ExecutionHostId, session: WorkspaceSessionState ): void { - const prior = - owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] - // Why here and not at the callers: the before-unload stage path writes the renderer's payload - // straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows. - session = preserveRuntimeAuthoredWorkspaceSessionFields(session, prior) - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = preserveMissingWorkspaceSessionTerminalBindings( - session, - prior, - owner[sessionHostPartitionOperationsContext].bindingRecovery, - { - targetIdForWorktree: sshTargetIdForWorkspaceSessionHost(hostId), - executionHostId: hostId - } - ) // Why here too: the load-side drop only survives until the next full snapshot write. A renderer // or runtime payload that still carries local's globals would re-inject them into this partition. const pruned = withoutRedundantGlobalFields( diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 13174ef18b5a..85877a2ab603 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -11,7 +11,6 @@ import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session- import { setLocalWorkspaceSession } from './workspace-session-snapshot-publication' import type { StoreRuntimeState } from './store-runtime-state' import type { SessionHostPartitionOperations } from './session-host-partitions' -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' import { resolveHostId, @@ -36,7 +35,6 @@ const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') type SessionSnapshotOperationsContext = { runtime: SessionSnapshotOperationsRuntime sessions: SessionHostPartitionOperations - bindingRecovery: TerminalBindingRecoveryOperations scheduling: WriteSchedulingOperations } @@ -46,10 +44,9 @@ export class SessionSnapshotOperations { constructor( runtime: SessionSnapshotOperationsRuntime, sessions: SessionHostPartitionOperations, - bindingRecovery: TerminalBindingRecoveryOperations, scheduling: WriteSchedulingOperations ) { - this[sessionSnapshotOperationsContext] = { runtime, sessions, bindingRecovery, scheduling } + this[sessionSnapshotOperationsContext] = { runtime, sessions, scheduling } } setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { @@ -57,7 +54,7 @@ export class SessionSnapshotOperations { const { runtime } = this[sessionSnapshotOperationsContext] if (runtime.durableMutationPhase === 'rollback') { this.assertSnapshotAdmission(true) - // The fieldwise rollback already preserves newer edits; renderer rebasing would undo it. + // The fieldwise rollback already holds the newer edits; it is published as is. this.publishSession(session, resolved) return } diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 7188b4efb664..f32f14e94ad9 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -39,9 +39,9 @@ import { type SshPtyBindingCleanupOperations } from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' +import { toComparableRelaySshPtyId, toRelaySshPtyId } from '../../providers/ssh-pty-id' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteFlushBarrierOperations } from './write-flush-barriers' -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' import { @@ -56,7 +56,6 @@ const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { runtime: SshLeaseRecoveryOperationsRuntime flushBarriers: WriteFlushBarrierOperations - bindingRecovery: TerminalBindingRecoveryOperations scheduling: WriteSchedulingOperations } @@ -66,15 +65,9 @@ export class SshLeaseRecoveryOperations { constructor( runtime: SshLeaseRecoveryOperationsRuntime, flushBarriers: WriteFlushBarrierOperations, - bindingRecovery: TerminalBindingRecoveryOperations, scheduling: WriteSchedulingOperations ) { - this[sshLeaseRecoveryOperationsContext] = { - runtime, - flushBarriers, - bindingRecovery, - scheduling - } + this[sshLeaseRecoveryOperationsContext] = { runtime, flushBarriers, scheduling } } getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { @@ -224,11 +217,7 @@ export function getSshPtyBindingCleanupOperations( ): SshPtyBindingCleanupOperations { return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, - toComparablePtyId: (targetId, ptyId) => - owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( - targetId, - ptyId - ), + toComparablePtyId: toComparableRelaySshPtyId, scheduleSave: () => scheduleSave(owner[sshLeaseRecoveryOperationsContext].scheduling) } } @@ -236,16 +225,8 @@ export function getSshPtyBindingCleanupOperations( export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): SshPtyLeaseOperations { return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, - toStoredPtyId: (targetId, ptyId) => - owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseStorage( - targetId, - ptyId - ), - toComparablePtyId: (targetId, ptyId) => - owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( - targetId, - ptyId - ), + toStoredPtyId: toRelaySshPtyId, + toComparablePtyId: toComparableRelaySshPtyId, clearBindingsForTarget: (targetId) => clearSshRemotePtyBindingsForTargetOperation( getSshPtyBindingCleanupOperations(owner), diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 4ba2e58881ac..22bdb65fcbad 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -21,7 +21,6 @@ import { RepoLifecycleOperations, installRepoLifecycleOperationsContext } from './repo-lifecycle-operations' -import { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import { SessionHostPartitionOperations, installSessionHostPartitionOperationsContext @@ -101,7 +100,6 @@ export type StoreDomains = { flushBarriers: WriteFlushBarrierOperations preferences: ProfilePreferences repos: RepoLifecycleOperations - bindingRecovery: TerminalBindingRecoveryOperations sessions: SessionHostPartitionOperations sessionSnapshots: SessionSnapshotOperations metadata: MetadataLineageOperations @@ -169,14 +167,8 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) const preferences = new ProfilePreferences(runtime, scheduling) const repos = new RepoLifecycleOperations(runtime, scheduling) - const bindingRecovery = new TerminalBindingRecoveryOperations(runtime) - const sessions = new SessionHostPartitionOperations(runtime, scheduling, bindingRecovery) - const sessionSnapshots = new SessionSnapshotOperations( - runtime, - sessions, - bindingRecovery, - scheduling - ) + const sessions = new SessionHostPartitionOperations(runtime, scheduling) + const sessionSnapshots = new SessionSnapshotOperations(runtime, sessions, scheduling) const metadata = new MetadataLineageOperations(runtime, scheduling, sessions) const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) const automations = new AutomationPersistence(runtime, flushBarriers, preferences) @@ -185,12 +177,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) - const sshLeases = new SshLeaseRecoveryOperations( - runtime, - flushBarriers, - bindingRecovery, - scheduling - ) + const sshLeases = new SshLeaseRecoveryOperations(runtime, flushBarriers, scheduling) const orcadCatalogImports = new OrcadCatalogImportPersistence(runtime, repos, scheduling) return { adaptation, @@ -202,7 +189,6 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { flushBarriers, preferences, repos, - bindingRecovery, sessions, sessionSnapshots, metadata, diff --git a/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts b/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts index 1cc673e52b47..81eb605f9806 100644 --- a/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts +++ b/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts @@ -1,11 +1,8 @@ import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' /** - * Drives the real `Store`, not the helper and not a fake. - * - * `preserveRuntimeAuthoredWorkspaceSessionFields` is only worth anything where the shipping writers - * call it, and a mutation that unwires it at the call site survives every test that drives the - * helper directly. Both desktop write paths belong here: the ordinary session write and the - * before-unload stage, which is the one the desktop actually takes when the user quits. + * Drives the real `Store` through the window's save entries, not a fake. Both desktop write paths + * belong here: the ordinary session write and the before-unload stage, which is the one the desktop + * actually takes when the user quits. */ import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -33,6 +30,8 @@ vi.mock('electron', () => ({ })) const { Store } = await import('./store') +const { setRendererSession, stageRendererSessionBeforeUnload } = + await import('../terminal-topology/terminal-renderer-presentation-save') const HOST_ID = 'ssh:user@host' const WT = 'repo-1::/tmp/worktree-a' @@ -94,7 +93,7 @@ describe('Store keeps runtime-authored session fields across desktop writes', () const store = createStore() seedRuntimeRows(store) - store.setWorkspaceSession(rendererSession('after-write')) + setRendererSession(store, rendererSession('after-write')) expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] @@ -108,7 +107,7 @@ describe('Store keeps runtime-authored session fields across desktop writes', () // The beforeunload chain (use-app-session-persistence -> stageBeforeUnloadSync -> // renderer-shutdown-checkpoint) lands here, and it is the last write before the process exits. - store.stageWorkspaceSessionBeforeUnload(rendererSession('before-unload')) + stageRendererSessionBeforeUnload(store, rendererSession('before-unload')) expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] @@ -119,12 +118,12 @@ describe('Store keeps runtime-authored session fields across desktop writes', () const store = createStore() seedRuntimeRows(store, HOST_ID) - store.setWorkspaceSession(rendererSession('after-write'), HOST_ID) + setRendererSession(store, rendererSession('after-write'), HOST_ID) expect(store.getWorkspaceSession(HOST_ID).clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] }) - store.stageWorkspaceSessionBeforeUnload(rendererSession('before-unload'), HOST_ID) + stageRendererSessionBeforeUnload(store, rendererSession('before-unload'), HOST_ID) expect(store.getWorkspaceSession(HOST_ID).clientHostedBrowserPagesByWorktree).toEqual({ [WT]: [page()] }) @@ -195,8 +194,8 @@ describe('a host partition written from a per-host renderer snapshot', () => { activeWorktreeId: null, unifiedTabs: {} } as unknown as WorkspaceSessionState - store.setWorkspaceSession(slice, 'runtime:env-1') - store.stageWorkspaceSessionBeforeUnload(slice, HOST_ID) + setRendererSession(store, slice, 'runtime:env-1') + stageRendererSessionBeforeUnload(store, slice, HOST_ID) for (const hostId of ['runtime:env-1', HOST_ID]) { expect(store.getWorkspaceSession(hostId)).toMatchObject({ tabsByWorktree: {}, diff --git a/src/main/persistence/loading-store/terminal-binding-recovery.ts b/src/main/persistence/loading-store/terminal-binding-recovery.ts deleted file mode 100644 index e3b4a99e61b5..000000000000 --- a/src/main/persistence/loading-store/terminal-binding-recovery.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { SshRemotePtyLease } from '../../../shared/ssh-types' -import { toComparableRelaySshPtyId, toRelaySshPtyId } from '../../providers/ssh-pty-id' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' - -import type { StoreRuntimeState } from './store-runtime-state' - -type TerminalBindingRecoveryOperationsRuntime = Pick - -/** - * `terminated` is the only lease state that withdraws a pane binding. It is the operator-close - * state, and the one written after a host-acknowledged stop. - * - * `expired` is deliberately not death: every writer of it records that the CLIENT lost its route — - * a superseded sibling, a recycled relay id, a persistPtyBinding refusal, a failed reattach, a - * relay reset — and `docs/reference/ssh-execution-boundary.md` grades all of those `unverifiable`. - * Refusing the binding there strands a remote shell that is still running behind a pane that can no - * longer reach it. Keeping it authorizes a reattach ATTEMPT, never a respawn: when the shell really - * is gone, `attachStablePaneOwner` retires the binding on the relay's own absence answer and falls - * through to a fresh spawn. - */ -function sshRemotePtyLeaseWithdrawsBinding(lease: SshRemotePtyLease): boolean { - return lease.state === 'terminated' -} - -export class TerminalBindingRecoveryOperations { - constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {} - - getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - const visit = (node: TerminalPaneLayoutNode | null): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(root) - return leafIds - } - - isRestorablePtyBinding(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - const leases = this.runtime.state.sshRemotePtyLeases?.filter((entry) => - this.sshRemotePtyLeaseMatchesBinding(entry, binding) - ) - return !leases?.some(sshRemotePtyLeaseWithdrawsBinding) - } - - getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - return toComparableRelaySshPtyId(targetId, ptyId) - } - - getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. - return ( - (binding.targetId === undefined || - binding.targetId === null || - lease.targetId === binding.targetId) && - (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && - (binding.tabId === undefined || lease.tabId === binding.tabId) && - (binding.leafId === undefined || lease.leafId === binding.leafId) - ) - } - - hasRestorableSshRemotePtyLease(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - return ( - this.runtime.state.sshRemotePtyLeases?.some( - (lease) => - this.sshRemotePtyLeaseMatchesBinding(lease, binding) && - !sshRemotePtyLeaseWithdrawsBinding(lease) - ) ?? false - ) - } - - getConnectionIdForWorktree(worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return this.runtime.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null - } -} diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 178f1d833ff7..ff2e2ed06ded 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -1,6 +1,5 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' @@ -10,7 +9,6 @@ import { deleteRemovedTerminalScrollbackSnapshotsAsync, migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync } from '../../terminal-scrollback-snapshot-async-migration' -import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' import { normalizeWorkspaceSessionPaneIdentities, @@ -21,7 +19,6 @@ import { type WorkspaceSessionPaneIdentityRemap } from '../restoring-sessions/workspace-pane-normalization' import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup' -import { preserveMissingWorkspaceSessionTerminalBindings } from './workspace-session-terminal-binding-replay' import { getSessionSnapshotOperationsContext, type SessionSnapshotOperations @@ -35,15 +32,10 @@ export function setLocalWorkspaceSession( ): void { const context = getSessionSnapshotOperationsContext(owner) const prior = context.runtime.state.workspaceSession - // Why here and not at the callers: the before-unload stage path writes the renderer's payload - // straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows. - session = preserveRuntimeAuthoredWorkspaceSessionFields(session, prior) - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) session = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) ) - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) @@ -93,7 +85,6 @@ export function setLocalWorkspaceSession( if (remappedLeases.changed) { context.runtime.state.sshRemotePtyLeases = remappedLeases.leases } - session = preserveMissingWorkspaceSessionTerminalBindings(session, prior, context.bindingRecovery) session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) if (!deferSnapshotFiles) { const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( diff --git a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts b/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts deleted file mode 100644 index d41b973f3ab7..000000000000 --- a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { preserveMissingWorkspaceSessionTerminalBindings } from './workspace-session-terminal-binding-replay' - -const LEAF_ONE = '11111111-1111-4111-8111-111111111111' -const LEAF_TWO = '22222222-2222-4222-8222-222222222222' -const WORKTREE_A = 'worktree-a' -const WORKTREE_B = 'worktree-b' - -function session(ptyId: string | null): WorkspaceSessionState { - return { - activeRepoId: 'repo', - activeWorktreeId: 'worktree', - activeTabId: 'tab', - tabsByWorktree: { - worktree: [ - { - id: 'tab', - worktreeId: 'worktree', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId - } - ] - }, - terminalLayoutsByTabId: {} - } -} - -function terminalTab(worktreeId: string, id: string, ptyId: string | null) { - return { - id, - worktreeId, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId - } -} - -const bindingRecovery = { - getTerminalLayoutLeafIds: (root: { leafId?: string } | null) => - new Set(root?.leafId ? [root.leafId] : []), - getConnectionIdForWorktree: () => null, - isRestorablePtyBinding: () => true, - hasRestorableSshRemotePtyLease: () => false -} - -describe('workspace session terminal binding replay', () => { - it('retains a restorable legacy tab binding when neither snapshot has a layout', () => { - const prior = session('runtime-pty') - const incoming = session(null) - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBe('runtime-pty') - }) - - it('does not retain a tab binding whose prior leaf was removed from the layout', () => { - const prior = session('runtime-pty') - prior.terminalLayoutsByTabId.tab = { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ONE]: 'runtime-pty' } - } - const incoming = session(null) - incoming.terminalLayoutsByTabId.tab = { - root: { type: 'leaf', leafId: LEAF_TWO }, - activeLeafId: LEAF_TWO, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBeNull() - }) - - it('fails closed when one tab id is duplicated across worktrees', () => { - const prior = session(null) - prior.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-a')], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'duplicate-tab', 'pty-b')] - } - prior.terminalLayoutsByTabId = { - 'duplicate-tab': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ONE]: 'pty-a' } - } - } - const incoming = session(null) - incoming.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'duplicate-tab', null)] - } - incoming.terminalLayoutsByTabId = { - 'duplicate-tab': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull() - expect(incoming.tabsByWorktree[WORKTREE_B]![0]!.ptyId).toBeNull() - expect(incoming.terminalLayoutsByTabId['duplicate-tab']?.ptyIdsByLeafId).toEqual({}) - }) - - it('still replays bindings for distinct tab ids in separate worktrees', () => { - const prior = session(null) - prior.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'tab-a', 'pty-a')], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'tab-b', 'pty-b')] - } - prior.terminalLayoutsByTabId = { - 'tab-a': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ONE]: 'pty-a' } - }, - 'tab-b': { - root: { type: 'leaf', leafId: LEAF_TWO }, - activeLeafId: LEAF_TWO, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_TWO]: 'pty-b' } - } - } - const incoming = session(null) - incoming.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'tab-a', null)], - [WORKTREE_B]: [terminalTab(WORKTREE_B, 'tab-b', null)] - } - incoming.terminalLayoutsByTabId = { - 'tab-a': { - root: { type: 'leaf', leafId: LEAF_ONE }, - activeLeafId: LEAF_ONE, - expandedLeafId: null, - ptyIdsByLeafId: {} - }, - 'tab-b': { - root: { type: 'leaf', leafId: LEAF_TWO }, - activeLeafId: LEAF_TWO, - expandedLeafId: null, - ptyIdsByLeafId: {} - } - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBe('pty-a') - expect(incoming.tabsByWorktree[WORKTREE_B]![0]!.ptyId).toBe('pty-b') - expect(incoming.terminalLayoutsByTabId['tab-a']?.ptyIdsByLeafId).toEqual({ - [LEAF_ONE]: 'pty-a' - }) - expect(incoming.terminalLayoutsByTabId['tab-b']?.ptyIdsByLeafId).toEqual({ - [LEAF_TWO]: 'pty-b' - }) - }) - - it('fails closed when one tab id is duplicated inside a single worktree list', () => { - // Map indexing is last-wins where a linear find was first-wins; the ambiguity - // fence must skip these ids so the two strategies can never disagree. - const prior = session(null) - prior.tabsByWorktree = { - [WORKTREE_A]: [ - terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-first'), - terminalTab(WORKTREE_A, 'duplicate-tab', 'pty-last') - ] - } - const incoming = session(null) - incoming.tabsByWorktree = { - [WORKTREE_A]: [terminalTab(WORKTREE_A, 'duplicate-tab', null)] - } - - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - - expect(incoming.tabsByWorktree[WORKTREE_A]![0]!.ptyId).toBeNull() - }) - - it('indexes prior tabs once when replaying a large workspace snapshot', () => { - let reads = 0 - const prior = session(null) - prior.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) => ({ - ...terminalTab('worktree', `tab-${i}`, `pty-${i}`), - get id() { - reads++ - return `tab-${i}` - } - })) - const incoming = session(null) - incoming.tabsByWorktree.worktree = Array.from({ length: 1000 }, (_, i) => - terminalTab('worktree', `tab-${i}`, null) - ) - preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) - expect(reads).toBeLessThan(10_000) - expect(incoming.tabsByWorktree.worktree.map((tab) => tab.ptyId)).toEqual( - Array.from({ length: 1000 }, (_, i) => `pty-${i}`) - ) - }) -}) diff --git a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts b/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts deleted file mode 100644 index 8e805b67b50d..000000000000 --- a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { getPtyExecutionHost } from '../../../shared/terminal-execution-host' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization' - -import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' - -type TerminalBindingRecovery = Pick< - TerminalBindingRecoveryOperations, - | 'getTerminalLayoutLeafIds' - | 'getConnectionIdForWorktree' - | 'isRestorablePtyBinding' - | 'hasRestorableSshRemotePtyLease' -> - -/** A bare tab id cannot select one binding when persisted rows disagree on its worktree. */ -function collectAmbiguousTabIds( - tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] -): ReadonlySet { - const ownerByTabId = new Map() - const ambiguous = new Set() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - if (ownerByTabId.has(tab.id)) { - ambiguous.add(tab.id) - } else { - ownerByTabId.set(tab.id, worktreeId) - } - } - } - return ambiguous -} - -export type WorkspaceSessionTerminalBindingReplayOptions = { - /** Resolve the SSH target that owns bindings in a host partition. */ - targetIdForWorktree?: (worktreeId: string) => string | null - /** Reject a binding that explicitly names another execution host. */ - executionHostId?: ExecutionHostId -} - -function ptyBindingMatchesExecutionHost( - ptyId: string, - executionHostId: ExecutionHostId | undefined -): boolean { - if (!executionHostId || executionHostId === LOCAL_EXECUTION_HOST_ID) { - return true - } - const owner = getPtyExecutionHost(ptyId) - // Legacy unscoped ids carry no host proof; lease/context checks below still - // decide whether they are restorable. Known foreign ids must never cross a partition. - return owner === null || owner === executionHostId -} - -/** - * Reapplies durable pane bindings omitted by an older or in-flight renderer snapshot. - * - * An empty binding map is ambiguous: it can be a pre-spawn snapshot, or an intentional close. - * Lease/tombstone state is the authority that distinguishes those cases. A partial map is only - * repaired when a live SSH lease proves the omitted sibling still belongs to this host. - */ -export function preserveMissingWorkspaceSessionTerminalBindings( - session: WorkspaceSessionState, - prior: WorkspaceSessionState | undefined, - bindingRecovery: TerminalBindingRecovery, - options: WorkspaceSessionTerminalBindingReplayOptions = {} -): WorkspaceSessionState { - if (!prior) { - return session - } - - const priorTabs = prior.tabsByWorktree ?? {} - const nextTabs = session.tabsByWorktree ?? {} - const priorLayouts = prior.terminalLayoutsByTabId ?? {} - const nextLayouts = session.terminalLayoutsByTabId ?? {} - // Both snapshots are independently allowed to contain the same id during a - // normal worktree move. Only ids duplicated within one snapshot are unsafe; - // skip their replay rather than assigning a global layout to an arbitrary row. - const ambiguousTabIds = new Set([ - ...collectAmbiguousTabIds(priorTabs), - ...collectAmbiguousTabIds(nextTabs) - ]) - const targetIdForWorktree = - options.targetIdForWorktree ?? - ((worktreeId: string) => bindingRecovery.getConnectionIdForWorktree(worktreeId)) - - // Keep a tab-level binding when the renderer has not observed the host's spawn yet. - for (const [worktreeId, tabs] of Object.entries(nextTabs)) { - const priorList = priorTabs[worktreeId] - if (!priorList) { - continue - } - let priorById: Map | undefined - for (const tab of tabs) { - if (ambiguousTabIds.has(tab.id)) { - continue - } - if (tab.ptyId) { - continue - } - priorById ??= new Map(priorList.map((candidate) => [candidate.id, candidate])) - const priorTab = priorById.get(tab.id) - const incomingLayout = nextLayouts[tab.id] - const priorLayout = priorLayouts[tab.id] - const priorPtyLeafId = priorLayout - ? Object.entries(priorLayout.ptyIdsByLeafId ?? {}).find( - ([, ptyId]) => ptyId === priorTab?.ptyId - )?.[0] - : undefined - const bindingLeafWasRemoved = - incomingLayout !== undefined && - priorPtyLeafId !== undefined && - !bindingRecovery.getTerminalLayoutLeafIds(incomingLayout.root).has(priorPtyLeafId) - if ( - priorTab?.ptyId && - !bindingLeafWasRemoved && - ptyBindingMatchesExecutionHost(priorTab.ptyId, options.executionHostId) && - bindingRecovery.isRestorablePtyBinding({ - ptyId: priorTab.ptyId, - worktreeId, - targetId: targetIdForWorktree(worktreeId), - tabId: tab.id - }) - ) { - tab.ptyId = priorTab.ptyId - } - } - } - - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - - for (const [tabId, layout] of Object.entries(nextLayouts)) { - if (ambiguousTabIds.has(tabId)) { - continue - } - const priorLayout = priorLayouts[tabId] - if (!priorLayout?.ptyIdsByLeafId) { - continue - } - const incoming = layout.ptyIdsByLeafId ?? {} - const incomingHasAnyBinding = Object.keys(incoming).length > 0 - const liveLeafIds = bindingRecovery.getTerminalLayoutLeafIds(layout.root) - const worktreeId = worktreeIdByTabId.get(tabId) - const targetId = worktreeId ? targetIdForWorktree(worktreeId) : null - const restorableBindings = Object.fromEntries( - Object.entries(priorLayout.ptyIdsByLeafId).filter( - ([leafId, ptyId]) => - liveLeafIds.has(leafId) && - incoming[leafId] === undefined && - ptyBindingMatchesExecutionHost(ptyId, options.executionHostId) && - // An empty map may be a stale pre-spawn snapshot; a partial map is intentional unless - // a durable SSH lease proves the omitted sibling is still live on this host. - (incomingHasAnyBinding - ? bindingRecovery.hasRestorableSshRemotePtyLease({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - }) - : bindingRecovery.isRestorablePtyBinding({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - })) - ) - ) - if (Object.keys(restorableBindings).length === 0) { - continue - } - - layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } - // Keep pane metadata alongside a binding rescued from a stale renderer write. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - - return session -} - -/** Target resolver for a persisted execution-host partition. */ -export function sshTargetIdForWorkspaceSessionHost( - hostId: ExecutionHostId -): ((worktreeId: string) => string | null) | undefined { - if (hostId === LOCAL_EXECUTION_HOST_ID) { - return undefined - } - const parsed = parseExecutionHostId(hostId) - return parsed?.kind === 'ssh' ? () => parsed.targetId : () => null -} diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.test.ts b/src/main/persistence/runtime-authored-workspace-session-fields.test.ts deleted file mode 100644 index 5f5ca100f697..000000000000 --- a/src/main/persistence/runtime-authored-workspace-session-fields.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { preserveRuntimeAuthoredWorkspaceSessionFields } from './runtime-authored-workspace-session-fields' - -const row = { - v: 1 as const, - browserPageId: 'page-a', - workspaceId: 'repo-1::wt-a', - browserProfileId: 'profile-a', - executionHostKey: 'native:runtime-a:1', - url: 'https://kept.internal/', - title: 'Kept', - pairedDeviceId: 'device-a', - savedAt: 1_800_000_000_000 -} - -describe('preserving runtime-authored workspace session fields', () => { - it('carries the rows across a write that never mentions them', () => { - // The desktop renderer builds its payload from Zustand, which has no idea the runtime - // authority sharing this profile persists client-hosted pages. - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - session(), - session({ 'repo-1::wt-a': [row] }) - ) - - expect(next.clientHostedBrowserPagesByWorktree).toEqual({ 'repo-1::wt-a': [row] }) - }) - - it('lets the runtime clear its own rows, which it does with an empty map', () => { - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - session({}), - session({ 'repo-1::wt-a': [row] }) - ) - - expect(next.clientHostedBrowserPagesByWorktree).toEqual({}) - }) - - it('lets the runtime replace its rows outright', () => { - const replaced = { 'repo-1::wt-b': [{ ...row, workspaceId: 'repo-1::wt-b' }] } - - expect( - preserveRuntimeAuthoredWorkspaceSessionFields( - session(replaced), - session({ 'repo-1::wt-a': [row] }) - ).clientHostedBrowserPagesByWorktree - ).toEqual(replaced) - }) - - it('treats an explicit undefined as never having mentioned the field', () => { - // The one ambiguous input: a writer that spreads the field through as undefined is still a - // writer that knows nothing about it, so it must inherit rather than clear. - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - { ...session(), clientHostedBrowserPagesByWorktree: undefined }, - session({ 'repo-1::wt-a': [row] }) - ) - - expect(next.clientHostedBrowserPagesByWorktree).toEqual({ 'repo-1::wt-a': [row] }) - }) - - it('leaves an untouched write alone rather than inventing a field', () => { - const next = session() - - expect(preserveRuntimeAuthoredWorkspaceSessionFields(next, session())).toBe(next) - expect(preserveRuntimeAuthoredWorkspaceSessionFields(next, null)).toBe(next) - }) - - it('keeps default-terminal-tab markers a persist snapshot omitted', () => { - const next = preserveRuntimeAuthoredWorkspaceSessionFields(session(), { - ...session(), - defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/wt-a': true } - }) - - expect(next.defaultTerminalTabsAppliedByWorktreeId).toEqual({ 'repo-1::/wt-a': true }) - }) - - it('unions default-terminal-tab markers instead of replacing with a partial snapshot', () => { - const next = preserveRuntimeAuthoredWorkspaceSessionFields( - { ...session(), defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/wt-b': true } }, - { ...session(), defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/wt-a': true } } - ) - - expect(next.defaultTerminalTabsAppliedByWorktreeId).toEqual({ - 'repo-1::/wt-a': true, - 'repo-1::/wt-b': true - }) - }) -}) - -function session( - clientHostedBrowserPagesByWorktree?: WorkspaceSessionState['clientHostedBrowserPagesByWorktree'] -): WorkspaceSessionState { - return { - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - ...(clientHostedBrowserPagesByWorktree ? { clientHostedBrowserPagesByWorktree } : {}) - } -} diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.ts b/src/main/persistence/runtime-authored-workspace-session-fields.ts deleted file mode 100644 index 80a9d30b532d..000000000000 --- a/src/main/persistence/runtime-authored-workspace-session-fields.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -/** - * Keeps session fields the renderer persist snapshot does not author across a full write. - * - * A session write replaces the stored object. Zustand-built payloads omit runtime-owned - * client-hosted pages and terminal close records, and they omit-when-empty the write-once - * default-terminal-tab marker. Without this, those slices vanish on the next desktop write and - * only show up missing after restart. - * - * Callers do not opt in: the Store applies this inside setLocalWorkspaceSession and - * setHostWorkspaceSession, so the before-unload stage path inherits it too. - * - * A runtime clearing client-hosted pages writes an empty map, not `undefined`. The default-tab - * marker is write-once: omission means "never stamped", not "never applied". - */ -function unionWriteOnceDefaultTerminalTabsApplied( - next: WorkspaceSessionState, - prior: WorkspaceSessionState | null | undefined -): WorkspaceSessionState { - const priorMarks = prior?.defaultTerminalTabsAppliedByWorktreeId - if (!priorMarks || Object.keys(priorMarks).length === 0) { - return next - } - const nextMarks = next.defaultTerminalTabsAppliedByWorktreeId - const merged = { ...priorMarks, ...nextMarks } - if (nextMarks && Object.keys(merged).length === Object.keys(nextMarks).length) { - return next - } - return { ...next, defaultTerminalTabsAppliedByWorktreeId: merged } -} - -export function preserveRuntimeAuthoredWorkspaceSessionFields( - next: WorkspaceSessionState, - prior: WorkspaceSessionState | null | undefined -): WorkspaceSessionState { - let result = next - if ( - next.clientHostedBrowserPagesByWorktree === undefined && - prior?.clientHostedBrowserPagesByWorktree !== undefined - ) { - result = { - ...result, - clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree - } - } - // Why: close records are main's alone (its close transaction); a renderer save never carries them. - if ( - next.closedTerminalTabTombstonesByTabId === undefined && - prior?.closedTerminalTabTombstonesByTabId !== undefined - ) { - result = { - ...result, - closedTerminalTabTombstonesByTabId: prior.closedTerminalTabTombstonesByTabId - } - } - // Why union: persist snapshots omit this write-once map (empty Zustand slice, omit-when-empty - // payload). Treating omission as "never applied" re-spawns default terminals on every attach. - return unionWriteOnceDefaultTerminalTabsApplied(result, prior) -} diff --git a/src/main/persistence/terminal-topology/terminal-layout-set.ts b/src/main/persistence/terminal-topology/terminal-layout-set.ts index 162a575ffcb0..0aafe0a59257 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-set.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-set.ts @@ -10,6 +10,11 @@ import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layo const sortedLeafIds = (root: TerminalPaneLayoutNode | null | undefined): string[] => collectLayoutLeafIdsInOrder(root).sort() +export const sameTerminalLeafSet = ( + a: TerminalPaneLayoutNode | null | undefined, + b: TerminalPaneLayoutNode | null | undefined +): boolean => isDeepStrictEqual(sortedLeafIds(a), sortedLeafIds(b)) + /** * Replaces a tab's tree in the worktree's home partition with the window's, when it holds exactly * the same panes. A geometry edit never adds, removes or moves a pane; a tree that would is @@ -23,7 +28,7 @@ export function planTerminalLayoutSet( if (!layout?.root || !home.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId)) { return { result: { status: 'refused', reason: 'tab_not_held' }, session: null } } - if (!isDeepStrictEqual(sortedLeafIds(layout.root), sortedLeafIds(root))) { + if (!sameTerminalLeafSet(layout.root, root)) { return { result: { status: 'refused', reason: 'leaves_differ' }, session: null } } return { diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts index adc5100b67ba..5e7252f9acd9 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts @@ -4,6 +4,7 @@ import { toSshExecutionHostId } from '../../../shared/execution-host' import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' import { makeRepo, makeTerminalTab } from '../../persistence-test-harness' import { planTerminalLeafMove } from './terminal-leaf-move' +import { setRendererSession } from './terminal-renderer-presentation-save' import { closeMoveTestStores, FROM, @@ -293,7 +294,7 @@ describe('STA-9259 move sequence', () => { }) expect(reattached).toBe(true) // A debounced renderer save that predates the move must not resurrect the source copy. - store.setWorkspaceSession(preMoveRendererSnapshot) + setRendererSession(store, preMoveRendererSnapshot) expect(ownersOf(store.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts new file mode 100644 index 000000000000..45362f779432 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { Tab } from '../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import { mergeRendererPresentationSave } from './terminal-renderer-presentation-save' + +const WORKTREE = 'repo::/worktree' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const RUNTIME_HOST = 'runtime:env-1' + +function row(id: string, overrides: Partial = {}): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WORKTREE, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function unified(id: string, contentType: Tab['contentType'] = 'terminal'): Tab { + return { + id, + entityId: id, + groupId: 'group', + worktreeId: WORKTREE, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function sleeping(paneKey: string, capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin: 'worktree-sleep' + } +} + +function splitRoot(ratio: number): TerminalLayoutSnapshot['root'] { + return { + type: 'split', + direction: 'vertical', + ratio, + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } +} + +const splitLayout: TerminalLayoutSnapshot = { + root: splitRoot(0.3), + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' } +} + +/** What main holds: one split tab, its sleeping agent, a close record and fences. */ +function mainSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [row('tab', { launchAgent: 'codex', ptyId: 'pty-left' })] }, + terminalLayoutsByTabId: { tab: splitLayout }, + unifiedTabs: { [WORKTREE]: [unified('tab')] }, + tabGroups: { + [WORKTREE]: [{ id: 'group', worktreeId: WORKTREE, activeTabId: 'tab', tabOrder: ['tab'] }] + }, + activeTabIdByWorktree: { [WORKTREE]: 'tab' }, + sleepingAgentSessionsByPaneKey: { [`tab:${LEFT}`]: sleeping(`tab:${LEFT}`, 1) }, + terminalPtyIncarnationsByPaneKey: { [`tab:${LEFT}`]: 'incarnation-left' }, + closedTerminalTabTombstonesByTabId: { + closed: { worktreeId: WORKTREE, closedAt: 5 } + }, + terminalTopologyRevisionByRepoId: { repo: 3 }, + defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE]: true } + } +} + +/** Main's own authority: what the slice publishes plus the records no slice carries. */ +function mainAuthority(session: WorkspaceSessionState) { + return { + slice: projectTerminalTopologySlice(session, 'local', WORKTREE), + incarnations: session.terminalPtyIncarnationsByPaneKey, + closed: session.closedTerminalTabTombstonesByTabId, + defaults: session.defaultTerminalTabsAppliedByWorktreeId + } +} + +describe('mergeRendererPresentationSave', () => { + it('a stale or extra tab, layout, binding or sleeping record changes nothing main authors', () => { + const prior = mainSession() + const stale: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [ + // The window's ptyId is its live attachment, not main's binding. + row('tab', { ptyId: 'pty-window', launchAgent: 'codex' }), + row('extra') + ] + }, + terminalLayoutsByTabId: { + tab: { + ...splitLayout, + root: splitRoot(0.9), + ptyIdsByLeafId: { [LEFT]: 'pty-window' } + }, + extra: { root: { type: 'leaf', leafId: RIGHT }, activeLeafId: RIGHT, expandedLeafId: null } + }, + sleepingAgentSessionsByPaneKey: { [`extra:${RIGHT}`]: sleeping(`extra:${RIGHT}`, 2) }, + unifiedTabs: { [WORKTREE]: [unified('tab'), unified('extra')] }, + tabGroups: { + [WORKTREE]: [ + { id: 'group', worktreeId: WORKTREE, activeTabId: 'extra', tabOrder: ['tab', 'extra'] } + ] + }, + activeTabIdByWorktree: { [WORKTREE]: 'extra' } + } + + const saved = mergeRendererPresentationSave(stale, prior, 'local') + + expect(mainAuthority(saved)).toEqual(mainAuthority(prior)) + expect(saved.sleepingAgentSessionsByPaneKey).toEqual(prior.sleepingAgentSessionsByPaneKey) + expect(saved.terminalTopologyRevisionByRepoId).toEqual({ repo: 3 }) + expect(saved.unifiedTabs?.[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab']) + expect(saved.tabGroups?.[WORKTREE]).toEqual([ + { id: 'group', worktreeId: WORKTREE, activeTabId: 'tab', tabOrder: ['tab'] } + ]) + expect(saved.activeTabIdByWorktree?.[WORKTREE]).toBe('tab') + }) + + it('takes presentation per tab and per leaf, on the panes main holds', () => { + const prior = mainSession() + const window: WorkspaceSessionState = { + ...prior, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { + [WORKTREE]: [ + row('tab', { title: 'npm test', customTitle: 'Tests', color: 'red', sortOrder: 4 }) + ] + }, + terminalLayoutsByTabId: { + tab: { + ...splitLayout, + activeLeafId: RIGHT, + expandedLeafId: RIGHT, + buffersByLeafId: { [RIGHT]: 'scrollback' }, + titlesByLeafId: { [LEFT]: 'Server' } + } + } + } + + const saved = mergeRendererPresentationSave(window, prior, 'local') + + expect(saved.tabsByWorktree[WORKTREE]).toEqual([ + // Row fields are the window's (it clears launchAgent when the launching pane closes). + row('tab', { + ptyId: 'pty-left', + title: 'npm test', + customTitle: 'Tests', + color: 'red', + sortOrder: 4 + }) + ]) + expect(saved.terminalLayoutsByTabId.tab).toEqual({ + ...splitLayout, + activeLeafId: RIGHT, + expandedLeafId: RIGHT, + buffersByLeafId: { [RIGHT]: 'scrollback' }, + titlesByLeafId: { [LEFT]: 'Server' } + }) + expect(saved.unifiedTabs).toEqual(prior.unifiedTabs) + }) + + it('a layout naming other panes is stale, so main keeps its whole layout', () => { + const prior = mainSession() + const saved = mergeRendererPresentationSave( + { + ...prior, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf', leafId: LEFT }, + activeLeafId: LEFT, + expandedLeafId: null, + titlesByLeafId: { [LEFT]: 'Server' } + } + } + }, + prior, + 'local' + ) + expect(saved.terminalLayoutsByTabId.tab).toBe(prior.terminalLayoutsByTabId.tab) + }) + + it('restores the tab bar entry of a tab the window has not shown yet', () => { + const prior = mainSession() + const saved = mergeRendererPresentationSave( + { ...prior, unifiedTabs: { [WORKTREE]: [unified('editor', 'editor')] } }, + prior, + 'local' + ) + expect(saved.unifiedTabs?.[WORKTREE]?.map((tab) => tab.id)).toEqual(['editor', 'tab']) + }) + + it('a presentation patch stays one and cannot carry sleeping records', () => { + const prior = mainSession() + const patch = mergeRendererPresentationSave( + { + activeWorktreeId: WORKTREE, + sleepingAgentSessionsByPaneKey: {}, + defaultTerminalTabsAppliedByWorktreeId: undefined + }, + prior, + 'local' + ) + expect(patch).not.toHaveProperty('tabsByWorktree') + expect(patch).not.toHaveProperty('terminalLayoutsByTabId') + expect(patch.sleepingAgentSessionsByPaneKey).toBe(prior.sleepingAgentSessionsByPaneKey) + expect(patch.defaultTerminalTabsAppliedByWorktreeId).toEqual({ [WORKTREE]: true }) + }) + + it("keeps the window's terminal rows and sleeping records for another server's partition", () => { + const prior = { ...mainSession(), terminalTopologyRevisionByRepoId: { other: 1 } } + const window: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [row('remote-tab', { ptyId: 'remote:env@@handle' })] }, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { [`remote-tab:${LEFT}`]: sleeping(`remote-tab:${LEFT}`, 2) } + } + + const saved = mergeRendererPresentationSave(window, prior, RUNTIME_HOST) + + expect(saved.tabsByWorktree).toEqual(window.tabsByWorktree) + expect(saved.sleepingAgentSessionsByPaneKey).toBe(window.sleepingAgentSessionsByPaneKey) + // Records only main writes survive a save into any partition. + expect(saved.closedTerminalTabTombstonesByTabId).toBe(prior.closedTerminalTabTombstonesByTabId) + expect(saved.terminalTopologyRevisionByRepoId).toBe(prior.terminalTopologyRevisionByRepoId) + // Once main fenced the repo there (a worktree removal), main's rows stand as before. + const fenced = mergeRendererPresentationSave(window, mainSession(), RUNTIME_HOST) + expect(fenced.tabsByWorktree).toEqual(mainSession().tabsByWorktree) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts new file mode 100644 index 000000000000..beefece12f79 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts @@ -0,0 +1,257 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { pruneTabGroupLayoutAfterRetirement } from '../../runtime/mobile-session-terminal-retirement' +import type { Store } from '../loading-store/store' +import { resolveHostId } from '../loading-store/session-host-partitions' +import { sameTerminalLeafSet } from './terminal-layout-set' +import { + hasHostAuthoritativeTerminalMembership, + isTerminalOwnerPartition +} from './terminal-topology-membership' + +type RendererSaveStore = Pick< + Store, + | 'getWorkspaceSession' + | 'setWorkspaceSession' + | 'patchWorkspaceSession' + | 'stageWorkspaceSessionBeforeUnload' +> + +// The window's session writes: `session:set`, `session:set-sync`, `session:patch` and the quit stage. +export function setRendererSession( + store: RendererSaveStore, + session: WorkspaceSessionState, + hostId?: string | null +): void { + store.setWorkspaceSession(overMainTopology(store, session, hostId), hostId) +} + +export function patchRendererSession( + store: RendererSaveStore, + patch: WorkspaceSessionPatch, + hostId?: string | null +): void { + store.patchWorkspaceSession(overMainTopology(store, patch, hostId), hostId) +} + +export function stageRendererSessionBeforeUnload( + store: RendererSaveStore, + session: WorkspaceSessionState, + hostId?: string | null +): void { + store.stageWorkspaceSessionBeforeUnload(overMainTopology(store, session, hostId), hostId) +} + +function overMainTopology( + store: RendererSaveStore, + incoming: T, + hostId?: string | null +): T { + return mergeRendererPresentationSave( + incoming, + store.getWorkspaceSession(hostId), + resolveHostId(hostId) + ) +} + +/** + * A window save brings presentation: titles, colours, order, focus, buffers, tab groups. Main keeps + * what it authors: which tabs and panes exist, their trees and PTY bindings, sleeping records, + * close records and fences. + */ +export function mergeRendererPresentationSave( + incoming: T, + prior: WorkspaceSessionState, + hostId: ExecutionHostId +): T { + const ownerPartition = isTerminalOwnerPartition(hostId) + // Another server feeds a `runtime:` partition's rows; main's stand there only for a repo it fenced. + const mainHolds = (worktreeId: string | undefined): boolean => + ownerPartition || + (worktreeId !== undefined && hasHostAuthoritativeTerminalMembership(prior, worktreeId)) + return { + ...incoming, + clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree, + closedTerminalTabTombstonesByTabId: prior.closedTerminalTabTombstonesByTabId, + terminalPtyIncarnationsByPaneKey: prior.terminalPtyIncarnationsByPaneKey, + terminalSurfaceTombstonesByPaneKey: prior.terminalSurfaceTombstonesByPaneKey, + terminalTopologyRevisionByRepoId: prior.terminalTopologyRevisionByRepoId, + // Write-once: a payload that omits a mark has not un-applied it. + defaultTerminalTabsAppliedByWorktreeId: { + ...prior.defaultTerminalTabsAppliedByWorktreeId, + ...incoming.defaultTerminalTabsAppliedByWorktreeId + }, + ...(ownerPartition + ? { sleepingAgentSessionsByPaneKey: prior.sleepingAgentSessionsByPaneKey } + : {}), + ...(incoming.tabsByWorktree && { + tabsByWorktree: mainRows(incoming.tabsByWorktree, prior, mainHolds) + }), + ...(incoming.terminalLayoutsByTabId && { + terminalLayoutsByTabId: mainLayouts(incoming, prior, mainHolds) + }), + ...placementOfMainTabs(incoming, prior, mainHolds) + } +} + +type MainHolds = (worktreeId: string | undefined) => boolean + +function mainRows( + window: WorkspaceSessionState['tabsByWorktree'], + prior: WorkspaceSessionState, + mainHolds: MainHolds +): WorkspaceSessionState['tabsByWorktree'] { + const rows = Object.fromEntries(Object.entries(window).filter(([id]) => !mainHolds(id))) + for (const [worktreeId, tabs] of Object.entries(prior.tabsByWorktree)) { + if (mainHolds(worktreeId)) { + const windowRows = new Map((window[worktreeId] ?? []).map((tab) => [tab.id, tab])) + rows[worktreeId] = tabs.map((tab) => presentationRow(tab, windowRows.get(tab.id))) + } + } + return rows +} + +/** The window's row, except `ptyId`: there it is the live attachment, never main's binding. */ +function presentationRow(main: TerminalTab, window: TerminalTab | undefined): TerminalTab { + return window ? { ...window, ptyId: main.ptyId } : main +} + +function mainLayouts( + incoming: WorkspaceSessionPatch, + prior: WorkspaceSessionState, + mainHolds: MainHolds +): WorkspaceSessionState['terminalLayoutsByTabId'] { + const window = incoming.terminalLayoutsByTabId ?? {} + const worktreeOfTab = new Map() + for (const rows of [prior.tabsByWorktree, incoming.tabsByWorktree ?? {}]) { + for (const [worktreeId, tabs] of Object.entries(rows)) { + tabs.forEach((tab) => worktreeOfTab.set(tab.id, worktreeId)) + } + } + const layouts = Object.fromEntries( + Object.entries(window).filter(([tabId]) => !mainHolds(worktreeOfTab.get(tabId))) + ) + for (const [worktreeId, tabs] of Object.entries(prior.tabsByWorktree)) { + for (const tab of mainHolds(worktreeId) ? tabs : []) { + const main = prior.terminalLayoutsByTabId?.[tab.id] + if (main) { + layouts[tab.id] = presentationLayout(main, window[tab.id]) + } + } + } + return layouts +} + +/** Per-leaf presentation applies only to the panes main holds; a different pane set is stale. */ +function presentationLayout( + main: TerminalLayoutSnapshot, + window: TerminalLayoutSnapshot | undefined +): TerminalLayoutSnapshot { + if (!window || !sameTerminalLeafSet(main.root, window.root)) { + return main + } + return { ...window, root: main.root, ptyIdsByLeafId: main.ptyIdsByLeafId } +} + +/** The window's tab bar, holding exactly main's terminal tabs. */ +function placementOfMainTabs( + incoming: WorkspaceSessionPatch, + prior: WorkspaceSessionState, + mainHolds: MainHolds +): WorkspaceSessionPatch { + const unifiedTabs = { ...incoming.unifiedTabs } + const tabGroups = { ...incoming.tabGroups } + const tabGroupLayouts = { ...incoming.tabGroupLayouts } + const activeTabIdByWorktree = { ...incoming.activeTabIdByWorktree } + const worktreeIds = new Set([ + ...Object.keys(prior.tabsByWorktree), + ...Object.keys(incoming.tabsByWorktree ?? {}) + ]) + for (const worktreeId of [...worktreeIds].filter(mainHolds)) { + const mainTabs = prior.tabsByWorktree[worktreeId] ?? [] + const memberIds = new Set(mainTabs.map((tab) => tab.id)) + const unified = incoming.unifiedTabs + ? memberUnifiedTabs( + incoming.unifiedTabs[worktreeId] ?? [], + prior.unifiedTabs?.[worktreeId] ?? [], + memberIds + ) + : (prior.unifiedTabs?.[worktreeId] ?? []) + if (unified.length > 0 || Object.hasOwn(unifiedTabs, worktreeId)) { + unifiedTabs[worktreeId] = unified + } + const validTabIds = new Set([...memberIds, ...unified.map((tab) => tab.id)]) + const groups = groupsOfTabs( + incoming.tabGroups?.[worktreeId] ?? prior.tabGroups?.[worktreeId] ?? [], + validTabIds + ) + if (groups.length > 0 || Object.hasOwn(tabGroups, worktreeId)) { + tabGroups[worktreeId] = groups + } + const groupLayout = pruneTabGroupLayoutAfterRetirement( + incoming.tabGroupLayouts?.[worktreeId] ?? prior.tabGroupLayouts?.[worktreeId], + new Set(groups.map((group) => group.id)) + ) + if (groupLayout) { + tabGroupLayouts[worktreeId] = groupLayout + } else { + delete tabGroupLayouts[worktreeId] + } + if (!validTabIds.has(activeTabIdByWorktree[worktreeId] ?? '')) { + const priorActive = prior.activeTabIdByWorktree?.[worktreeId] + activeTabIdByWorktree[worktreeId] = + (priorActive && validTabIds.has(priorActive) + ? priorActive + : (groups[0]?.activeTabId ?? mainTabs[0]?.id)) ?? null + } + } + return { + ...(incoming.unifiedTabs && { unifiedTabs }), + ...(incoming.tabGroups && { tabGroups }), + ...(incoming.tabGroupLayouts && { tabGroupLayouts }), + ...(incoming.activeTabIdByWorktree && { activeTabIdByWorktree }) + } +} + +/** Terminal entries are a join on main's tabs (by `entityId` or id); other entries are the window's. */ +function memberUnifiedTabs( + window: readonly Tab[], + main: readonly Tab[], + memberIds: ReadonlySet +): Tab[] { + const isMember = (tab: Tab): boolean => + tab.contentType === 'terminal' && (memberIds.has(tab.id) || memberIds.has(tab.entityId)) + const kept = window.filter((tab) => tab.contentType !== 'terminal' || isMember(tab)) + const shown = new Set(kept.flatMap((tab) => (isMember(tab) ? [tab.id, tab.entityId] : []))) + return [ + ...kept, + ...main.filter((tab) => isMember(tab) && !shown.has(tab.id) && !shown.has(tab.entityId)) + ] +} + +function groupsOfTabs(groups: readonly TabGroup[], validTabIds: ReadonlySet): TabGroup[] { + return groups.flatMap((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + if (tabOrder.length === 0) { + return [] + } + const activeTabId = + group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : tabOrder[0] + return [ + { + ...group, + tabOrder, + activeTabId, + // Assigned even when empty: `...group` would otherwise bring back the unfiltered ids. + ...(group.recentTabIds && { + recentTabIds: group.recentTabIds.filter((tabId) => validTabIds.has(tabId)) + }) + } + ] + }) +} diff --git a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts index 297a99a3eb0b..2e642cca6a5d 100644 --- a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts +++ b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts @@ -15,14 +15,33 @@ import { reopenTopologyStore } from './terminal-topology-profile-fixture' +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ + syncHandlers: new Map void>(), + invokeHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: ( + channel: string, + handler: (event: { returnValue?: unknown }, ...args: unknown[]) => void + ) => syncHandlers.set(channel, handler), + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => + invokeHandlers.set(channel, handler) + } +})) vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../ssh/ssh-config-parser', () => ({ loadUserSshConfig: () => ({ hosts: [] }), sshConfigHostsToTargets: () => [] })) -// The mirror refactor moves sleeping records into main's own commits and makes window saves -// presentation-only; a slept agent must still be resumable after quit and relaunch. +import { registerRendererShutdownCheckpointHandler } from '../../ipc/renderer-shutdown-checkpoint' +import { registerSessionHandlers } from '../../ipc/session' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' + +// Sleeping records are main's own commits and window saves are presentation-only; a slept agent +// must still be resumable after quit and relaunch. const TAB = 'tab-agent' const LEAF = '11111111-1111-4111-8111-111111111111' @@ -32,6 +51,8 @@ const PANE_KEY = `${TAB}:${LEAF}` const directories: string[] = [] afterEach(() => { + syncHandlers.clear() + invokeHandlers.clear() for (const directory of directories.splice(0)) { rmSync(directory, { recursive: true, force: true }) } @@ -83,16 +104,40 @@ function windowSessionWithAgentPane(store: Store): WorkspaceSessionState { return session } +/** The window's IPC into main: its own sleeping-record commits, its saves and its quit stage. */ +function windowChannels(store: Store) { + registerSessionHandlers(store, new OrcaRuntimeService(store)) + registerRendererShutdownCheckpointHandler(store) + return { + commitSleep: (held: SleepingAgentSessionRecord) => + invokeHandlers.get('session:commit-terminal-sleeping-records')!( + {}, + { sleep: { [PANE_KEY]: held }, wake: [] } + ), + save: (session: WorkspaceSessionState) => + invokeHandlers.get('session:set')!({}, structuredClone(session)), + stageQuit: (session: WorkspaceSessionState) => { + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: structuredClone(session) }], + ui: {} + }) + return event.returnValue + } + } +} + async function storeWithAgentPane() { const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-')) directories.push(directory) const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) const window = windowSessionWithAgentPane(store) + // Main's own rows, as the spawn's placement wrote them. store.setWorkspaceSession(structuredClone(window)) await expect( store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY }) ).resolves.toBe(true) - return { directory, store, window } + return { directory, store, window, channels: windowChannels(store) } } /** What main holds for the slept pane, and what the window would show from its slice. */ @@ -120,13 +165,14 @@ function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | u describe('sleep → quit → resume', () => { it('keeps the quit capture over the periodic one through quit and relaunch', async () => { - const { directory, store, window } = await storeWithAgentPane() - // The agent is slept; the periodic save carries the first capture. + const { directory, store, window, channels } = await storeWithAgentPane() + // The agent is slept; the periodic capture commits it and the window's save carries it too. + channels.commitSleep(record('worktree-sleep', 1)) window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) } - store.setWorkspaceSession(structuredClone(window)) - // Quit: the window's quit capture rides only on the synchronous before-unload stage. - window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) } - store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + channels.save(window) + // Quit: the quit capture commits, then the stage carries the window's older copy. + channels.commitSleep(record('quit', 2)) + expect(channels.stageQuit(window)).toEqual({ ok: true }) const relaunched = await reopenTopologyStore(store, directory) try { @@ -138,7 +184,7 @@ describe('sleep → quit → resume', () => { } expect(heldForPane(relaunched)).toEqual(expected) // The relaunched window's first save is its hydrated copy; the record stays. - relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession())) + windowChannels(relaunched).save(relaunched.getWorkspaceSession()) expect(heldForPane(relaunched)).toEqual(expected) expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'") } finally { @@ -146,10 +192,10 @@ describe('sleep → quit → resume', () => { } }) - it('keeps a record that only the before-unload stage carried', async () => { - const { directory, store, window } = await storeWithAgentPane() - window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) } - store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + it('keeps a committed record through a quit stage whose window copy has none', async () => { + const { directory, store, window, channels } = await storeWithAgentPane() + channels.commitSleep(record('quit', 3)) + channels.stageQuit({ ...window, sleepingAgentSessionsByPaneKey: {} }) const relaunched = await reopenTopologyStore(store, directory) try { diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts index 6750eae5a214..07991db42d99 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -26,7 +26,6 @@ const ALLOWED_REFERENCES: Record = { // Several runtime files only check it exists, then write through setWorkspaceSessionForWorktree. setWorkspaceSession: [ 'ipc/pty/pane/stable-owner.ts', - 'ipc/session.ts', // Store-internal: patchWorkspaceSession -> setWorkspaceSession. 'persistence/loading-store/session-snapshot-operations.ts', // Test support: seeds sessions for the acknowledged-tab retirement audit. @@ -52,10 +51,9 @@ const ALLOWED_REFERENCES: Record = { setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], patchWorkspaceSession: [ // A window's SSH snapshot import, committed with the pull it came from. - 'ipc/remote-workspace.ts', - 'ipc/session.ts' + 'ipc/remote-workspace.ts' ], - stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], + stageWorkspaceSessionBeforeUnload: [], setWorkspaceSessionForWorktree: [ // Headless editor-tab retirement, like the headless mobile-session tab writers below. 'runtime/mobile-session-editor-projection.ts', diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 741a723a9fb8..b4fe5ec64411 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -12,6 +12,7 @@ import type { TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' import type { TerminalLeafBindRequest } from '../../../shared/terminal-leaf-bind' +import type { TerminalSleepingRecordChanges } from '../../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { PtyBindingPersistenceOperations } from '../loading-store/pty-binding-persistence' import { startSpan } from '../../observability/tracer' @@ -20,6 +21,7 @@ import { type TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import type { Store } from '../loading-store/store' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { planTerminalLayoutSet } from './terminal-layout-set' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' @@ -91,6 +93,32 @@ export function setLayout( ) } +/** A window's own sleeping-record changes, each written in its worktree's home partition. */ +export function commitSleepingRecords( + store: Pick< + Store, + 'getWorkspaceSessionHostIds' | 'getWorkspaceSession' | 'patchWorkspaceSession' + >, + changes: TerminalSleepingRecordChanges, + homeHostId: (worktreeId: string) => ExecutionHostId | null, + isFenced: (hostId: ExecutionHostId) => boolean +): void { + for (const hostId of store.getWorkspaceSessionHostIds()) { + if (isFenced(hostId)) { + continue + } + const isHome = (worktreeId: string): boolean => homeHostId(worktreeId) === hostId + const session = store.getWorkspaceSession(hostId) + const next = sleepLeaf(wakeLeaf(session, changes.wake, isHome), changes.sleep, isHome) + if (next !== session) { + store.patchWorkspaceSession( + { sleepingAgentSessionsByPaneKey: next.sleepingAgentSessionsByPaneKey }, + hostId + ) + } + } +} + /** A sleeping agent's record lands beside its tab, in its worktree's home partition. */ export function sleepLeaf( session: WorkspaceSessionState, diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index e87909a26bdb..c815f385669f 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -10,8 +10,13 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/termin import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import type { TerminalSleepingRecordChanges } from '../../../shared/terminal-topology-slice' import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants' -import { closeLeafOrTab } from './terminal-topology-commit' +import { closeLeafOrTab, commitSleepingRecords } from './terminal-topology-commit' +import { + setRendererSession, + stageRendererSessionBeforeUnload +} from './terminal-renderer-presentation-save' import { emptyTerminalSessionProfile, FIXTURE_FOLDER_WORKTREE_ID, @@ -28,7 +33,7 @@ vi.mock('../../ssh/ssh-config-parser', () => ({ /** * Model test for terminal layout as main holds it. Seeded gesture sequences run against main's - * real Store, written the way today's desktop window writes them; a plain reference model says + * real Store, written the way the desktop window writes them; a plain reference model says * what main must hold after each one. Making the window a mirror of main changes who writes, not * this outcome, so every seed must stay green through that refactor. * Replay one seed: FUZZ_SEED= FUZZ_ITERATIONS=1 pnpm test . @@ -272,7 +277,15 @@ async function runSeed(seed: number): Promise { const window = new WindowSession(structuredClone(store.getWorkspaceSession())) const model: Model = new Map() const log: string[] = [] - const save = (): void => store.setWorkspaceSession(window.snapshot()) + const save = (): void => setRendererSession(store, window.snapshot()) + // The window's own sleeping-record changes commit to main; its saves no longer carry them. + const commitSleeping = (changes: TerminalSleepingRecordChanges): void => + commitSleepingRecords( + store, + changes, + () => LOCAL_EXECUTION_HOST_ID, + () => false + ) const panes = () => [...model].flatMap(([tabId, tab]) => [...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane })) @@ -305,7 +318,7 @@ async function runSeed(seed: number): Promise { const reopen = async (stageQuit: boolean): Promise => { if (stageQuit) { - store.stageWorkspaceSessionBeforeUnload(window.snapshot()) + stageRendererSessionBeforeUnload(store, window.snapshot()) } store = await reopenTopologyStore(store, directory) window.session = structuredClone(store.getWorkspaceSession()) @@ -374,10 +387,12 @@ async function runSeed(seed: number): Promise { const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {} void _closed window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings) - const { [`${tabId}:${leafId}`]: _record, ...sleeping } = + const { [`${tabId}:${leafId}`]: record, ...sleeping } = window.session.sleepingAgentSessionsByPaneKey ?? {} - void _record window.setSleeping(sleeping) + if (record) { + commitSleeping({ sleep: {}, wake: [`${tabId}:${leafId}`] }) + } tab.leaves.delete(leafId) await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId }) save() @@ -477,12 +492,18 @@ async function runSeed(seed: number): Promise { log.push(`${op} ${target.tabId}:${target.leafId}`) const paneKey = `${target.tabId}:${target.leafId}` const records = { ...window.session.sleepingAgentSessionsByPaneKey } + const record = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) if (op === 'sleep') { - records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) + records[paneKey] = record } else { delete records[paneKey] } window.setSleeping(records) + commitSleeping( + op === 'sleep' + ? { sleep: { [paneKey]: record }, wake: [] } + : { sleep: {}, wake: [paneKey] } + ) target.pane.sleeping = op === 'sleep' save() break diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 32af5b46dd59..c66e01b8731f 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -13,6 +13,7 @@ import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' +import { setRendererSession } from '../persistence/terminal-topology/terminal-renderer-presentation-save' export const ACK_WORKTREE = 'repo1::/tmp/worktree' export const ACK_TAB = '11111111-1111-4111-8111-111111111111' @@ -160,7 +161,8 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { ACK_WORKTREE, ACK_TAB ) - store.setWorkspaceSession({ ...closed.session, terminalTopologyRevisionByRepoId: undefined }) + // The window's save after its close: presentation only, so main still holds the tab. + setRendererSession(store, { ...closed.session, terminalTopologyRevisionByRepoId: undefined }) await store.flushPendingOrThrowAsync() entered.resolve() await acknowledgement.promise diff --git a/src/main/runtime/host-terminal-close-persistence-durability.test.ts b/src/main/runtime/host-terminal-close-persistence-durability.test.ts index 642edd6cf17b..b7a9d848fe5f 100644 --- a/src/main/runtime/host-terminal-close-persistence-durability.test.ts +++ b/src/main/runtime/host-terminal-close-persistence-durability.test.ts @@ -1,10 +1,6 @@ /** - * Seam: store.persistPtyBinding must not arm the repo's topology fence. An - * armed fence makes rebaseWorkspaceSessionTerminalMembership treat a later - * renderer close as a stale replay and restore the row, leaving the durable - * de-persist to ride the PTY exit — asynchronous, and able to fail outright. - * Safety is one guard away: advanceTopologyFence's - * `currentRevision <= 0 && !establishesSplitAuthority` early return. + * Seam: main's binding write advances the repo's topology fence on every membership change, and + * a window save cannot change membership, so a close is main's commit and stays durable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -12,6 +8,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { createStore, testState } from '../persistence-test-harness' +import { setRendererSession } from '../persistence/terminal-topology/terminal-renderer-presentation-save' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' vi.mock('electron', () => ({ app: { getPath: () => testStateDirRef.dir, getName: () => 'orca', getVersion: () => '0.0.0' }, @@ -28,13 +26,11 @@ const WT = `${REPO_ID}::/tmp/wt-cli` const TAB = 'cli-tab-1' const LEAF = '11111111-1111-4111-8111-111111111111' const PTY = `${WT}@@a1b2c3d4` +const SECOND_LEAF = '22222222-2222-4222-8222-222222222222' +const INCARNATION = '33333333-3333-4333-8333-333333333333' -function rendererWriteWithout( - session: WorkspaceSessionState, - tabId: string -): WorkspaceSessionState { - // A renderer session write after closing `tabId`: the row is gone, and the - // renderer never writes the host-private topology fence. +function windowSaveWithout(session: WorkspaceSessionState, tabId: string): WorkspaceSessionState { + // A window save after hiding `tabId`: the row is gone, and the window never writes the fence. const next: WorkspaceSessionState = { ...session, tabsByWorktree: { @@ -42,7 +38,7 @@ function rendererWriteWithout( [WT]: (session.tabsByWorktree?.[WT] ?? []).filter((tab) => tab.id !== tabId) } } - delete (next as { terminalTopologyRevisionByRepoId?: unknown }).terminalTopologyRevisionByRepoId + delete next.terminalTopologyRevisionByRepoId return next } @@ -60,54 +56,41 @@ describe('host-created terminal close durability', () => { rmSync(testState.dir, { recursive: true, force: true }) }) - it('a host-created terminal does NOT push a fresh repo into host-authoritative membership', async () => { + it('a window save that omits a row main holds does not remove it', async () => { const store = await makeStore() await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - const session = store.getWorkspaceSession() - expect(session.tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) - // advanceTopologyFence (store.ts:3284) deliberately declines to arm the - // fence while currentRevision <= 0 and no split authority is established. - // That is what keeps a renderer close authoritative for this repo. - expect(session.terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0) + setRendererSession(store, windowSaveWithout(store.getWorkspaceSession(), TAB)) + expect(store.getWorkspaceSession().tabsByWorktree?.[WT]?.map((t) => t.id)).toEqual([TAB]) }) - it('a renderer close write durably removes the row it persisted', async () => { + it("main's close stays durable through later stale window saves", async () => { const store = await makeStore() - await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) - expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) - }) - - it('stays removed with the PTY still connected and no exit ever delivered', async () => { - const store = await makeStore() - await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) - // Kill-failure shape: no retirement, no exit, just more renderer writes. + await store.persistPtyBinding({ + worktreeId: WT, + tabId: TAB, + leafId: LEAF, + ptyId: PTY, + incarnationId: INCARNATION + }) + const stale = structuredClone(store.getWorkspaceSession()) + store.setWorkspaceSession( + retireTerminalSurfaceFromPersistence(store.getWorkspaceSession(), { + worktreeId: WT, + parentTabId: TAB, + leafId: LEAF, + ptyId: PTY, + incarnationId: INCARNATION + }) + ) + // Kill-failure shape: no exit, just more window saves that still list the tab. for (let i = 0; i < 3; i += 1) { - store.setWorkspaceSession({ ...store.getWorkspaceSession() }) + setRendererSession(store, structuredClone(stale)) } expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) }) - - it('pre-existing host-authoritative membership rebases the close away (not ours)', async () => { - const store = await makeStore() - // A serve/SSH host arms the fence long before any CLI terminal exists. - store.setWorkspaceSession({ - ...store.getWorkspaceSession(), - terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } - }) - await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) - // Documents PRE-EXISTING behavior: with the fence already armed, a renderer - // write that omits a row is treated as a stale replay and the row survives - // until an exit-driven retirement advances the fence. This is independent of - // host-created terminals — it applies to every tab in such a repo. - expect(store.getWorkspaceSession().tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) - }) }) -/** Pins the fence seam itself rather than spot-checking callers: a new - * fence-advancing path, or arming the fence from the create path, fails here. */ +/** Pins the fence seam itself: every membership change main's binding write makes advances it. */ describe('topology fence census', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-fence-census-')) @@ -118,10 +101,10 @@ describe('topology fence census', () => { rmSync(testState.dir, { recursive: true, force: true }) }) - it('no host-create binding shape arms a fresh repo fence', async () => { + it('every host-create binding shape advances a fresh repo fence', async () => { for (const [index, extra] of [ {}, - { incarnationId: '33333333-3333-4333-8333-333333333333' }, + { incarnationId: INCARNATION }, { startupCwd: '/tmp/wt-cli' } ].entries()) { const store = await makeStore() @@ -132,23 +115,21 @@ describe('topology fence census', () => { ptyId: `${PTY}-${index}`, ...extra }) - expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) } }) - it('a second pane in the same tab still does not arm the fence', async () => { + it('a second pane advances it; rebinding a held pane does not', async () => { const store = await makeStore() await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - await store.persistPtyBinding({ - worktreeId: WT, - tabId: TAB, - leafId: '22222222-2222-4222-8222-222222222222', - ptyId: `${PTY}-b` - }) - expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0).toBe(0) + const second = { worktreeId: WT, tabId: TAB, leafId: SECOND_LEAF, ptyId: `${PTY}-b` } + await store.persistPtyBinding(second) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(2) + await store.persistPtyBinding({ ...second, ptyId: `${PTY}-c` }) + expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(2) }) - it('an armed fence keeps climbing, so exit-driven retirement still outranks replays', async () => { + it('an armed fence keeps climbing', async () => { const store = await makeStore() store.setWorkspaceSession({ ...store.getWorkspaceSession(), diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts index e1138ddd20e8..61d997062c68 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' -import { - retireTerminalSurfaceFromPersistence, - sanitizeWorkspaceSessionTerminalRetirements -} from './mobile-session-terminal-persistence-retirement' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' const WORKTREE_ID = 'repo::/worktree' const REPO_ID = 'repo' @@ -240,163 +237,6 @@ describe('mobile session terminal persistence retirement', () => { expect(result.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) }) - it('rebases stale writes onto durable host membership without retaining pane history', () => { - const staleSession = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: 'terminal', - ptyId: 'pty-left', - worktreeId: WORKTREE_ID, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - terminal: { - root: { type: 'leaf' as const, leafId: 'left' }, - activeLeafId: 'left', - expandedLeafId: null, - ptyIdsByLeafId: { left: 'pty-left' } - } - }, - terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-a' } - } - const retired = retireTerminalSurfaceFromPersistence(staleSession, { - worktreeId: WORKTREE_ID, - parentTabId: 'terminal', - leafId: 'left', - ptyId: 'pty-left', - incarnationId: 'incarnation-a', - retiredAt: 42 - }) - - const afterStaleWrite = sanitizeWorkspaceSessionTerminalRetirements(staleSession, retired) - expect(afterStaleWrite.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(afterStaleWrite.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(afterStaleWrite.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1) - - const afterRestart = sanitizeWorkspaceSessionTerminalRetirements(staleSession, afterStaleWrite) - expect(afterRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) - - const untrustedReplacement = sanitizeWorkspaceSessionTerminalRetirements( - { - ...staleSession, - terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-b' } - }, - afterRestart - ) - expect(untrustedReplacement.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(untrustedReplacement.terminalPtyIncarnationsByPaneKey).toBeUndefined() - }) - - it('rebases a host partition that no longer carries the closed web-terminal layout', () => { - const tabId = 'web-terminal-24aa462c-589c-45fa-b332-6aa233cd84cf' - const prior = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: tabId, - ptyId: 'pty-worker', - worktreeId: WORKTREE_ID, - title: 'Worker', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId: 'worker' }, - activeLeafId: 'worker', - expandedLeafId: null, - ptyIdsByLeafId: { worker: 'pty-worker' } - } - }, - terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } - } - const incoming = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [] } - } - const partialIncoming: Partial = incoming - delete partialIncoming.terminalLayoutsByTabId - - const result = sanitizeWorkspaceSessionTerminalRetirements(incoming, prior) - - expect(result.tabsByWorktree[WORKTREE_ID]).toEqual(prior.tabsByWorktree[WORKTREE_ID]) - expect(result.terminalLayoutsByTabId[tabId]).toEqual(prior.terminalLayoutsByTabId[tabId]) - }) - - it('replays a legacy retirement when its host partition omits terminal layouts', () => { - const tabId = 'web-terminal-24aa462c-589c-45fa-b332-6aa233cd84cf' - const incoming = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: tabId, - ptyId: 'pty-worker', - worktreeId: WORKTREE_ID, - title: 'Worker', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalSurfaceTombstonesByPaneKey: { - [`${tabId}:worker`]: { - worktreeId: WORKTREE_ID, - parentTabId: tabId, - leafId: 'worker', - ptyId: 'pty-worker', - incarnationId: 'incarnation-worker', - retiredAt: 42 - } - } - } - const partialIncoming: Partial = incoming - delete partialIncoming.terminalLayoutsByTabId - - const result = sanitizeWorkspaceSessionTerminalRetirements(incoming, undefined) - - expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(result.terminalLayoutsByTabId).toEqual({}) - expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(result.terminalTopologyRevisionByRepoId).toEqual({ [REPO_ID]: 1 }) - }) - - it('migrates legacy tombstones into one repo watermark', () => { - const stale = { - ...getDefaultWorkspaceSession(), - terminalSurfaceTombstonesByPaneKey: { - 'terminal:left': { - worktreeId: WORKTREE_ID, - parentTabId: 'terminal', - leafId: 'left', - ptyId: 'pty-left', - incarnationId: 'incarnation-a', - retiredAt: 42 - } - } - } - - const migrated = sanitizeWorkspaceSessionTerminalRetirements(stale, stale) - - expect(migrated.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(migrated.terminalTopologyRevisionByRepoId).toEqual({ [REPO_ID]: 1 }) - }) - it('keeps retirement state proportional to repos across many worktrees and closed panes', () => { let session = getDefaultWorkspaceSession() for (let index = 0; index < 1_000; index += 1) { diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index 25ed04c2f32c..ff18e6b4a40d 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -6,7 +6,6 @@ import { retireLeavesFromTerminalLayout, type RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' function visibleTypeForContentType( @@ -219,39 +218,3 @@ export function retireTerminalSurfaceFromPersistence( paneKey ) } - -export function sanitizeWorkspaceSessionTerminalRetirements( - incoming: WorkspaceSessionState, - prior: WorkspaceSessionState | undefined -): WorkspaceSessionState { - if ( - !prior?.terminalSurfaceTombstonesByPaneKey && - !incoming.terminalSurfaceTombstonesByPaneKey && - !prior?.terminalTopologyRevisionByRepoId - ) { - return incoming - } - const bindings = { - ...prior?.terminalPtyIncarnationsByPaneKey, - ...incoming.terminalPtyIncarnationsByPaneKey - } - const tombstones = { - ...prior?.terminalSurfaceTombstonesByPaneKey, - ...incoming.terminalSurfaceTombstonesByPaneKey - } - const hasLegacyTombstones = Object.keys(tombstones).length > 0 - let next: WorkspaceSessionState = { - ...incoming, - terminalPtyIncarnationsByPaneKey: hasLegacyTombstones - ? bindings - : incoming.terminalPtyIncarnationsByPaneKey, - terminalSurfaceTombstonesByPaneKey: tombstones - } - for (const tombstone of Object.values(tombstones)) { - next = retireTerminalSurfaceFromPersistence(next, tombstone) - } - return rebaseWorkspaceSessionTerminalMembership( - { ...next, terminalSurfaceTombstonesByPaneKey: {} }, - prior - ) -} diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2bfeaec2adde..b2f5c1af3d50 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -184,7 +184,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU return finishCommittedClose() } if (closingWholeParent && this.notifier?.closeTerminalTab) { - // The renderer flush can rebase its omission; the host commits the acknowledged identity. + // The window's close commits through main; the host also commits the acknowledged identity. const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, tab.parentTabId) // Wait for the renderer's pin guard, retirement and forced session flush. const win = this.getAvailableAuthoritativeWindow() diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 78c31b578346..2d0a28b81edb 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -10,6 +10,7 @@ import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' import { cloneTerminalLayoutSnapshot } from './mobile-session-layout-projection' +import { sameTerminalLeafSet } from '../persistence/terminal-topology/terminal-layout-set' export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWithCloseHeadlessMobileTerminalTab { protected persistHeadlessSessionTabProps( @@ -130,25 +131,22 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi if (!existing) { return undefined } - const candidate = { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...cloneTerminalLayoutSnapshot(existing), - root: args.root ?? existing.root, - expandedLeafId: args.expandedLeafId, - ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), - ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) - } - } + // A client edits geometry, focus and titles; a tree with other panes is stale, so main's stands. + if (args.root && !sameTerminalLeafSet(existing.root, args.root)) { + return existing + } + const layout = { + ...cloneTerminalLayoutSnapshot(existing), + root: args.root ?? existing.root, + expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), + ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } - this.setWorkspaceSessionForWorktree(worktreeId, candidate) - // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. - return ( - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[args.tabId] ?? - candidate.terminalLayoutsByTabId[args.tabId] - ) + this.setWorkspaceSessionForWorktree(worktreeId, { + ...session, + terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, [args.tabId]: layout } + }) + return layout } protected applyHeadlessTerminalPaneLayoutToSnapshot( diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts index 28bb3e62d2e8..15b2869c66b9 100644 --- a/src/main/runtime/orca-runtime-terminal-close-records.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -20,6 +20,7 @@ import { makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' +import { setRendererSession } from '../persistence/terminal-topology/terminal-renderer-presentation-save' const SSH_REPO_ID = 'ssh-repo' const SSH_HOST_ID = 'ssh:target-1' @@ -89,7 +90,7 @@ describe('close records', () => { target: { kind: 'tab', tabId: TAB_ID }, reason }) - store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + setRendererSession(store, rendererSave(store.getWorkspaceSession())) const reloaded = (await reload()).getWorkspaceSession() expect(reloaded.tabsByWorktree[WORKTREE_ID]).toEqual([]) @@ -101,7 +102,7 @@ describe('close records', () => { } ) - // The store keeps main's map only when a write omits it; main's own writes carry it and win. + // A window save never carries the records; main's own writes do and win. it("keeps main's own record writes across later store writes", async () => { const { store, runtime } = createPersistedRuntime() @@ -113,7 +114,7 @@ describe('close records', () => { worktreeId: WORKTREE_ID, target: { kind: 'tab', tabId: LATE_TAB_ID } }) - store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + setRendererSession(store, rendererSave(store.getWorkspaceSession())) expect( Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}).sort() diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index d6f70fd4c6e0..9f0df34c4ef1 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -7,7 +7,6 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' import { OrcaRuntimeService } from './orca-runtime' const WORKTREE_ID = 'repo::/worktree' @@ -417,7 +416,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { expect(tabs[0]).toMatchObject({ type: 'terminal', ptyId: 'pty-right' }) }) - it('publishes the host-rebased layout after a stale client pane update', async () => { + it('keeps the host layout after a stale client pane update with other panes', async () => { let session = makePersistedSplitSession() session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-right' session.terminalLayoutsByTabId.tab = { @@ -433,7 +432,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { runtimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (incoming: WorkspaceSessionState) => { - session = sanitizeWorkspaceSessionTerminalRetirements(incoming, session) + session = incoming } }) ) diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index 98e22b90b568..b09e86173f5b 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -19,10 +19,8 @@ import { createHarness, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' -import { - retireTerminalSurfaceFromPersistence, - sanitizeWorkspaceSessionTerminalRetirements -} from './mobile-session-terminal-persistence-retirement' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' +import { mergeRendererPresentationSave } from '../persistence/terminal-topology/terminal-renderer-presentation-save' import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const splitLayout = { @@ -247,9 +245,10 @@ describe('CLI close of one pane in a split tab', () => { const receipt = await harness.runtime.closeTerminal(terminal.handle) // A stale renderer save that still lists the pane can neither restore it nor drop the sibling. - const saved = sanitizeWorkspaceSessionTerminalRetirements( + const saved = mergeRendererPresentationSave( { ...harness.getSession(), terminalLayoutsByTabId: { [TAB_ID]: splitLayout } }, - harness.getSession() + harness.getSession(), + 'local' ) expect(saved.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) expect(saved.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts index be1d4c8175c4..b782105bc97b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts @@ -254,6 +254,13 @@ describe('OrcaRuntimeService', () => { it('persists a headless pane layout (ratio/expand) so it survives a cold rehydrate', async () => { const session = makeWorkspaceSessionWithHeadlessTerminal() + // A client edits the geometry of panes main holds; it cannot add one. + session.terminalLayoutsByTabId['host-tab']!.root = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: 'leaf-2' } + } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService(runtimeStore as never) diff --git a/src/main/runtime/workspace-session-membership-scaling.test.ts b/src/main/runtime/workspace-session-membership-scaling.test.ts deleted file mode 100644 index 802245461b77..000000000000 --- a/src/main/runtime/workspace-session-membership-scaling.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { TabGroup } from '../../shared/tab-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' - -it('rebases a large group without rescanning tab order for every recent tab', () => { - const ids = Array.from({ length: 1000 }, (_, index) => `tab-${index}`) - const session: WorkspaceSessionState = { - activeRepoId: 'repo', - activeWorktreeId: 'repo::/workspace', - activeTabId: null, - tabsByWorktree: { - 'repo::/workspace': ids.map((id, index) => ({ - id, - worktreeId: 'repo::/workspace', - ptyId: null, - title: id, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 0 - })) - }, - terminalLayoutsByTabId: {}, - terminalTopologyRevisionByRepoId: { repo: 1 }, - tabGroups: { - 'repo::/workspace': [ - { - id: 'group', - worktreeId: 'repo::/workspace', - activeTabId: 'missing', - tabOrder: [...ids, 'missing'], - recentTabIds: [...ids, 'missing'] - } - ] - } - } - const includes = vi.spyOn(Array.prototype, 'includes') - let result: WorkspaceSessionState - let probes: number - try { - result = rebaseWorkspaceSessionTerminalMembership(session, session) - probes = includes.mock.calls.length - } finally { - includes.mockRestore() - } - expect(probes).toBeLessThan(10) - expect(result.tabGroups?.['repo::/workspace'][0]).toMatchObject({ - tabOrder: ids, - recentTabIds: ids, - activeTabId: ids[0] - }) -}) - -// Why: the rebased session is the host-authoritative one, so a tab id the host no -// longer has must not survive into it. `activeTabId` already failed closed; the -// filtered `recentTabIds` used to be dropped when it emptied, letting the `...group` -// spread put the unfiltered array back. -it('drops tab ids the host no longer has from group membership, failing closed', () => { - const buildSession = ( - activeTabId: string | null, - recentTabIds: string[] | undefined - ): WorkspaceSessionState => - ({ - activeRepoId: 'repo', - activeWorktreeId: 'repo::/workspace', - activeTabId: null, - tabsByWorktree: { - 'repo::/workspace': ['kept-a', 'kept-b'].map((id, index) => ({ - id, - worktreeId: 'repo::/workspace', - ptyId: null, - title: id, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 0 - })) - }, - terminalLayoutsByTabId: {}, - terminalTopologyRevisionByRepoId: { repo: 1 }, - tabGroups: { - 'repo::/workspace': [ - { - id: 'group', - worktreeId: 'repo::/workspace', - activeTabId, - tabOrder: ['kept-a', 'closed-on-host', 'kept-b'], - ...(recentTabIds ? { recentTabIds } : {}) - } - ] - } - }) as WorkspaceSessionState - - const rebase = ( - activeTabId: string | null, - recentTabIds: string[] | undefined - ): TabGroup | undefined => { - const session = buildSession(activeTabId, recentTabIds) - return rebaseWorkspaceSessionTerminalMembership(session, session).tabGroups?.[ - 'repo::/workspace' - ][0] - } - - // Every recent id is stale: the array must empty, not revert to the stale one. - expect(rebase('kept-b', ['closed-on-host'])).toMatchObject({ - tabOrder: ['kept-a', 'kept-b'], - activeTabId: 'kept-b', - recentTabIds: [] - }) - // Mixed: only the host-known ids survive, in order. - expect(rebase('kept-a', ['closed-on-host', 'kept-b', 'closed-on-host', 'kept-a'])).toMatchObject({ - recentTabIds: ['kept-b', 'kept-a'] - }) - // A stale active tab falls back to the first surviving tab, never to the stale id. - expect(rebase('closed-on-host', ['kept-a'])).toMatchObject({ - activeTabId: 'kept-a', - recentTabIds: ['kept-a'] - }) - expect(rebase(null, undefined)).toMatchObject({ activeTabId: 'kept-a' }) - // A group that never carried recentTabIds must not gain the key. - expect(rebase('kept-a', undefined)).not.toHaveProperty('recentTabIds') -}) diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts deleted file mode 100644 index 4c2ea719da52..000000000000 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { Tab, TabGroup } from '../../shared/tab-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode -} from '../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' - -function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { - if (!node) { - return - } - if (node.type === 'leaf') { - ids.add(node.leafId) - return - } - collectLeafIds(node.first, ids) - collectLeafIds(node.second, ids) -} - -function layoutHasSameMembership( - candidate: TerminalLayoutSnapshot, - current: TerminalLayoutSnapshot -): boolean { - const candidateIds = new Set() - const currentIds = new Set() - collectLeafIds(candidate.root, candidateIds) - collectLeafIds(current.root, currentIds) - return ( - candidateIds.size === currentIds.size && - [...candidateIds].every((leafId) => currentIds.has(leafId)) - ) -} - -function rebaseLayout( - candidate: TerminalLayoutSnapshot | undefined, - current: TerminalLayoutSnapshot | undefined -): TerminalLayoutSnapshot | undefined { - if (!current) { - return undefined - } - if (!candidate || !layoutHasSameMembership(candidate, current)) { - return current - } - return { - ...candidate, - // Why: renderer layout metadata may move, but only host-persisted live bindings may name PTYs. - ptyIdsByLeafId: { - ...candidate.ptyIdsByLeafId, - ...current.ptyIdsByLeafId - } - } -} - -function terminalUnifiedTabMatches(tab: Tab, terminalTabIds: ReadonlySet): boolean { - return ( - tab.contentType === 'terminal' && - (terminalTabIds.has(tab.id) || terminalTabIds.has(tab.entityId)) - ) -} - -function rebaseUnifiedTabs( - candidate: readonly Tab[], - current: readonly Tab[], - terminalTabIds: ReadonlySet -): Tab[] { - const result = candidate.filter( - (tab) => tab.contentType !== 'terminal' || terminalUnifiedTabMatches(tab, terminalTabIds) - ) - const representedTerminalIds = new Set( - result.flatMap((tab) => (tab.contentType === 'terminal' ? [tab.id, tab.entityId] : [])) - ) - for (const tab of current) { - if ( - terminalUnifiedTabMatches(tab, terminalTabIds) && - !representedTerminalIds.has(tab.id) && - !representedTerminalIds.has(tab.entityId) - ) { - result.push(tab) - } - } - return result -} - -function rebaseTabGroups( - groups: readonly TabGroup[], - validTabIds: ReadonlySet -): TabGroup[] { - return groups.flatMap((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - if (tabOrder.length === 0) { - return [] - } - const tabIds = new Set(tabOrder) - const activeTabId = - group.activeTabId && tabIds.has(group.activeTabId) ? group.activeTabId : (tabOrder[0] ?? null) - const recentTabIds = group.recentTabIds?.filter((tabId) => tabIds.has(tabId)) - return [ - { - ...group, - tabOrder, - activeTabId, - // Why assigned even when it filters to empty: omitting the key lets `...group` - // re-introduce the unfiltered array, persisting ids for tabs the host dropped. - ...(group.recentTabIds ? { recentTabIds: recentTabIds ?? [] } : {}) - } - ] - }) -} - -function rebaseIncarnationBindings( - session: WorkspaceSessionState, - prior: WorkspaceSessionState -): Record | undefined { - const terminalTabIds = new Set( - Object.values(session.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) - ) - const allowedPaneKeys = new Set() - for (const tabId of terminalTabIds) { - const layout = session.terminalLayoutsByTabId[tabId] - if (!layout) { - continue - } - const leafIds = new Set() - collectLeafIds(layout.root, leafIds) - for (const leafId of leafIds) { - allowedPaneKeys.add(`${tabId}:${leafId}`) - } - } - const merged = { - ...session.terminalPtyIncarnationsByPaneKey, - ...prior.terminalPtyIncarnationsByPaneKey - } - const retained = Object.fromEntries( - Object.entries(merged).filter(([paneKey]) => { - const separator = paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = paneKey.slice(0, separator) - return session.terminalLayoutsByTabId[tabId] - ? allowedPaneKeys.has(paneKey) - : terminalTabIds.has(tabId) - }) - ) - return Object.keys(retained).length > 0 ? retained : undefined -} - -export function rebaseWorkspaceSessionTerminalMembership( - incoming: WorkspaceSessionState, - prior: WorkspaceSessionState | undefined -): WorkspaceSessionState { - if (!prior?.terminalTopologyRevisionByRepoId) { - return incoming - } - const terminalTopologyRevisionByRepoId = { ...incoming.terminalTopologyRevisionByRepoId } - for (const [repoId, revision] of Object.entries(prior.terminalTopologyRevisionByRepoId)) { - terminalTopologyRevisionByRepoId[repoId] = Math.max( - revision, - terminalTopologyRevisionByRepoId[repoId] ?? 0 - ) - } - const tabsByWorktree = { ...incoming.tabsByWorktree } - const incomingTerminalLayoutsByTabId = incoming.terminalLayoutsByTabId ?? {} - const priorTerminalLayoutsByTabId = prior.terminalLayoutsByTabId ?? {} - const terminalLayoutsByTabId = { ...incomingTerminalLayoutsByTabId } - const unifiedTabs = { ...incoming.unifiedTabs } - const tabGroups = { ...incoming.tabGroups } - const tabGroupLayouts = { ...incoming.tabGroupLayouts } - const activeTabIdByWorktree = { ...incoming.activeTabIdByWorktree } - let includeUnifiedTabs = incoming.unifiedTabs !== undefined - let includeTabGroups = incoming.tabGroups !== undefined - let includeTabGroupLayouts = incoming.tabGroupLayouts !== undefined - let rebasedMembership = false - const worktreeIds = new Set([ - ...Object.keys(prior.tabsByWorktree), - ...Object.keys(incoming.tabsByWorktree) - ]) - for (const worktreeId of worktreeIds) { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const revision = terminalTopologyRevisionByRepoId[repoId] ?? 0 - const priorRevision = prior.terminalTopologyRevisionByRepoId[repoId] ?? 0 - const incomingRevision = incoming.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - if (revision <= 0 || incomingRevision > priorRevision) { - continue - } - rebasedMembership = true - const currentTabs = prior.tabsByWorktree[worktreeId] ?? [] - const candidateTabsById = new Map( - (incoming.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) - ) - const terminalTabIds = new Set(currentTabs.map((tab) => tab.id)) - const tabs = currentTabs.map((current) => { - const candidate = candidateTabsById.get(current.id) - return candidate ? { ...candidate, ptyId: current.ptyId } : current - }) - for (const candidate of incoming.tabsByWorktree[worktreeId] ?? []) { - if (!terminalTabIds.has(candidate.id)) { - delete terminalLayoutsByTabId[candidate.id] - } - } - for (const tabId of terminalTabIds) { - const layout = rebaseLayout( - incomingTerminalLayoutsByTabId[tabId], - priorTerminalLayoutsByTabId[tabId] - ) - if (layout) { - terminalLayoutsByTabId[tabId] = layout - } else { - delete terminalLayoutsByTabId[tabId] - } - } - const rebasedUnifiedTabs = rebaseUnifiedTabs( - incoming.unifiedTabs?.[worktreeId] ?? [], - prior.unifiedTabs?.[worktreeId] ?? [], - terminalTabIds - ) - if (includeUnifiedTabs || rebasedUnifiedTabs.length > 0) { - unifiedTabs[worktreeId] = rebasedUnifiedTabs - includeUnifiedTabs = true - } - const validTabIds = new Set([...terminalTabIds, ...rebasedUnifiedTabs.map((tab) => tab.id)]) - const rebasedGroups = rebaseTabGroups( - incoming.tabGroups?.[worktreeId] ?? prior.tabGroups?.[worktreeId] ?? [], - validTabIds - ) - if (includeTabGroups || rebasedGroups.length > 0) { - tabGroups[worktreeId] = rebasedGroups - includeTabGroups = true - } - const rebasedGroupLayout = pruneTabGroupLayoutAfterRetirement( - incoming.tabGroupLayouts?.[worktreeId] ?? prior.tabGroupLayouts?.[worktreeId], - new Set(rebasedGroups.map((group) => group.id)) - ) - if (rebasedGroupLayout) { - tabGroupLayouts[worktreeId] = rebasedGroupLayout - includeTabGroupLayouts = true - } else { - delete tabGroupLayouts[worktreeId] - } - if (!validTabIds.has(activeTabIdByWorktree[worktreeId] ?? '')) { - activeTabIdByWorktree[worktreeId] = - (prior.activeTabIdByWorktree?.[worktreeId] && - validTabIds.has(prior.activeTabIdByWorktree[worktreeId] ?? '') - ? prior.activeTabIdByWorktree[worktreeId] - : (rebasedGroups[0]?.activeTabId ?? tabs[0]?.id)) ?? null - } - tabsByWorktree[worktreeId] = tabs - } - const next: WorkspaceSessionState = { - ...incoming, - terminalTopologyRevisionByRepoId, - ...(rebasedMembership - ? { - tabsByWorktree, - terminalLayoutsByTabId, - activeTabIdByWorktree, - ...(includeUnifiedTabs ? { unifiedTabs } : {}), - ...(includeTabGroups ? { tabGroups } : {}), - ...(includeTabGroupLayouts ? { tabGroupLayouts } : {}) - } - : {}) - } - return rebasedMembership - ? { ...next, terminalPtyIncarnationsByPaneKey: rebaseIncarnationBindings(next, prior) } - : next -} diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index d4c795817a35..e713ddd02222 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -281,9 +281,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () }) // A close raises the repo's topology revision, and that is what the fence reads. Pinned as - // behavior because `terminalSurfaceTombstonesByPaneKey` — the older per-surface fence — is - // consumed and cleared by `sanitizeWorkspaceSessionTerminalRetirements` on every session write, - // so it is never present by the time a binding write could consult it. + // behavior because `terminalSurfaceTombstonesByPaneKey` — the older per-surface fence — is no + // longer written, so a binding write cannot count on it. it('treats a raised topology revision as the authority that makes absence a close', async () => { const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })) diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index 8a5f1dad8a45..ce58ac95f313 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import type { TerminalTab } from '../shared/terminal-tab-types' import { getDefaultWorkspaceSession } from '../shared/constants' import { toRuntimeExecutionHostId } from '../shared/execution-host' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' let hasCreatedStoreInCase = false @@ -131,14 +132,15 @@ describe('worktree removal across host session partitions', () => { expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) - store.setWorkspaceSession( + setRendererSession( + store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'stale-tab', worktreeId: STALE })] } }, ENV_A ) - expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toEqual([]) + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() }) // Regression: runtime removal purged only the host partition, so tabs the renderer had parked in the @@ -215,7 +217,8 @@ describe('worktree removal across host session partitions', () => { expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) - store.setWorkspaceSession( + setRendererSession( + store, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'delayed-tab', worktreeId: STALE })] } @@ -223,7 +226,7 @@ describe('worktree removal across host session partitions', () => { ENV_A ) - expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toEqual([]) + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() }) // The widened local purge must not fence the local blob when only siblings live there. diff --git a/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts b/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts index f8398e9ca9f6..82d8a5490423 100644 --- a/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts +++ b/src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts @@ -17,7 +17,7 @@ const DEFAULT_TABS = { tabs: [{ title: 'Claude' }] } -function preserveRuntimeAuthoredWorkspaceSessionFields>( +function unionDefaultTabMarksLikeMainSave>( next: T, prior: T ): T { @@ -66,7 +66,7 @@ describe('defaultTerminalTabsAppliedByWorktreeId persist round-trip', () => { } // Persist snapshots omit this write-once slice (empty payload / renderer never mentioned it). const omittedSnapshot = getDefaultWorkspaceSession() - const preserved = preserveRuntimeAuthoredWorkspaceSessionFields(omittedSnapshot, patched) + const preserved = unionDefaultTabMarksLikeMainSave(omittedSnapshot, patched) const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(preserved))) expect(parsed.ok).toBe(true) if (!parsed.ok) { diff --git a/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts b/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts index 03e72adfd98a..440fce2f966a 100644 --- a/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts +++ b/tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts @@ -134,6 +134,8 @@ async function resumeAcrossRestart( .toBe(remote.worktreeId) await waitForActiveTerminalManager(firstLaunch.page, 60_000) const descriptor = await waitForActivePaneHookDescriptor(firstLaunch.page, 60_000) + // A real agent reports from inside a bound PTY; main holds the tab (and its sleeping record) from then. + await waitForActivePanePtyId(firstLaunch.page, 60_000) // Why seeded rather than driven by a real agent: a real `claude` run needs an install and auth // in the container. This is the same store entry the hook server writes, so the capture, diff --git a/tests/e2e/terminal-layout-parity-declared-differences.ts b/tests/e2e/terminal-layout-parity-declared-differences.ts index e779d83f1cdf..cce8f6f933c7 100644 --- a/tests/e2e/terminal-layout-parity-declared-differences.ts +++ b/tests/e2e/terminal-layout-parity-declared-differences.ts @@ -7,7 +7,43 @@ import type { * Differences from main this branch is allowed, each tied to the named bug it fixes. Keep empty * on an inert PR. Paths are prefixes of the runner's report paths, e.g. `[0].persisted.local`. */ -export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = [] +export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = [ + // The core's mechanism: main alone authors terminal topology, so a window save no longer erases + // main's per-pane incarnations, and the binding write counts every membership change it makes. + // Saved-profile fields only; nothing on screen differs. + ...[ + 'create-tab', + 'split-right-down', + 'close-pane', + 'close-tab', + 'reorder-panes', + 'cli-split', + 'drag-out-to-tab', + 'restart-restore', + 'folder-workspace', + 'setup-split-first-activation', + 'sleep-quit-resume' + ].map((scenario) => ({ + scenario, + bugId: 'STA-9417', + paths: ['[0]', '[1]', '[2]'].flatMap((checkpoint) => [ + `${checkpoint}.persisted.local.terminalTopologyRevisionByRepoId`, + `${checkpoint}.persisted.local.terminalPtyIncarnationsByPaneKey` + ]), + reason: 'Main keeps the topology fields it authors; a window save no longer rewrites them.' + })), + { + scenario: 'setup-split-first-activation', + bugId: 'STA-9417', + paths: [ + '[0].renderer.tabsByWorktree.#2::[0].startupCwd', + '[0].persisted.local.tabsByWorktree.#2::[0].startupCwd' + ], + reason: + "A host-created tab keeps main's row (its start folder is the worktree root); the window " + + 'no longer replaces it with its own copy first.' + } +] /** Paths main itself does not reproduce; reported but not failed. Remove an entry once main is fixed. */ export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = [] diff --git a/tests/e2e/terminal-layout-parity-snapshot.ts b/tests/e2e/terminal-layout-parity-snapshot.ts index e310a5d01be5..f2be2ca8edb0 100644 --- a/tests/e2e/terminal-layout-parity-snapshot.ts +++ b/tests/e2e/terminal-layout-parity-snapshot.ts @@ -104,9 +104,17 @@ class IdLabeler { walk(root) while (pending.length > 0) { const ready = pending.filter(([key]) => this.knows(key)) - // Fallback for ids seen only as keys: raw order, the one place a label can depend on value. + // Fallback for ids seen only as keys: key shape first (unlabeled ids masked), then raw order, + // the one place a label can depend on value. + const shape = (key: string): string => key.replace(new RegExp(ID_SOURCE, 'gi'), '#') const next = - ready.length > 0 ? ready : [pending.toSorted((a, b) => a[0].localeCompare(b[0]))[0]!] + ready.length > 0 + ? ready + : [ + pending.toSorted( + (a, b) => shape(a[0]).localeCompare(shape(b[0])) || a[0].localeCompare(b[0]) + )[0]! + ] const rank = (key: string): string => this.replaceIn(key).replace(/\d+/g, (n) => n.padStart(6, '0')) next.sort((a, b) => rank(a[0]).localeCompare(rank(b[0]))) diff --git a/tests/e2e/terminal-layout-parity.spec.ts b/tests/e2e/terminal-layout-parity.spec.ts index 242574f4e66a..034ec7d65cf8 100644 --- a/tests/e2e/terminal-layout-parity.spec.ts +++ b/tests/e2e/terminal-layout-parity.spec.ts @@ -4,6 +4,7 @@ * this file against two builds and diffs the outputs; see tests/e2e/AGENTS.md. */ +import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -27,8 +28,10 @@ import { seededRepoPathOrSkip } from './helpers/terminal-restart-persistence' import { createTerminalTabFromMenu, SORTABLE_TAB } from './helpers/terminal-tab-menu' +import { activateWorkspaceByClick, sleepWorkspaceViaSidebar } from './helpers/slept-workspace-probe' import { RuntimeClient } from '../../src/cli/runtime/client' import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' +import type { RuntimeWorktreeCreateResult } from '../../src/shared/runtime-worktree-contracts' import { TERMINAL_LAYOUT_PARITY_OUT_ENV, type RawParityCheckpoint, @@ -175,10 +178,15 @@ type ScenarioSetup = { type ParityScenario = { id: string - setup?: (page: Page) => Promise + setup?: (page: Page, userDataDir: string) => Promise journey: (journey: Journey) => Promise /** Relaunch (`times`, default once) and capture each restored renderer and the next save. */ - restart?: { expectedPaneCount: number; times?: number } + restart?: { + expectedPaneCount: number + times?: number + /** How the user gets back to the worktree; default: it is still the active one. */ + reopen?: (page: Page, worktreeId: string) => Promise + } } /** Quit through the shared helper and report how the process ended; a forced kill skips the final save. */ @@ -208,7 +216,7 @@ async function runScenario(testInfo: TestInfo, scenario: ParityScenario): Promis const first = await session.launch() app = first.app setup = scenario.setup - ? await scenario.setup(first.page) + ? await scenario.setup(first.page, session.userDataDir) : await bootstrapFirstLaunch(first.page, repoPath) await waitForBoundPanes(first.page, 1) const journey = { @@ -224,11 +232,15 @@ async function runScenario(testInfo: TestInfo, scenario: ParityScenario): Promis const persisted = readPersistedSessions(session.userDataDir) checkpoints.push({ label: 'after-quit', renderer, persisted, exit }) - const { expectedPaneCount = 0, times: restarts = 1 } = scenario.restart ?? { times: 0 } + const { + expectedPaneCount = 0, + times: restarts = 1, + reopen = bootstrapRestoredLaunch + } = scenario.restart ?? { times: 0 } for (let restart = 1; restart <= restarts; restart += 1) { const next = await session.launch() app = next.app - await bootstrapRestoredLaunch(next.page, setup.worktreeId) + await reopen(next.page, setup.worktreeId) await waitForBoundPanes(next.page, expectedPaneCount) const restored = await readSettledRendererLayout(next.page) const restartExit = await quitAndReadExit(session, app) @@ -290,6 +302,118 @@ async function addFolderWorkspace(page: Page): Promise { return { worktreeId: worktreeId!, pathLabels: { [folderPath]: '' }, cleanup } } +/** + * STA-9417's shape: the CLI creates a worktree whose setup script runs in a split of its first + * terminal, with the window open on another worktree; the user then opens it for the first time. + */ +async function addHostCreatedSetupSplitWorktree( + page: Page, + userDataDir: string +): Promise { + const { worktreeId } = await bootstrapFirstLaunch(page, seededRepoPathOrSkip()) + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-parity-setup-'))) + const git = (...args: string[]): void => { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) + } + git('init', '-b', 'main') + git('config', 'user.email', 'e2e@test.local') + git('config', 'user.name', 'E2E Test') + writeFileSync(path.join(repoPath, 'orca.yaml'), 'scripts:\n setup: echo SETUP_COMPLETE\n') + git('add', '-A') + git('commit', '-m', 'setup hook') + await page.evaluate(() => + window.__store!.getState().updateSettings({ setupScriptLaunchMode: 'split-vertical' }) + ) + const client = new RuntimeClient(userDataDir, 30_000) + const added = await client.call<{ repo: { id: string } }>('repo.add', { + path: repoPath, + kind: 'git' + }) + const created = await client.call('worktree.create', { + repo: `id:${added.result.repo.id}`, + name: 'parity-setup', + noParent: true, + activate: false, + setupDecision: 'run' + }) + const worktreePath = created.result.worktree.path + return { + worktreeId, + pathLabels: { [repoPath]: '', [worktreePath]: '' }, + cleanup: () => { + rmSync(worktreePath, { recursive: true, force: true }) + rmSync(repoPath, { recursive: true, force: true }) + } + } +} + +async function openSetupSplitWorktree({ page, userDataDir }: Journey): Promise { + let setupWorktreeId: string | undefined + await expect + .poll(async () => { + setupWorktreeId = await page.evaluate(async () => { + const store = window.__store! + await store.getState().fetchRepos() + for (const repo of store.getState().repos) { + await store.getState().fetchWorktrees(repo.id) + } + return Object.values(store.getState().worktreesByRepo) + .flat() + .find((worktree) => worktree.branch === 'refs/heads/parity-setup')?.id + }) + return setupWorktreeId + }) + .toBeDefined() + const client = new RuntimeClient(userDataDir, 30_000) + // Main has spawned the first terminal and the setup split before the user opens the worktree. + await expect + .poll(async () => { + const listed = await client + .call('terminal.list', { worktree: `id:${setupWorktreeId!}` }) + .catch(() => null) + return listed?.result.terminals.length ?? 0 + }) + .toBe(2) + await activateWorkspaceByClick(page, setupWorktreeId!) + await waitForActiveTerminalManager(page) + await waitForBoundPanes(page, 2) +} + +/** A slept agent pane, through the user's sidebar Sleep, so the quit and relaunch resume it. */ +async function sleepAgentPane({ page, worktreeId }: Journey): Promise { + await splitActiveTerminalPane(page, 'vertical') + await waitForBoundPanes(page, 2) + await page.evaluate((id) => { + const state = window.__store!.getState() + const tabId = state.activeTabId! + const manager = window.__paneManagers!.get(tabId)! + const leafId = manager.getLeafId(manager.getActivePane()!.id)! + state.setAgentStatus( + `${tabId}:${leafId}`, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: id }, + { providerSession: { key: 'session_id', id: 'parity-sleep-session' } } + ) + }, worktreeId) + await sleepWorkspaceViaSidebar(page, worktreeId) + await expect + .poll(() => + page.evaluate((id) => { + const state = window.__store!.getState() + const slept = Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === id + ) + const live = (state.tabsByWorktree[id] ?? []).some( + (tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0 + ) + return slept && !live + }, worktreeId) + ) + .toBe(true) +} + /** * The user's close-pane chord, so the close reaches main through the real commit path; driving * PaneManager.closePane directly leaves main to learn of it from the PTY exit, which races quit. @@ -349,6 +473,14 @@ async function splitThenNewTab(page: Page): Promise { await waitForBoundPanes(page, 1) } +/** A slept worktree is not active after relaunch; the user's click wakes it and resumes the agent. */ +async function wakeByClick(page: Page, worktreeId: string): Promise { + await waitForSessionReady(page) + await activateWorkspaceByClick(page, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) +} + const SCENARIOS: ParityScenario[] = [ { id: 'create-tab', @@ -431,6 +563,16 @@ const SCENARIOS: ParityScenario[] = [ id: 'folder-workspace', setup: addFolderWorkspace, journey: ({ page }) => splitThenNewTab(page) + }, + { + id: 'setup-split-first-activation', + setup: addHostCreatedSetupSplitWorktree, + journey: openSetupSplitWorktree + }, + { + id: 'sleep-quit-resume', + journey: sleepAgentPane, + restart: { expectedPaneCount: 2, reopen: wakeByClick } } ] From 93042376f578aacd68fec4e88558d5fc29f62602 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 20:58:32 -0400 Subject: [PATCH 32/75] test(terminal): make the setup-split parity scenario reproduce STA-9417 on main Main reveals a setup split to the window only when the window's runtime graph already lists the split's source terminal. An idle e2e window publishes that graph about 80 ms after the first terminal's reveal, before main's split commits, so the split reached the window and main never minted a duplicate. Hold the window busy just after the first reveal, as the natural repro's window was, so it misses the split; then main's first-activation sweep mints a second tab for the setup terminal (or leaves it without a pane). The scenario now settles on whatever activation produces instead of requiring two panes, and the declared STA-9417 difference covers the whole first checkpoint, since the extra tab renumbers every later id. Base b57bb7c3514: 14/16 runs persist the setup terminal in two panes, 2/16 leave it without a pane, 0 clean. Core C7 head: 11/11 one tab, both panes. Co-Authored-By: Claude --- ...inal-layout-parity-declared-differences.ts | 11 ++-- tests/e2e/terminal-layout-parity.spec.ts | 56 ++++++++++++++++++- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/tests/e2e/terminal-layout-parity-declared-differences.ts b/tests/e2e/terminal-layout-parity-declared-differences.ts index cce8f6f933c7..21e24ad7e07c 100644 --- a/tests/e2e/terminal-layout-parity-declared-differences.ts +++ b/tests/e2e/terminal-layout-parity-declared-differences.ts @@ -33,15 +33,14 @@ export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParit reason: 'Main keeps the topology fields it authors; a window save no longer rewrites them.' })), { + // The bug itself: main mints a second tab for the setup terminal (or leaves it with no pane); + // the extra tab renumbers every later id, so the whole checkpoint differs. scenario: 'setup-split-first-activation', bugId: 'STA-9417', - paths: [ - '[0].renderer.tabsByWorktree.#2::[0].startupCwd', - '[0].persisted.local.tabsByWorktree.#2::[0].startupCwd' - ], + paths: ['[0].renderer', '[0].persisted.local'], reason: - "A host-created tab keeps main's row (its start folder is the worktree root); the window " + - 'no longer replaces it with its own copy first.' + 'Main mints a duplicate tab for the setup split on first activation; the core keeps one ' + + "tab whose split holds both terminals, and keeps main's row (start folder) for that tab." } ] diff --git a/tests/e2e/terminal-layout-parity.spec.ts b/tests/e2e/terminal-layout-parity.spec.ts index 034ec7d65cf8..383d91b48b57 100644 --- a/tests/e2e/terminal-layout-parity.spec.ts +++ b/tests/e2e/terminal-layout-parity.spec.ts @@ -302,6 +302,33 @@ async function addFolderWorkspace(page: Page): Promise { return { worktreeId: worktreeId!, pathLabels: { [folderPath]: '' }, cleanup } } +/** + * Main reveals the split to the window only if the window's runtime graph already lists the first + * terminal. The natural repro lost that race; an idle e2e window wins it in ~80 ms, so hold the + * window busy from just after the first reveal until well past main's split. + */ +async function stallRendererOnFirstSetupTab(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store! + const unsubscribe = store.subscribe((state) => { + const revealed = Object.entries(state.tabsByWorktree).some( + ([worktreeId, tabs]) => worktreeId.endsWith('parity-setup') && (tabs ?? []).length > 0 + ) + if (!revealed) { + return + } + unsubscribe() + // After the reveal's own task, before the graph sync it scheduled. + setTimeout(() => { + const until = performance.now() + 3_000 + while (performance.now() < until) { + // Busy, as a loaded renderer is: no timer or IPC runs. + } + }, 0) + }) + }) +} + /** * STA-9417's shape: the CLI creates a worktree whose setup script runs in a split of its first * terminal, with the window open on another worktree; the user then opens it for the first time. @@ -324,6 +351,7 @@ async function addHostCreatedSetupSplitWorktree( await page.evaluate(() => window.__store!.getState().updateSettings({ setupScriptLaunchMode: 'split-vertical' }) ) + await stallRendererOnFirstSetupTab(page) const client = new RuntimeClient(userDataDir, 30_000) const added = await client.call<{ repo: { id: string } }>('repo.add', { path: repoPath, @@ -366,17 +394,41 @@ async function openSetupSplitWorktree({ page, userDataDir }: Journey): Promise { const listed = await client .call('terminal.list', { worktree: `id:${setupWorktreeId!}` }) .catch(() => null) - return listed?.result.terminals.length ?? 0 + hostPtyIds = (listed?.result.terminals ?? []).flatMap((terminal) => + terminal.ptyId ? [terminal.ptyId] : [] + ) + return hostPtyIds.length }) .toBe(2) await activateWorkspaceByClick(page, setupWorktreeId!) await waitForActiveTerminalManager(page) - await waitForBoundPanes(page, 2) + // Main either mints a second tab for the setup terminal or leaves it with no pane at all, so + // wait for every host terminal to have a pane but capture whichever state activation settles in. + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const allBound = await page.evaluate( + ([worktreeId, ptyIds]) => { + const state = window.__store!.getState() + const bound = new Set( + (state.tabsByWorktree[worktreeId] ?? []).flatMap((tab) => + Object.values(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ) + ) + return ptyIds.every((ptyId) => bound.has(ptyId)) + }, + [setupWorktreeId!, hostPtyIds] as const + ) + if (allBound) { + return + } + await page.waitForTimeout(250) + } } /** A slept agent pane, through the user's sidebar Sleep, so the quit and relaunch resume it. */ From d2f6df05ae07e7ced4cc5c936c104790937c7a2a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 21:30:07 -0400 Subject: [PATCH 33/75] fix(terminal): main clears a tab's launched agent and keeps creation fields on window saves (core C7 review) - clearLaunchAgent commit: the window's clear lands in main's home partition, so a push between the clear and the debounced save cannot bring the agent back. - Window saves keep main's creation fields (shared withTopologyRow, also used by the mirror). - A phone's title/expand edits survive a stale pane tree; main's tree stands. - Patches carry only the fields they name. - Retire SSH tombstones without the binding scan: its only reader was deleted. - Delete unused preserveMissingLeafRecordEntries; small simplifications. --- src/main/ipc/session.ts | 15 ++++ ...ence-ssh-lease-tombstone-retention.test.ts | 8 +- .../ssh-pty-lease-tombstone-retention.ts | 57 ++------------ .../terminal-layout-normalization.ts | 14 ---- ...erminal-renderer-presentation-save.test.ts | 77 ++++++++++++++++++- .../terminal-renderer-presentation-save.ts | 39 ++++++---- .../terminal-topology-commit.ts | 20 +++++ ...time-persist-headless-session-tab-props.ts | 7 +- .../mobile-session-tabs-part-06.spec.ts | 23 ++++++ .../runtime/terminal-topology-projection.ts | 2 +- src/preload/api/session-bridge.ts | 2 + src/preload/api/workspace-session-api.ts | 2 + .../terminals/terminal-tab-presentation.ts | 4 + .../terminals/terminal-topology-mirror.ts | 23 +----- .../preload-api/web-workspace-session-api.ts | 2 + src/shared/terminal-topology-tab-row.ts | 28 +++++++ tests/e2e/terminal-layout-parity-snapshot.ts | 6 +- 17 files changed, 214 insertions(+), 115 deletions(-) create mode 100644 src/shared/terminal-topology-tab-row.ts diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 328e9079c529..a6e1140c321b 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -19,6 +19,7 @@ import type { import { sleepingAgentSessionsByPaneKeySchema } from '../../shared/workspace-session-sleeping-agents' import { bindLeaf, + clearLaunchAgent, commitSleepingRecords } from '../persistence/terminal-topology/terminal-topology-commit' import { @@ -101,6 +102,20 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic } ) + // The window saw a tab's launched agent go; main's row drops it so no save or push brings it back. + ipcMain.handle( + 'session:terminal-clear-launch-agent', + (_event, args: { worktreeId?: unknown; tabId?: unknown } | undefined) => { + if (typeof args?.worktreeId !== 'string' || typeof args.tabId !== 'string') { + return + } + const hostId = runtime.getTerminalTopologyHomeHostId(args.worktreeId) + if (hostId && !isFenced(hostId)) { + clearLaunchAgent(store, { worktreeId: args.worktreeId, tabId: args.tabId }, hostId) + } + } + ) + // A gesture's geometry; the reply's publishSeq tells the window when main's push holds it. ipcMain.handle('session:terminal-set-layout', async (_event, args: unknown) => { const request = parseTerminalLayoutSetRequest(args) diff --git a/src/main/persistence-ssh-lease-tombstone-retention.test.ts b/src/main/persistence-ssh-lease-tombstone-retention.test.ts index 6bc860402503..1fe5d67dc40a 100644 --- a/src/main/persistence-ssh-lease-tombstone-retention.test.ts +++ b/src/main/persistence-ssh-lease-tombstone-retention.test.ts @@ -66,15 +66,13 @@ describe('operator-closed SSH lease tombstones', () => { return store } - it('keeps the tombstone while a binding the scrub could not reach still names the pty', async () => { + it('retires the tombstone even while a binding the scrub could not reach still names the pty', async () => { const store = await storeWithDetachedPaneBinding() store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') - // A persisted binding still names the pty, so the row stays. - expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ - expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) - ]) + // No reader tells a terminated row from an absent one, binding or not. + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(store.getWorkspaceSession().terminalLayoutsByTabId['tab-new'].ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' }) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts index 3702576ec4d4..3cae5287de97 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts @@ -3,7 +3,6 @@ import type { SshRemotePtyLease } from '../../../shared/ssh-types' export type SshPtyLeaseTombstoneRetentionOperations = { state: PersistedState - toComparablePtyId: (targetId: string, ptyId: string) => string } /** A routing tombstone with nothing left to route: the operator closed this PTY and no stop is @@ -17,53 +16,15 @@ function isRetiredRoutingTombstone(lease: SshRemotePtyLease, targetId: string): ) } -/** Every stored-form relay pty id some persisted pane binding still names for this target. - * - * Reads all partitions, not only the two `clearSshRemotePtyBindingsForLeases` scrubs: this answer - * authorizes a delete, so a partition left unscanned would be a binding whose tombstone we dropped. - */ -function boundRelayPtyIds( - operations: SshPtyLeaseTombstoneRetentionOperations, - targetId: string -): Set { - const bound = new Set() - const sessions = [ - operations.state.workspaceSession, - ...Object.values(operations.state.workspaceSessionsByHostId ?? {}) - ] - for (const session of sessions) { - if (!session) { - continue - } - for (const tabs of Object.values(session.tabsByWorktree ?? {})) { - for (const tab of tabs) { - if (tab.ptyId) { - bound.add(operations.toComparablePtyId(targetId, tab.ptyId)) - } - } - } - for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { - for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { - bound.add(operations.toComparablePtyId(targetId, ptyId)) - } - } - } - return bound -} - /** - * Deletes the `terminated` rows nothing can reach, bounding an array that otherwise only grew. + * Deletes the `terminated` rows no reader needs, bounding an array that otherwise only grew. * - * `terminated` is written with a binding scrub in the same call, so once no persisted binding names - * the id the row answers no question any reader asks. Reattach refuses it + * The row answers no question any reader asks. Reattach refuses it * (`sshRemotePtyLeaseAllowsReattach`), pane recovery matches on `expired` only, the orphan sweep * already classes it neither routed nor expired, and `ssh:reset` / `ssh:terminateSessions` skip it - * outright — every one of those behaves identically on an absent row. A row a persisted binding still - * names is kept all the same, which is what the reachability test checks. A `pendingKill` is an - * undelivered stop, so those rows stay until the replay retires them. - * - * The reachability test is not redundant with the scrub: a lease freezes its `tabId`, so a pane - * broken out into a new tab leaves a binding the scrub's tab-qualified match no longer reaches. + * outright — every one of those behaves identically on an absent row, whether or not a binding + * still names the pty. A `pendingKill` is an undelivered stop, so those rows stay until the replay + * retires them. * * Does not re-arm the local-worktree-metadata prune gate: a `terminated` lease no longer counts as * a persisted workspace owner, so dropping one cannot make any metadata row more removable. @@ -73,13 +34,7 @@ export function pruneRetiredSshRemotePtyLeaseTombstones( targetId: string ): boolean { const leases = operations.state.sshRemotePtyLeases ?? [] - if (!leases.some((lease) => isRetiredRoutingTombstone(lease, targetId))) { - return false - } - const bound = boundRelayPtyIds(operations, targetId) - const retained = leases.filter( - (lease) => !isRetiredRoutingTombstone(lease, targetId) || bound.has(lease.ptyId) - ) + const retained = leases.filter((lease) => !isRetiredRoutingTombstone(lease, targetId)) if (retained.length === leases.length) { return false } diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts index 551d57e6e35c..232c4b2aa395 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -117,20 +117,6 @@ export function leafRecordEquivalent( return leftEntries.every(([key, value]) => rightRecord[key] === value) } -export function preserveMissingLeafRecordEntries( - priorRecord: Record | undefined, - incomingRecord: Record | undefined, - liveLeafIds: Set -): Record | undefined { - const preserved = Object.fromEntries( - Object.entries(priorRecord ?? {}).filter( - ([leafId]) => liveLeafIds.has(leafId) && incomingRecord?.[leafId] === undefined - ) - ) - const next = { ...preserved, ...incomingRecord } - return Object.keys(next).length > 0 ? next : undefined -} - export function normalizeTerminalLayoutSnapshotForPersistence( snapshot: TerminalLayoutSnapshot, preferredLayout?: TerminalLayoutSnapshot diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts index 45362f779432..8ab736f213ad 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts @@ -5,7 +5,11 @@ import type { Tab } from '../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' -import { mergeRendererPresentationSave } from './terminal-renderer-presentation-save' +import { + mergeRendererPresentationSave, + patchRendererSession +} from './terminal-renderer-presentation-save' +import { clearLaunchAgent } from './terminal-topology-commit' const WORKTREE = 'repo::/worktree' const LEFT = '11111111-1111-4111-8111-111111111111' @@ -170,8 +174,8 @@ describe('mergeRendererPresentationSave', () => { const saved = mergeRendererPresentationSave(window, prior, 'local') expect(saved.tabsByWorktree[WORKTREE]).toEqual([ - // Row fields are the window's (it clears launchAgent when the launching pane closes). row('tab', { + launchAgent: 'codex', ptyId: 'pty-left', title: 'npm test', customTitle: 'Tests', @@ -189,6 +193,75 @@ describe('mergeRendererPresentationSave', () => { expect(saved.unifiedTabs).toEqual(prior.unifiedTabs) }) + it("a save cannot change a tab's creation fields", () => { + const prior = mainSession() + prior.tabsByWorktree[WORKTREE] = [ + row('tab', { launchAgent: 'codex', ptyId: 'pty-left', shellOverride: 'zsh' }) + ] + const window: WorkspaceSessionState = { + ...prior, + tabsByWorktree: { + [WORKTREE]: [ + row('tab', { launchAgent: 'claude', startupCwd: '/elsewhere', customTitle: 'Mine' }) + ] + } + } + + const saved = mergeRendererPresentationSave(window, prior, 'local') + + expect(saved.tabsByWorktree[WORKTREE]).toEqual([ + row('tab', { + launchAgent: 'codex', + ptyId: 'pty-left', + shellOverride: 'zsh', + customTitle: 'Mine' + }) + ]) + }) + + it('a push landing between clearing the launched agent and the save does not bring it back', () => { + let session = mainSession() + const store = { + getWorkspaceSession: () => session, + patchWorkspaceSession: (patch: Partial) => { + session = { ...session, ...patch } + } + } + // The window clears the agent and tells main; a push sent before main's clear then puts it back. + clearLaunchAgent(store, { worktreeId: WORKTREE, tabId: 'tab' }, 'local') + const resurrected = row('tab', { launchAgent: 'codex', ptyId: 'pty-left' }) + + patchRendererSession( + { ...store, setWorkspaceSession: () => {}, stageWorkspaceSessionBeforeUnload: () => {} }, + { tabsByWorktree: { [WORKTREE]: [resurrected] } } + ) + + expect(session.tabsByWorktree[WORKTREE]?.[0]).not.toHaveProperty('launchAgent') + expect(projectTerminalTopologySlice(session, 'local', WORKTREE).tabs[0]).not.toHaveProperty( + 'launchAgent' + ) + }) + + it('a patch carries only the fields it names', () => { + const prior = mainSession() + const patches: Partial[] = [] + patchRendererSession( + { + getWorkspaceSession: () => prior, + patchWorkspaceSession: (patch) => patches.push(patch), + setWorkspaceSession: () => {}, + stageWorkspaceSessionBeforeUnload: () => {} + }, + { activeWorktreeId: WORKTREE, sleepingAgentSessionsByPaneKey: {} } + ) + expect(patches).toEqual([ + { + activeWorktreeId: WORKTREE, + sleepingAgentSessionsByPaneKey: prior.sleepingAgentSessionsByPaneKey + } + ]) + }) + it('a layout naming other panes is stale, so main keeps its whole layout', () => { const prior = mainSession() const saved = mergeRendererPresentationSave( diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts index beefece12f79..0f4362dc159c 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts @@ -5,7 +5,9 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { withTopologyRow } from '../../../shared/terminal-topology-tab-row' import { pruneTabGroupLayoutAfterRetirement } from '../../runtime/mobile-session-terminal-retirement' +import { projectTabRow } from '../../runtime/terminal-topology-projection' import type { Store } from '../loading-store/store' import { resolveHostId } from '../loading-store/session-host-partitions' import { sameTerminalLeafSet } from './terminal-layout-set' @@ -36,7 +38,12 @@ export function patchRendererSession( patch: WorkspaceSessionPatch, hostId?: string | null ): void { - store.patchWorkspaceSession(overMainTopology(store, patch, hostId), hostId) + const merged = overMainTopology(store, patch, hostId) + // A patch carries only the fields it names; main's fields it omits already stand. + store.patchWorkspaceSession( + Object.fromEntries(Object.entries(merged).filter(([key]) => Object.hasOwn(patch, key))), + hostId + ) } export function stageRendererSessionBeforeUnload( @@ -61,8 +68,8 @@ function overMainTopology( /** * A window save brings presentation: titles, colours, order, focus, buffers, tab groups. Main keeps - * what it authors: which tabs and panes exist, their trees and PTY bindings, sleeping records, - * close records and fences. + * what it authors: which tabs and panes exist, their creation fields, trees and PTY bindings, + * sleeping records, close records and fences. */ export function mergeRendererPresentationSave( incoming: T, @@ -116,9 +123,12 @@ function mainRows( return rows } -/** The window's row, except `ptyId`: there it is the live attachment, never main's binding. */ +/** + * Main's row under the window's presentation. The window's `ptyId` is its live attachment, never + * main's binding. + */ function presentationRow(main: TerminalTab, window: TerminalTab | undefined): TerminalTab { - return window ? { ...window, ptyId: main.ptyId } : main + return window ? withTopologyRow(window, projectTabRow(main)) : main } function mainLayouts( @@ -137,7 +147,10 @@ function mainLayouts( Object.entries(window).filter(([tabId]) => !mainHolds(worktreeOfTab.get(tabId))) ) for (const [worktreeId, tabs] of Object.entries(prior.tabsByWorktree)) { - for (const tab of mainHolds(worktreeId) ? tabs : []) { + if (!mainHolds(worktreeId)) { + continue + } + for (const tab of tabs) { const main = prior.terminalLayoutsByTabId?.[tab.id] if (main) { layouts[tab.id] = presentationLayout(main, window[tab.id]) @@ -202,13 +215,13 @@ function placementOfMainTabs( } else { delete tabGroupLayouts[worktreeId] } - if (!validTabIds.has(activeTabIdByWorktree[worktreeId] ?? '')) { - const priorActive = prior.activeTabIdByWorktree?.[worktreeId] - activeTabIdByWorktree[worktreeId] = - (priorActive && validTabIds.has(priorActive) - ? priorActive - : (groups[0]?.activeTabId ?? mainTabs[0]?.id)) ?? null - } + activeTabIdByWorktree[worktreeId] = + [ + activeTabIdByWorktree[worktreeId], + prior.activeTabIdByWorktree?.[worktreeId], + groups[0]?.activeTabId, + mainTabs[0]?.id + ].find((tabId) => tabId != null && validTabIds.has(tabId)) ?? null } return { ...(incoming.unifiedTabs && { unifiedTabs }), diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index b4fe5ec64411..6488aa2a9671 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -93,6 +93,26 @@ export function setLayout( ) } +/** The agent a tab launched is gone, so the tab stops naming it, in `hostId`, the worktree's home. */ +export function clearLaunchAgent( + store: Pick, + request: { worktreeId: string; tabId: string }, + hostId: ExecutionHostId +): void { + const session = store.getWorkspaceSession(hostId) + const tabs = session.tabsByWorktree[request.worktreeId] ?? [] + if (!tabs.some((tab) => tab.id === request.tabId && tab.launchAgent !== undefined)) { + return + } + const cleared = tabs.map((tab) => + tab.id === request.tabId ? { ...tab, launchAgent: undefined } : tab + ) + store.patchWorkspaceSession( + { tabsByWorktree: { ...session.tabsByWorktree, [request.worktreeId]: cleared } }, + hostId + ) +} + /** A window's own sleeping-record changes, each written in its worktree's home partition. */ export function commitSleepingRecords( store: Pick< diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 2d0a28b81edb..7ca89ab8534c 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -131,13 +131,10 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi if (!existing) { return undefined } - // A client edits geometry, focus and titles; a tree with other panes is stale, so main's stands. - if (args.root && !sameTerminalLeafSet(existing.root, args.root)) { - return existing - } const layout = { ...cloneTerminalLayoutSnapshot(existing), - root: args.root ?? existing.root, + // A tree with other panes is stale, so main's stands; the client's focus and titles still apply. + root: args.root && sameTerminalLeafSet(existing.root, args.root) ? args.root : existing.root, expandedLeafId: args.expandedLeafId, ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts index b782105bc97b..73246869f065 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts @@ -295,6 +295,29 @@ describe('OrcaRuntimeService', () => { }) }) + it('keeps a client title and expand edit when its pane tree is stale', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.updateMobileSessionPaneLayout(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: 'leaf-gone' } + }, + expandedLeafId: HEADLESS_LEAF_ID, + titlesByLeafId: { [HEADLESS_LEAF_ID]: 'Pane A' } + }) + + const persisted = getSession().terminalLayoutsByTabId['host-tab']! + expect(persisted.root).toEqual({ type: 'leaf', leafId: HEADLESS_LEAF_ID }) + expect(persisted.expandedLeafId).toBe(HEADLESS_LEAF_ID) + expect(persisted.titlesByLeafId).toEqual({ [HEADLESS_LEAF_ID]: 'Pane A' }) + }) + it('persists headless tab color + pin and surfaces them through a cold rehydrate', async () => { const session = makeWorkspaceSessionWithHeadlessTerminal() const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) diff --git a/src/main/runtime/terminal-topology-projection.ts b/src/main/runtime/terminal-topology-projection.ts index 6c07956ddfc2..e4ad01a8cac9 100644 --- a/src/main/runtime/terminal-topology-projection.ts +++ b/src/main/runtime/terminal-topology-projection.ts @@ -11,7 +11,7 @@ import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' export type UnsequencedTerminalTopologySlice = Omit -function projectTabRow(tab: TerminalTab): TerminalTopologyTabRow { +export function projectTabRow(tab: TerminalTab): TerminalTopologyTabRow { const { launchAgent, defaultTitle, shellOverride, startupCwd, forceHostRuntime } = tab const { quickCommandLabel } = tab return { diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 2d9623bd5407..e1358cba8439 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -13,6 +13,8 @@ export const sessionApi = { ipcRenderer.invoke('session:commit-terminal-sleeping-records', changes), setTerminalLayout: (args) => ipcRenderer.invoke('session:terminal-set-layout', args), bindTerminalLeaf: (args) => ipcRenderer.invoke('session:terminal-bind-leaf', args), + clearTerminalLaunchAgent: (args) => + ipcRenderer.invoke('session:terminal-clear-launch-agent', args), getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), onTerminalTopologyChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 1818e71b7d8f..51ca09b4a7a0 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -48,6 +48,8 @@ export type WorkspaceSessionApi = { bindTerminalLeaf: ( args: TerminalLeafBindRequest ) => Promise + /** Drops a tab's launched agent from main's row once the window sees the agent gone. */ + clearTerminalLaunchAgent: (args: { worktreeId: string; tabId: string }) => Promise /** Sleeping-agent records this window wrote or dropped, committed beside their tabs in main. */ commitTerminalSleepingRecords: (changes: TerminalSleepingRecordChanges) => Promise /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts index 7e1d2f644428..3a5cc5f4190d 100644 --- a/src/renderer/src/store/terminals/terminal-tab-presentation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -139,6 +139,10 @@ export function createTerminalTabPresentationActions( const nextTabs = [...tabs] nextTabs[tabIndex] = tabWithoutLaunchAgent scheduleRuntimeGraphSync() + // Main holds the row; without this its push and the next save bring the agent back. + void globalThis.window?.api?.session + ?.clearTerminalLaunchAgent?.({ worktreeId: ownerWorktreeId, tabId }) + ?.catch((error: unknown) => console.warn('[terminal] launch agent not cleared', error)) return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } }) }, diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 33cd1cad4c45..595d1f3e4567 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -6,6 +6,7 @@ import type { TerminalTopologySlice, TerminalTopologyTabRow } from '../../../../shared/terminal-topology-slice' +import { withTopologyRow } from '../../../../shared/terminal-topology-tab-row' import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { sameStringRecord, terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' import type { AppState } from '../types' @@ -20,32 +21,12 @@ import { terminalStoreReady } from './terminal-pending-panes' -const OPTIONAL_ROW_FIELDS = [ - 'launchAgent', - 'defaultTitle', - 'shellOverride', - 'startupCwd', - 'forceHostRuntime', - 'quickCommandLabel' -] as const satisfies readonly (keyof TerminalTopologyTabRow)[] - -type _UnmirroredRowField = Exclude< - keyof TerminalTopologyTabRow, - 'id' | 'ptyId' | 'worktreeId' | 'createdAt' | (typeof OPTIONAL_ROW_FIELDS)[number] -> -void (true satisfies [_UnmirroredRowField] extends [never] ? true : never) - /** * Main's row fields replace the window's; presentation stays, and so does `ptyId`. In the window it * is the PTY the tab is attached to now (liveness, D1), not main's persisted binding. */ function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { - const next: TerminalTab = { ...current, ...row, ptyId: current.ptyId } - for (const field of OPTIONAL_ROW_FIELDS) { - if (!(field in row)) { - delete next[field] - } - } + const next = withTopologyRow(current, { ...row, ptyId: current.ptyId }) return structuralValuesEqual(next, current) ? current : next } diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 59bee3c5278a..80a9599397e9 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -89,6 +89,8 @@ export function createWebWorkspaceSessionApi(): Partial { setTerminalLayout: async () => ({ status: 'refused', reason: 'tab_not_held' }), // Why refused: web panes attach through the host, which records their bindings. bindTerminalLeaf: async () => ({ status: 'refused', reason: 'not_bound' }), + // Web clients keep launch agents in their saved session. + clearTerminalLaunchAgent: async () => {}, // Why empty: web clients follow the host's session tabs, not main's topology push. getTerminalTopologySlices: async () => [], onTerminalTopologyChanged: () => () => {}, diff --git a/src/shared/terminal-topology-tab-row.ts b/src/shared/terminal-topology-tab-row.ts new file mode 100644 index 000000000000..27866fde6cd4 --- /dev/null +++ b/src/shared/terminal-topology-tab-row.ts @@ -0,0 +1,28 @@ +import type { TerminalTab } from './terminal-tab-types' +import type { TerminalTopologyTabRow } from './terminal-topology-slice' + +const OPTIONAL_ROW_FIELDS = [ + 'launchAgent', + 'defaultTitle', + 'shellOverride', + 'startupCwd', + 'forceHostRuntime', + 'quickCommandLabel' +] as const satisfies readonly (keyof TerminalTopologyTabRow)[] + +type _UnlistedRowField = Exclude< + keyof TerminalTopologyTabRow, + 'id' | 'ptyId' | 'worktreeId' | 'createdAt' | (typeof OPTIONAL_ROW_FIELDS)[number] +> +void (true satisfies [_UnlistedRowField] extends [never] ? true : never) + +/** `tab` with main's row fields; an optional one the row lacks is dropped, not kept from `tab`. */ +export function withTopologyRow(tab: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { + const next: TerminalTab = { ...tab, ...row } + for (const field of OPTIONAL_ROW_FIELDS) { + if (!(field in row)) { + delete next[field] + } + } + return next +} diff --git a/tests/e2e/terminal-layout-parity-snapshot.ts b/tests/e2e/terminal-layout-parity-snapshot.ts index f2be2ca8edb0..86d9afcdebc5 100644 --- a/tests/e2e/terminal-layout-parity-snapshot.ts +++ b/tests/e2e/terminal-layout-parity-snapshot.ts @@ -104,15 +104,15 @@ class IdLabeler { walk(root) while (pending.length > 0) { const ready = pending.filter(([key]) => this.knows(key)) - // Fallback for ids seen only as keys: key shape first (unlabeled ids masked), then raw order, + // Fallback for ids seen only as keys: masked key first (unlabeled ids masked), then raw order, // the one place a label can depend on value. - const shape = (key: string): string => key.replace(new RegExp(ID_SOURCE, 'gi'), '#') + const masked = (key: string): string => key.replace(new RegExp(ID_SOURCE, 'gi'), '#') const next = ready.length > 0 ? ready : [ pending.toSorted( - (a, b) => shape(a[0]).localeCompare(shape(b[0])) || a[0].localeCompare(b[0]) + (a, b) => masked(a[0]).localeCompare(masked(b[0])) || a[0].localeCompare(b[0]) )[0]! ] const rank = (key: string): string => From fba1399330723aafa5139a9afaee15a11c38446c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 21:36:02 -0400 Subject: [PATCH 34/75] fix(terminal): a pane respawned over an absent process keeps its place in main's layout Waking a slept split after a relaunch reattaches each pane to a process the sleep stopped. The host answers absent, and main retired the pane's surface (exit retirement and the persisted owner retirement) before the fresh spawn re-minted it: the tab was rebuilt from main's copy with the split reordered, and with the wake hint gone the agent resumed into an extra tab. Main's copy did this before the mirror too; the window's saves hid it. The absence is now a 'replaced' stop: the fresh spawn takes the pane, so main keeps the pane and its binding until that spawn's bind swaps it, and only the tab row lets go of the gone process, as the window's hydration does. --- src/main/ipc/pty-dead-owner-respawn.test.ts | 4 +- .../pty-pane-reservation-settlement.test.ts | 20 +-- .../pty-persisted-incarnation-repair.test.ts | 6 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 2 + src/main/ipc/pty/pane/stable-owner.ts | 20 +-- ...ble-pane-absence-death-certificate.test.ts | 9 +- ...ble-pane-absent-respawn-keeps-pane.test.ts | 131 ++++++++++++++++++ .../stable-pane-relay-absence-respawn.test.ts | 67 ++++----- .../loading-store/pty-binding-persistence.ts | 5 +- .../loading-store/replaced-pane-binding.ts | 21 ++- .../runtime/terminal-intentional-stops.ts | 2 +- .../ssh-expired-lease-pane-readoption.test.ts | 8 +- 12 files changed, 218 insertions(+), 77 deletions(-) create mode 100644 src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 7a72c0d53b49..3d16b1f20c9d 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -171,8 +171,8 @@ describe('registerPtyHandlers', () => { 'inc-proven-absent-owner', { hostExitConfirmed: true } ) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + // The pane and its binding stay for the fresh spawn's bind to swap. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() }) it('does not poll after the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/probe-blip-owner' diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..ceb05dc03e0e 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -184,16 +184,16 @@ describe('registerPtyHandlers', () => { expect(remoteSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-ssh-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledWith(expect.anything(), hostId) - expect(store.persistPtyBinding).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId, - tabId, - leafId, - ptyId: freshPtyId - }), - hostId - ) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + // The fresh spawn replaces the absent owner, so its bind swaps the binding naming it. + const [input, persistHostId] = store.persistPtyBinding.mock.calls[0] ?? [] + expect(persistHostId).toBe(hostId) + expect(typeof input === 'function' ? input() : input).toMatchObject({ + worktreeId, + tabId, + leafId, + ptyId: freshPtyId + }) } finally { unregisterSshPtyProvider(connectionId) } diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index c057d6ec5d21..5e3e27934dd2 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -350,7 +350,7 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-dead-persisted-owner', command: undefined }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, @@ -374,8 +374,8 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + // The pane and its binding stay for the fresh spawn's bind to swap. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index b6a50c43bbbf..ba151e0f834a 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -69,6 +69,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : null ctx.spawnTiming.mark('stable_adoption') if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { + // The owner's process is absent; this spawn replaces it, so it is not reattached again. + ctx.replacedPaneOwner ??= { ptyId: ctx.earlyStablePaneOwner.ptyId, pane: null } const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) if (pathUsable) { await pathUsable diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 83ebdd50364d..f4e61f0c0381 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -17,6 +17,7 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' +import { releaseTabRowPty } from '../../../persistence/loading-store/replaced-pane-binding' export type StablePaneOwner = { handle?: string @@ -225,7 +226,7 @@ export async function persistAdmittedStablePaneBinding(args: { export async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions } = args + const { owner, provider, runtime, spawnOptions, store, worktreeId, connectionId } = args let result: PtySpawnResult try { result = await provider.spawn({ @@ -265,6 +266,10 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } + // Why 'replaced': the fresh spawn that follows takes the pane, so main keeps the pane and its + // binding until that spawn's bind swaps it, as for a restart. + const stops = runtime?.intentionalPtyStops + const settleReplacement = stops?.mark(owner.ptyId, 'replaced', owner.incarnationId ?? null) // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its // session map never had — every id minted before a relay restart, checked against nothing. Only // the marked half observed the process, so only it may certify a death; the rest publishes the @@ -276,16 +281,13 @@ export async function attachStablePaneOwner( owner.incarnationId, isObservedPtyExitEvidence(error) ? { hostExitConfirmed: true } : {} ) + settleReplacement?.(true) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) - if ( - args.worktreeId && - !(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)) - ) { - throw new Error('terminal_pane_owner_changed') - } - if (args.resolveOwner?.()) { - throw new Error('terminal_pane_owner_changed') + if (worktreeId) { + // The first pane to bind takes the row, as after the window's hydration. + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + await store?.retirePtyBinding?.({ ...owner, worktreeId }, hostId, releaseTabRowPty) } return null } diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 390bcdd451a2..49f7eeff8b32 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -11,7 +11,6 @@ import { describe, expect, it, vi } from 'vitest' import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { SessionNotFoundError } from '../../../daemon/daemon-errors' @@ -23,7 +22,7 @@ import { } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' import { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import { resolvePersistedStablePaneOwner, spawnForStablePane } from './stable-owner' +import { spawnForStablePane } from './stable-owner' const CONNECTION = 'conn-1' const WORKTREE = 'repo-1::/tmp/pane-absence' @@ -137,16 +136,12 @@ describe('a stable pane whose reattach was refused', () => { }) it('still certifies the death the relay proved with a pid probe', async () => { - const { runtime, store, spawn } = await adoptAfterAttachRefusal( + const { runtime, spawn } = await adoptAfterAttachRefusal( new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) ) expect(spawn).toHaveBeenCalledTimes(2) expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) - // If nothing ever retired, a proven-dead pane would reattach to a corpse on every adoption. - expect( - resolvePersistedStablePaneOwner(store, makePaneKey(TAB, LEAF), WORKTREE, CONNECTION) - ).toBeNull() }) it('certifies an absence reported by the process registry that owns the PTY', async () => { diff --git a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts new file mode 100644 index 000000000000..ba7e7e344df1 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts @@ -0,0 +1,131 @@ +// Waking a slept split after a relaunch reattaches each pane to a process the sleep stopped. The +// host answers absent and the pane spawns fresh, so main's copy must keep the pane and its binding +// until the fresh spawn's bind swaps it: dropping the pane lets that spawn re-mint the tab and +// reorder the split, and a pane shown unbound meanwhile reads as gone to the agent resume. +import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { Store } from '../../../persistence' +import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' +import type { IPtyProvider } from '../../../providers/types' +import { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { spawnForStablePane } from './stable-owner' + +const WORKTREE = 'repo-1::/tmp/pane-respawn' +const TAB = 'tab-1' +const SHELL_LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' +const AGENT_LEAF = '2c4d3e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f' +const SHELL_PTY = `${WORKTREE}@@aaaa0001` +const AGENT_PTY = `${WORKTREE}@@aaaa0002` +const SPLIT = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: SHELL_LEAF }, + second: { type: 'leaf', leafId: AGENT_LEAF } +} + +function sleptSplit(): { store: Store; read: () => WorkspaceSessionState } { + let session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [{ id: TAB, worktreeId: WORKTREE, ptyId: SHELL_PTY }] }, + terminalLayoutsByTabId: { + [TAB]: { + root: SPLIT, + activeLeafId: AGENT_LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [SHELL_LEAF]: SHELL_PTY, [AGENT_LEAF]: AGENT_PTY } + } + } + } as unknown as WorkspaceSessionState + return { + read: () => session, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence and catalog reads stable-pane adoption and exit bookkeeping use. + store: withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + // The store's host-side binding retirement, applying the change the caller names. + retirePtyBinding: async ( + ...[binding, , retire = clearReplacedPaneBinding]: Parameters + ) => { + session = retire(session, { ...binding, parentTabId: binding.tabId }) + return true + }, + flushOrThrow: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: '/tmp/pane-respawn', + displayName: 'r', + badgeColor: '#000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + }) as unknown as Store + } +} + +/** The waking window names each pane's slept PTY, as its graph does before the reattach. */ +function wakingRuntime(store: Store): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + hasPty: () => null, + listProcesses: async () => [], + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + runtime.registerPty(SHELL_PTY, WORKTREE, null, { tabId: TAB, leafId: SHELL_LEAF }) + runtime.registerPty(AGENT_PTY, WORKTREE, null, { tabId: TAB, leafId: AGENT_LEAF }) + return runtime +} + +async function respawnOverAbsentProcess( + runtime: OrcaRuntimeService, + store: Store, + leafId: string, + ptyId: string +): Promise { + const spawn = vi + .fn() + .mockRejectedValueOnce(new SessionNotFoundError(ptyId)) + .mockResolvedValueOnce({ id: `${ptyId}-fresh`, isReattach: false }) + const result = await spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: { tabId: TAB, leafId, ptyId, hasPersistedBinding: true }, + worktreeId: WORKTREE, + resolveOwner: () => null + }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(result.owner).toBeNull() +} + +describe('a stable pane respawned over an absent process', () => { + it('keeps the panes and their bindings, releasing only the row of the gone process', async () => { + const { store, read } = sleptSplit() + const before = structuredClone(read()) + const runtime = wakingRuntime(store) + + await respawnOverAbsentProcess(runtime, store, SHELL_LEAF, SHELL_PTY) + await respawnOverAbsentProcess(runtime, store, AGENT_LEAF, AGENT_PTY) + + expect(read().terminalLayoutsByTabId).toEqual(before.terminalLayoutsByTabId) + // The row named the shell's gone process; the first pane to bind takes it. + expect(read().tabsByWorktree[WORKTREE]).toEqual([ + { id: TAB, worktreeId: WORKTREE, ptyId: null } + ]) + }) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index ce3c578cfa03..9ec5988ab763 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -7,6 +7,7 @@ import { SshPtyAbsentFromRelayError } from '../../../providers/ssh-pty-errors' import type { Store } from '../../../persistence' +import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' import type { IPtyProvider } from '../../../providers/types' import { spawnForStablePane, type StablePaneOwner } from './stable-owner' @@ -77,6 +78,13 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, + // The store's host-side binding retirement, applying the change the caller names. + retirePtyBinding: async ( + ...[binding, , retire = clearReplacedPaneBinding]: Parameters + ) => { + session = retire(session, { ...binding, parentTabId: binding.tabId }) + return true + }, flushOrThrow: () => {} }) as unknown as Store } @@ -152,47 +160,30 @@ describe('stable pane adoption after the relay reports the PTY absent', () => { expect(spawn).toHaveBeenCalledTimes(1) }) - // Why a real store: without one the `args.worktreeId` guard short-circuits and the retirement — - // which can delete the parent tab and its layout — never runs. "Reconnect lost every tab" is the - // regression this subsystem was reverted for twice, so the composition needs its own coverage. + // Why a real store: "Reconnect lost every tab" is the regression this subsystem was reverted for + // twice. The fresh spawn takes the pane, so the absence leaves the tab, its layout and the + // bindings for that spawn's bind to swap; only the row lets go of the gone process. describe('with persistence actually reached', () => { - it('retires only the absent leaf and leaves the tab and its sibling bound', async () => { - const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) - const { run, spawn } = spawnAfterAttachRejection( - new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), - { store, worktreeId: WORKTREE } - ) - - const result = await run() - - expect(spawn).toHaveBeenCalledTimes(2) - expect(result.owner).toBeNull() - const session = read() - expect(session.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab-1']) - const layout = session.terminalLayoutsByTabId['tab-1'] - expect(layout).toBeDefined() - expect(layout?.ptyIdsByLeafId?.[SIBLING_LEAF]).toBe('ssh:conn-1@@pty-2') - expect(layout?.ptyIdsByLeafId?.[LEAF]).toBeUndefined() - }) - - // Pins current behaviour rather than blessing it: retiring the LAST leaf drops the tab from - // persistence, and the fallback returns owner=null so main does not re-persist a binding — tab - // survival then rests entirely on the renderer. If that ever regresses, this is the tripwire. - it('drops the tab when the absent leaf was the only one, leaving re-persistence to the renderer', async () => { - const { store, read } = sessionStore([LEAF]) - const { run, spawn } = spawnAfterAttachRejection( - new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), - { store, worktreeId: WORKTREE } - ) + it.each([[[LEAF, SIBLING_LEAF]], [[LEAF]]])( + 'keeps the tab, its panes and their bindings (leaves %j)', + async (leaves) => { + const { store, read } = sessionStore(leaves) + const layouts = structuredClone(read().terminalLayoutsByTabId) + const { run, spawn } = spawnAfterAttachRejection( + new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), + { store, worktreeId: WORKTREE } + ) - const result = await run() + const result = await run() - expect(spawn).toHaveBeenCalledTimes(2) - expect(result.owner).toBeNull() - const session = read() - expect(session.tabsByWorktree[WORKTREE]).toEqual([]) - expect(session.terminalLayoutsByTabId['tab-1']).toBeUndefined() - }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(result.owner).toBeNull() + expect(read().terminalLayoutsByTabId).toEqual(layouts) + expect(read().tabsByWorktree[WORKTREE]).toEqual([ + { id: 'tab-1', worktreeId: WORKTREE, ptyId: null } + ]) + } + ) it('leaves persistence untouched when the failure is not positive absence', async () => { const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 359c55dbed90..cdb0a6380c41 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -82,7 +82,8 @@ export class PtyBindingPersistenceOperations { /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ async retirePtyBinding( binding: Pick, - hostId?: string | null + hostId?: string | null, + retire = clearReplacedPaneBinding ): Promise { const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolved = resolveHostId(hostId) @@ -113,7 +114,7 @@ export class PtyBindingPersistenceOperations { return { value: false, persist: false } } const before = cloneWorkspaceSessionState(session) - const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + const retired = retire(session, { ...binding, parentTabId: binding.tabId }) // Host retirement must not run renderer snapshot repair, which would put the old binding back. publish(retired) const staged = cloneWorkspaceSessionState(retired) diff --git a/src/main/persistence/loading-store/replaced-pane-binding.ts b/src/main/persistence/loading-store/replaced-pane-binding.ts index 5cefc332677f..81f876abf14f 100644 --- a/src/main/persistence/loading-store/replaced-pane-binding.ts +++ b/src/main/persistence/loading-store/replaced-pane-binding.ts @@ -21,12 +21,25 @@ export function clearReplacedPaneBinding( ...session.terminalLayoutsByTabId, [surface.parentTabId]: { ...layout, ptyIdsByLeafId } }, + tabsByWorktree: releaseTabRowPty(session, surface).tabsByWorktree + } +} + +/** A tab row names a live attachment; a process that is gone leaves it for the next bind to take. */ +export function releaseTabRowPty( + session: WorkspaceSessionState, + surface: Pick +): WorkspaceSessionState { + const tabs = session.tabsByWorktree[surface.worktreeId] ?? [] + if (!tabs.some((tab) => tab.id === surface.parentTabId && tab.ptyId === surface.ptyId)) { + return session + } + return { + ...session, tabsByWorktree: { ...session.tabsByWorktree, - [surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) => - tab.id === surface.parentTabId && tab.ptyId === surface.ptyId - ? { ...tab, ptyId: null } - : tab + [surface.worktreeId]: tabs.map((tab) => + tab.id === surface.parentTabId ? { ...tab, ptyId: null } : tab ) } } diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts index b89bc709e5f2..4ee52f772221 100644 --- a/src/main/runtime/terminal-intentional-stops.ts +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -3,7 +3,7 @@ import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/vis /** * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: * - `reversible`: sleep or hibernation; the binding is the wake hint. - * - `replaced`: a restart stops the old process so a new one can take the pane. + * - `replaced`: a new process takes the pane (a restart, or a respawn over an absent one). */ export type TerminalIntentionalStopKind = 'reversible' | 'replaced' diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index 11bf5fe2083d..642d9343425e 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -155,6 +155,12 @@ describe('recovery through createTerminal reattaches before it respawns', () => // A null adoption is exactly what routes createTerminal to a fresh shell, so a pane whose // shell genuinely died still gets a working terminal. await expect(adoptStablePane(undefined, store, ADOPT_ARGS)).resolves.toBeNull() - expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toBeNull() + // The pane keeps its binding for the fresh shell's bind to swap; only the row lets go. + expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toMatchObject({ + ptyId: APP_PTY_ID + }) + expect(store.getWorkspaceSession(`ssh:${TARGET}`).tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBe( + null + ) }) }) From 695f194d2c8fa4a67a455423105802d7e031e976 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 22:13:35 -0400 Subject: [PATCH 35/75] fix(ssh): an export agreement holds only over the connection it was made on After a relay restart, main kept the pre-drop agreement and the matching cached host observation, so the first session write after reconnect exported this desktop's view (the dead PTYs' tabs already retired) before the window's pull ran, and the pull then imported the empty host. An agreement now records the connection it was reached on and exports only while that connection is the target's live one; the reconnect pull agrees anew. The push-status event carries only the revision and observation it ran under. Co-Authored-By: Claude --- src/main/ipc/remote-workspace-export.ts | 38 ++++++++++++++----- .../ipc/remote-workspace-two-desktops.test.ts | 20 ++++++---- src/main/ipc/remote-workspace.test.ts | 16 ++++++++ .../ssh-host-partition-session-export.test.ts | 33 ++++++++-------- 4 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts index 5f942c7fbda1..238243b90095 100644 --- a/src/main/ipc/remote-workspace-export.ts +++ b/src/main/ipc/remote-workspace-export.ts @@ -1,5 +1,6 @@ import type { Store } from '../persistence' import type { SshTarget } from '../../shared/ssh-types' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { RemoteWorkspaceExportAuthority, RemoteWorkspaceObservedPatchResult, @@ -24,8 +25,16 @@ import { } from './remote-workspace-snapshot-cache' import { remoteWorkspaceSessionsMatch } from './remote-workspace-snapshot-normalization' -/** What this desktop and a host agree the host holds; null when the host's copy is unknown. */ -type HostAgreement = RemoteWorkspaceExportAuthority & { session: RemoteWorkspaceSession | null } +/** + * What this desktop and a host agree the host holds; null when the host's copy is unknown. It holds + * only over the connection it was reached on: after a reconnect the host may have moved on, and + * this desktop's own writes in the gap (exits retired by a relay's death) are not the host's news. + */ +type HostAgreement = { + authority: RemoteWorkspaceExportAuthority + session: RemoteWorkspaceSession | null + connection: SshChannelMultiplexer | undefined +} export type RemoteWorkspaceExports = { /** Records a window's pull; the import's own writes must already be in the store. */ @@ -65,20 +74,21 @@ export function createRemoteWorkspaceExports( ): Promise => { if ( agreements.get(target.id) !== agreement || + agreement.connection !== getActiveMultiplexer(target.id) || (agreement.session && remoteWorkspaceSessionsMatch(session, agreement.session)) ) { return } let result: RemoteWorkspaceObservedPatchResult | null try { - result = await patchUnderAgreement(target, session, agreement) + result = await patchUnderAgreement(target, session, agreement.authority) } catch (error) { if (agreements.get(target.id) !== agreement) { return } sendPushStatus({ targetId: target.id, - authority: agreement, + authority: agreement.authority, result: null, error: error instanceof Error ? error.message : 'Workspace upload failed' }) @@ -90,12 +100,16 @@ export function createRemoteWorkspaceExports( } if (result?.ok) { const { revision, hostObservationToken } = result.snapshot - agreements.set(target.id, { revision, hostObservationToken, session }) + agreements.set(target.id, { + ...agreement, + authority: { revision, hostObservationToken }, + session + }) } else if (result?.reason === 'stale-revision') { // The host moved on; the window's next pull agrees again. agreements.delete(target.id) } - sendPushStatus({ targetId: target.id, authority: agreement, result }) + sendPushStatus({ targetId: target.id, authority: agreement.authority, result }) } const exportAll = async (): Promise => { @@ -149,7 +163,11 @@ export function createRemoteWorkspaceExports( outcome === 'kept-local' ? (getCachedRemoteWorkspaceSnapshot(targetId)?.session ?? null) : sessionForTarget(readWorktreeOwners(), targetId) - agreements.set(targetId, { revision, hostObservationToken, session }) + agreements.set(targetId, { + authority: { revision, hostObservationToken }, + session, + connection: getActiveMultiplexer(targetId) + }) exportChanged() } @@ -160,13 +178,13 @@ export function createRemoteWorkspaceExports( async function patchUnderAgreement( target: SshTarget, session: RemoteWorkspaceSession, - agreement: HostAgreement + authority: RemoteWorkspaceExportAuthority ): Promise { const current = getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) if ( !current || - current.hostObservationToken !== agreement.hostObservationToken || - !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, agreement.revision) + current.hostObservationToken !== authority.hostObservationToken || + !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, authority.revision) ) { const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current return latest ? { ok: false, reason: 'stale-revision', snapshot: latest } : null diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts index 365e55306a42..c4acceaa2f9f 100644 --- a/src/main/ipc/remote-workspace-two-desktops.test.ts +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -73,12 +73,9 @@ vi.mock('electron', () => ({ vi.mock('./remote-workspace-events', () => ({ registerRemoteWorkspaceNotificationHandler: () => () => {} })) -vi.mock('./ssh', () => ({ - getSshConnectionStore: () => ({ - listTargets: () => [TARGET], - getTarget: () => TARGET - }), - getActiveMultiplexer: () => ({ +vi.mock('./ssh', () => { + // One connection for the whole suite: an agreement holds only over the connection it was made on. + const connection = { request: async (method: string, { baseRevision, clientId, patch }: RelayPatchParams) => { if (method === 'workspace.get') { return relaySnapshot() @@ -99,8 +96,15 @@ vi.mock('./ssh', () => ({ } return { ok: true, snapshot } } - }) -})) + } + return { + getSshConnectionStore: () => ({ + listTargets: () => [TARGET], + getTarget: () => TARGET + }), + getActiveMultiplexer: () => connection + } +}) type Desktop = { driver: ExportDriverModule.RemoteWorkspaceExportDriver diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index c96421967bd0..f879daf0cf14 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -442,6 +442,22 @@ describe('main exports a session write to the hosts it agrees with', () => { expect(patchRequests('target-1')).toHaveLength(2) }) + it('exports nothing over a new connection until a pull on it agrees', async () => { + // A relay restart retires the dead PTYs' tabs here before the window re-pulls; exporting + // that under the old connection's agreement erased the tabs the pull would have restored. + await agreeWith('target-1') + muxByTargetId.delete('target-1') + + driver.write(sessionWithTab) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(patchRequests('target-1')).toEqual([]) + + await agreeWith('target-1') + driver.write({ ...sessionWithTab, activeTabId: null }) + await driver.nextPushes() + expect(patchRequests('target-1')).toHaveLength(1) + }) + it('reports nothing for an export a newer pull superseded', async () => { const observed = await observeTarget('target-1') driver.agree('target-1', observed) diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts index bf5ff494392a..5c3fd253fd68 100644 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ b/src/main/ipc/ssh-host-partition-session-export.test.ts @@ -137,24 +137,23 @@ beforeEach(() => { [target, otherTarget].find((candidate) => candidate.id === targetId) }) getActiveMultiplexerMock.mockReset() + const connection = { + request: (method: string, params: Record) => { + if (method === 'workspace.get') { + return Promise.resolve(hostSnapshot) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. + const patch = params.patch as { session: RemoteWorkspaceSession } + hostSnapshot = { + ...hostSnapshot, + revision: hostSnapshot.revision + 1, + session: patch.session + } + return Promise.resolve({ ok: true, snapshot: hostSnapshot }) + } + } getActiveMultiplexerMock.mockImplementation((targetId: string) => - targetId === TARGET_ID - ? { - request: (method: string, params: Record) => { - if (method === 'workspace.get') { - return Promise.resolve(hostSnapshot) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. - const patch = params.patch as { session: RemoteWorkspaceSession } - hostSnapshot = { - ...hostSnapshot, - revision: hostSnapshot.revision + 1, - session: patch.session - } - return Promise.resolve({ ok: true, snapshot: hostSnapshot }) - } - } - : undefined + targetId === TARGET_ID ? connection : undefined ) }) From 629b47e9b075344852021962cf122a3c237ace3f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 22:13:36 -0400 Subject: [PATCH 36/75] fix(relay): a shutting-down relay records no workspace patch A relay's shutdown kills its PTYs and reports their exits; main retires those panes and exports immediately, so the patch could land on the dying relay and erase the tabs every client restores from its snapshot after reconnecting. The workspace store refuses patches from the start of shutdown (answering unavailable) and reopens if the shutdown defers. Co-Authored-By: Claude --- ...lay-runtime-owned-process-shutdown.test.ts | 1 + src/relay/relay-runtime-services.ts | 9 ++++-- src/relay/workspace-session-handler.test.ts | 32 ++++++++++++++++++- src/relay/workspace-session-handler.ts | 13 ++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts index 6cdef3ed1fce..97303d1897d9 100644 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ b/src/relay/relay-runtime-owned-process-shutdown.test.ts @@ -13,6 +13,7 @@ function fixture() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shutdown paths only read the owners stubbed here. const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { agentExecHandler: { dispose: agents }, + workspaceSessionHandler: { close: vi.fn() }, skillInstallHandler: { dispose: skill }, aiVaultService: { dispose: vault }, responseStreams: { disposeAllAndWait: responses }, diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index 1325e792d078..6b8a923410d6 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -33,6 +33,7 @@ export class RelayRuntimeServices { readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler readonly agentExecHandler: AgentExecHandler + private readonly workspaceSessionHandler: WorkspaceSessionHandler private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null @@ -81,7 +82,7 @@ export class RelayRuntimeServices { const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) const portScanHandler = new PortScanHandler(dispatcher) this.agentExecHandler = new AgentExecHandler(dispatcher) - const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) + this.workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null @@ -107,7 +108,7 @@ export class RelayRuntimeServices { externalAutomationsHandler, portScanHandler, this.agentExecHandler, - workspaceSessionHandler, + this.workspaceSessionHandler, new AiVaultHandler(dispatcher, { hostPlatform, service: this.aiVaultService ?? undefined @@ -125,6 +126,9 @@ export class RelayRuntimeServices { // Why: answering a stream's request does not prove its detached pumps, descriptors or children // are gone; only these registry drains do. async disposeOwnedProcesses(): Promise { + // The PTY teardown that follows retires every pane on each client; recording that here would + // erase the tabs the clients restore from this snapshot once they reconnect. + this.workspaceSessionHandler.close() const owned = await Promise.allSettled([ this.agentExecHandler.dispose(), this.responseStreams.disposeAllAndWait(), @@ -158,6 +162,7 @@ export class RelayRuntimeServices { } reopenOwnedProcesses(): void { + this.workspaceSessionHandler.reopen() this.agentExecHandler.reopen() this.responseStreams.reopen() this.fsHandler.reopen() diff --git a/src/relay/workspace-session-handler.test.ts b/src/relay/workspace-session-handler.test.ts index 7c3917e9c2a5..38985b1a9337 100644 --- a/src/relay/workspace-session-handler.test.ts +++ b/src/relay/workspace-session-handler.test.ts @@ -35,6 +35,7 @@ describe('WorkspaceSessionHandler', () => { let baseDir: string let dispatcher: RelayDispatcher let written: Buffer[] + let handler: WorkspaceSessionHandler beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), 'orca-workspace-session-')) @@ -42,7 +43,7 @@ describe('WorkspaceSessionHandler', () => { dispatcher = new RelayDispatcher((data) => { written.push(Buffer.from(data)) }) - new WorkspaceSessionHandler(dispatcher, baseDir) + handler = new WorkspaceSessionHandler(dispatcher, baseDir) }) afterEach(() => { @@ -50,6 +51,35 @@ describe('WorkspaceSessionHandler', () => { rmSync(baseDir, { recursive: true, force: true }) }) + it('records no patch while the relay shuts down, and records again once it reopens', async () => { + // A shutting-down relay kills its PTYs; the clients' retirement of those panes must not erase + // the tabs they restore from this snapshot on reconnect. + const patch = (id: number): Promise => + sendRequest( + dispatcher, + 'workspace.patch', + { + namespace: 'ns', + baseRevision: 0, + clientId: 'client-a', + patch: { kind: 'replace-session', session: { tabsByWorktreePath: {} } } + }, + id + ) + const response = (id: number): unknown => + decodeJsonFrames(written).find( + (frame) => typeof frame === 'object' && frame !== null && 'id' in frame && frame.id === id + ) + + handler.close() + await patch(1) + expect(response(1)).toMatchObject({ result: { ok: false, reason: 'unavailable' } }) + + handler.reopen() + await patch(2) + expect(response(2)).toMatchObject({ result: { ok: true, snapshot: { revision: 1 } } }) + }) + it('stores snapshots atomically and rejects stale revisions', async () => { const session = { activeWorktreePath: '/repo/worktree', diff --git a/src/relay/workspace-session-handler.ts b/src/relay/workspace-session-handler.ts index f9b3ef2e5077..f4dcbf7b5ed6 100644 --- a/src/relay/workspace-session-handler.ts +++ b/src/relay/workspace-session-handler.ts @@ -55,6 +55,7 @@ function sanitizeClientId(value: string): string { export class WorkspaceSessionHandler { private readonly clientsByNamespace = new Map>() + private closed = false constructor( private dispatcher: RelayDispatcher, @@ -125,7 +126,19 @@ export class WorkspaceSessionHandler { return this.read(sanitizeNamespace(params.namespace)) } + /** Refuses patches while the relay shuts down; a deferred shutdown reopens. */ + close(): void { + this.closed = true + } + + reopen(): void { + this.closed = false + } + private async patch(params: Record): Promise { + if (this.closed) { + return { ok: false, reason: 'unavailable', message: 'Relay is shutting down' } + } const namespace = sanitizeNamespace(params.namespace) const current = this.read(namespace) const baseRevision = Number(params.baseRevision) From 197646f8ae10165c93f42f0a70148becd03fc1f4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 22:50:58 -0400 Subject: [PATCH 37/75] fix(ssh): a window's import writes only its target's partition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSH import sent the merge split across every partition, and main patched each one straight into the store, so a pull could overwrite local and other-host rows with the window's copy — tabs main hadn't created, and main writes the window hadn't received yet rolled back. The window now sends only the `ssh:` slice, and main commits it through the topology commit module (`importPeerTopology`, traced like the other commits) into that partition alone. Presentation fields of other worktrees reach main through the window's normal save, as before. Co-Authored-By: Claude --- .../remote-workspace-export-test-harness.ts | 2 +- .../ipc/remote-workspace-two-desktops.test.ts | 2 +- src/main/ipc/remote-workspace.test.ts | 8 ++--- src/main/ipc/remote-workspace.ts | 16 +++++---- .../ssh-host-partition-session-export.test.ts | 35 ++++++++++++++++++- ...terminal-topology-boundary-ratchet.test.ts | 6 +--- .../terminal-topology-commit.test.ts | 18 +++++++++- .../terminal-topology-commit.ts | 34 ++++++++++++++++-- .../hooks/remote-workspace-snapshot-apply.ts | 6 +++- ...e-workspace-snapshot-import-commit.test.ts | 33 ++++++++++++++--- .../remote-workspace-target-sync.test.ts | 2 +- .../src/hooks/remote-workspace-target-sync.ts | 2 +- src/shared/remote-workspace-types.ts | 5 ++- 13 files changed, 136 insertions(+), 33 deletions(-) diff --git a/src/main/ipc/remote-workspace-export-test-harness.ts b/src/main/ipc/remote-workspace-export-test-harness.ts index 54ca2d424611..ba592a07025b 100644 --- a/src/main/ipc/remote-workspace-export-test-harness.ts +++ b/src/main/ipc/remote-workspace-export-test-harness.ts @@ -101,7 +101,7 @@ export function createRemoteWorkspaceExportDriver( handlers, pushes, agree: (targetId, authority, outcome = 'synced') => - importPeer({ targetId, ...authority, outcome, patches: [] }), + importPeer({ targetId, ...authority, outcome, session: {} }), importPeer, write: (patch) => fakeStore.patchWorkspaceSession(patch), readSession: () => session, diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts index c4acceaa2f9f..c2a9618576eb 100644 --- a/src/main/ipc/remote-workspace-two-desktops.test.ts +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -200,7 +200,7 @@ async function bootDesktop(): Promise { revision: event.snapshot.revision, hostObservationToken: event.snapshot.hostObservationToken, outcome: desktop.conflicted ? 'conflict' : keptTabs.length > 0 ? 'kept-local' : 'synced', - patches: [{ hostId: `ssh:${TARGET.id}`, patch }] + session: patch }) desktop.driver.write(patch) } diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index f879daf0cf14..f1d83f3a7424 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -339,14 +339,14 @@ describe('main exports a session write to the hosts it agrees with', () => { revision: -1, hostObservationToken: 'token', outcome: 'synced', - patches: [] + session: {} }) driver.importPeer({ targetId: 'target-1', revision: 7, hostObservationToken: '', outcome: 'synced', - patches: [] + session: {} }) driver.write(sessionWithTab) @@ -414,7 +414,7 @@ describe('main exports a session write to the hosts it agrees with', () => { ...observed, targetId: 'target-1', outcome: 'synced', - patches: [{ patch: sessionWithTab }] + session: sessionWithTab }) // The window saves back what it applied from the import. driver.write(sessionWithTab) @@ -472,7 +472,7 @@ describe('main exports a session write to the hosts it agrees with', () => { driver.write(sessionWithTab) await vi.waitFor(() => expect(patchRequests('target-1')).toHaveLength(1)) - driver.importPeer({ ...observed, targetId: 'target-1', outcome: 'synced', patches: [] }) + driver.importPeer({ ...observed, targetId: 'target-1', outcome: 'synced', session: {} }) finishPatch({ ok: false, reason: 'stale-revision', snapshot: snapshot(emptyRemoteSession, 9) }) await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index fc91bfe2121c..2ca5b9c38885 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -9,7 +9,9 @@ import { type RemoteWorkspacePeerImport, type RemoteWorkspacePushStatusEvent } from '../../shared/remote-workspace-types' +import { toSshExecutionHostId } from '../../shared/execution-host' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' +import { importPeerTopology } from '../persistence/terminal-topology/terminal-topology-commit' import { createRemoteWorkspaceExports } from './remote-workspace-export' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' @@ -63,7 +65,9 @@ function isValidPeerImport(pull: RemoteWorkspacePeerImport): boolean { pull.hostObservationToken.length > 0 && pull.hostObservationToken.length <= 128 && ['synced', 'kept-local', 'conflict'].includes(pull.outcome) && - Array.isArray(pull.patches) + typeof pull.session === 'object' && + pull.session !== null && + !Array.isArray(pull.session) ) } @@ -152,18 +156,16 @@ export function registerRemoteWorkspaceHandlers( return getRemoteSnapshot(target) }) - // The window imports a host's snapshot through main, so the import's writes are main's commit - // and the agreement they set keeps them from exporting back. + // The window imports a host's snapshot through main: only that host's partition is written, and + // the agreement it sets keeps the import from exporting back. ipcMain.handle( 'remoteWorkspace:importPeerTopology', (_event, pull: RemoteWorkspacePeerImport | undefined) => { if (!pull || !isValidPeerImport(pull)) { return } - for (const { hostId, patch } of pull.patches) { - if (!isFrozenOrcadSourceSessionPartition(store, hostId)) { - store.patchWorkspaceSession(patch, hostId) - } + if (!isFrozenOrcadSourceSessionPartition(store, toSshExecutionHostId(pull.targetId))) { + importPeerTopology(store, pull.targetId, pull.session) } exports.recordPull(pull) } diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts index 5c3fd253fd68..050d9281b2f6 100644 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ b/src/main/ipc/ssh-host-partition-session-export.test.ts @@ -202,7 +202,7 @@ async function publishRuntimeWrite(store: InstanceType): Promise { expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([]) }) }) + +describe('a window import writes only its target partition', () => { + it('cannot change local or other-host rows, nor roll back a main write not yet mirrored', async () => { + const store = createStrandedStore() + registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) + // Main's own local write, which the window has not received yet. + const localWorktreeId = 'repo-local::/home/me/proj' + const localTab = { ...runtimeAuthoredTab(), id: 'tab-main', worktreeId: localWorktreeId } + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + tabsByWorktree: { + ...store.getWorkspaceSession().tabsByWorktree, + [localWorktreeId]: [localTab] + } + }) + const localBefore = store.getWorkspaceSession() + const otherBefore = store.getWorkspaceSession(OTHER_SSH_HOST_ID) + + await ipcHandlers.get('remoteWorkspace:importPeerTopology')?.(null, { + targetId: TARGET_ID, + revision: 4, + hostObservationToken: 'observation-4', + outcome: 'synced', + session: { tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } } + }) + + expect(store.getWorkspaceSession()).toEqual(localBefore) + expect(store.getWorkspaceSession(OTHER_SSH_HOST_ID)).toEqual(otherBefore) + expect( + store.getWorkspaceSession(SSH_HOST_ID).tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id) + ).toEqual(['tab-runtime']) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts index 6750eae5a214..575a6a319066 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -50,11 +50,7 @@ const ALLOWED_REFERENCES: Record = { setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], // The runtime's session controller, reachable from every OrcaRuntime mixin. setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], - patchWorkspaceSession: [ - // A window's SSH snapshot import, committed with the pull it came from. - 'ipc/remote-workspace.ts', - 'ipc/session.ts' - ], + patchWorkspaceSession: ['ipc/session.ts'], stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], setWorkspaceSessionForWorktree: [ // Headless editor-tab retirement, like the headless mobile-session tab writers below. diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts index 8b086c56e394..75df4b8371a5 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -4,7 +4,7 @@ import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surfac import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' -import { closeLeafOrTab } from './terminal-topology-commit' +import { closeLeafOrTab, importPeerTopology } from './terminal-topology-commit' // An ssh: partition, so a leaked host id or worktree path would show in the span. const HOST_ID: ExecutionHostId = 'ssh:target-1' @@ -149,4 +149,20 @@ describe('persistence.terminal-topology span', () => { expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' }) expect(records[0].exit).toMatchObject({ _tag: 'Failure' }) }) + + it("commits an SSH import into that target's partition alone", () => { + const patchWorkspaceSession = vi.fn() + importPeerTopology({ patchWorkspaceSession }, 'target-1', { tabsByWorktree: {} }) + importPeerTopology({ patchWorkspaceSession }, 'target-1', {}) + + expect(patchWorkspaceSession.mock.calls).toEqual([[{ tabsByWorktree: {} }, HOST_ID]]) + expect(records.map((record) => record.attributes)).toEqual([ + { + kind: 'persistence', + 'topology.kind': 'import_peer_topology', + 'topology.outcome': 'committed' + }, + { kind: 'persistence', 'topology.kind': 'import_peer_topology', 'topology.outcome': 'noop' } + ]) + }) }) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 741a723a9fb8..0089f7e27315 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -1,5 +1,5 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { ExecutionHostId } from '../../../shared/execution-host' +import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { structuralValuesEqual } from '../../../shared/structural-value-equality' @@ -12,7 +12,10 @@ import type { TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' import type { TerminalLeafBindRequest } from '../../../shared/terminal-leaf-bind' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' import type { PtyBindingPersistenceOperations } from '../loading-store/pty-binding-persistence' import { startSpan } from '../../observability/tracer' import { @@ -20,6 +23,7 @@ import { type TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import type { Store } from '../loading-store/store' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { planTerminalLayoutSet } from './terminal-layout-set' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' @@ -29,7 +33,12 @@ import { assignWorkspaceSessionPartition } from './terminal-topology-membership' // move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' | 'set_layout' +type TerminalTopologyCommitKind = + | 'close_leaf' + | 'close_tab' + | 'move_leaf' + | 'set_layout' + | 'import_peer_topology' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -91,6 +100,25 @@ export function setLayout( ) } +/** A window's pull from an SSH host, written only to that host's partition, its worktrees' home. */ +export function importPeerTopology( + store: Pick, + targetId: string, + session: WorkspaceSessionPatch +): void { + traced( + 'import_peer_topology', + () => { + if (Object.keys(session).length === 0) { + return { value: undefined, persist: false } + } + store.patchWorkspaceSession(session, toSshExecutionHostId(targetId)) + return { value: undefined } + }, + () => undefined + )() +} + /** A sleeping agent's record lands beside its tab, in its worktree's home partition. */ export function sleepLeaf( session: WorkspaceSessionState, diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index aca9f758e77f..cf51b3c71ab4 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -261,7 +261,11 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ : mergeKeptLocalState(merged, remoteSession, worktreeIds, localLayoutTabIds) ? 'kept-local' : 'synced', - patches: buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore) + // Only the target's partition: the merge carries the window's copy of every other host's rows. + session: + buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore).find( + ({ hostId }) => hostId === toSshExecutionHostId(authority.targetId) + )?.patch ?? {} }).catch((error: unknown) => console.warn('[remote-workspace] import commit failed:', error)) currentStore.hydrateWorkspaceSession(merged, { directSshAuthority: authority, diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts index 94e13bd49529..fae38c13587c 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts @@ -136,8 +136,7 @@ describe('direct SSH snapshot import commit', () => { hostObservationToken: 'observation-6', outcome: 'synced' }) - const sshPatch = pull.patches.find(({ hostId }) => hostId === `ssh:${TARGET_ID}`)?.patch - expect(sshPatch?.tabsByWorktree?.[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['host-tab']) + expect(pull.session.tabsByWorktree?.[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['host-tab']) // Only what the merge rewrote; editor and browser state stay the window's own writes. const mergedFields = new Set([ 'activeRepoId', @@ -152,9 +151,35 @@ describe('direct SSH snapshot import commit', () => { 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId' ]) - for (const { patch } of pull.patches) { - expect(Object.keys(patch).filter((field) => !mergedFields.has(field))).toEqual([]) + expect(Object.keys(pull.session).filter((field) => !mergedFields.has(field))).toEqual([]) + }) + + it("sends none of the window's rows for other partitions", async () => { + const store = createTestStore() + seedCatalog(store) + // The window's copy of a local worktree; main may already hold a newer one. + const localWorktreeId = 'repoL::/home/me/proj' + const localTab = { + id: 'local-tab', + ptyId: 'pty-local', + worktreeId: localWorktreeId, + title: 'local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 } + store.setState({ + tabsByWorktree: { [localWorktreeId]: [localTab] }, + terminalLayoutsByTabId: { + [localTab.id]: { root: null, activeLeafId: null, expandedLeafId: null } + } + }) + + const pull = await importOf(store, snapshot(6, ['host-tab'])) + + expect(Object.keys(pull.session.tabsByWorktree ?? {})).toEqual([WORKTREE_ID]) + expect(pull.session.terminalLayoutsByTabId ?? {}).not.toHaveProperty(localTab.id) }) it('reports kept-local when the merge keeps a tab the host has not seen', async () => { diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 8925c9263283..b89f086a3768 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -34,7 +34,7 @@ describe('createRemoteWorkspaceTargetSync', () => { revision: 0, hostObservationToken: snapshot(0).hostObservationToken, outcome: 'kept-local', - patches: [] + session: {} }) }) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 75f67e50fc37..051e9041f684 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -198,7 +198,7 @@ export function createRemoteWorkspaceTargetSync( revision: snapshot.revision, hostObservationToken: snapshot.hostObservationToken, outcome: 'kept-local', - patches: [] + session: {} }) } diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 4e5c3ce8dcb8..fa1718407790 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,4 +1,3 @@ -import type { ExecutionHostId } from './execution-host' import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' import type { WorkspaceSessionPatch } from './workspace-session-state-types' @@ -82,8 +81,8 @@ export type RemoteWorkspacePeerImport = RemoteWorkspaceExportAuthority & { * not place every host tab, so this desktop must not export. */ outcome: 'synced' | 'kept-local' | 'conflict' - /** The import's session patch per owning partition; empty when the host had nothing to import. */ - patches: { hostId?: ExecutionHostId; patch: WorkspaceSessionPatch }[] + /** The import's patch to the target's own `ssh:` partition; empty when the host had nothing. */ + session: WorkspaceSessionPatch } /** Main's report of one export it ran, for the window's sync status. */ From 0a92a896c64c82a4fa42db195004ee109ffe7975 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 22:57:14 -0400 Subject: [PATCH 38/75] fix(terminal): hold pending entries only for tabs main's push settles A web client's tabs and a paired Orca server's (`runtime:`) worktree tabs come from the host's snapshot, not main's topology push, so nothing ever settled a pending tab add made there, and a close there waited on a push main never sends. One rule now decides it: isTerminalTabMirroredFromMain (not a web client, not a runtime-owned worktree, not a runtime-hosted tab) gates the tab add, the pane add, and the mirror's keep. Main's reply names no publishSeq for a worktree it publishes no slice for, so such a close settles on the reply. A pane split in a host tab is still kept until the host lists it: the host-snapshot sync retains a leaf the window's layout names and the host cannot resolve yet, so the layout keeps naming it (regression test added). --- src/main/runtime/orca-runtime.ts | 2 +- .../terminal-topology-publisher.test.ts | 11 +++- .../runtime/terminal-topology-publisher.ts | 9 ++- ...ssion-split-pane-retirement-fences.test.ts | 55 +++++++++++++++++++ .../terminals/terminal-pending-panes.test.ts | 18 +++++- .../store/terminals/terminal-pending-panes.ts | 21 +++++-- .../store/terminals/terminal-tab-creation.ts | 12 ++-- .../terminals/terminal-topology-mirror.ts | 6 +- 8 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f04b4b49f6c1..e49360f797f2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -39,7 +39,7 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { } /** The publishSeq of the push holding every topology write made so far, for a reply to name. */ - settleTerminalTopology(worktreeId?: string): number { + settleTerminalTopology(worktreeId?: string): number | undefined { return this.terminalTopology.settle(worktreeId) } diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index 7a048f3f92a2..91cf71111182 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -171,16 +171,23 @@ describe('TerminalTopologyPublisher', () => { expect(h.publisher.settle()).toBe(seq) }) + it('names no push for a worktree it publishes no slice for', () => { + const h = harness(sessionWith([WT])) + + expect(h.publisher.settle('repo::/hosted-elsewhere')).toBeUndefined() + }) + it('keeps publishSeq monotonic across pushes', async () => { const h = harness(sessionWith([WT])) - const seqs: number[] = [h.publisher.settle(WT)] + const settle = (): number => h.publisher.settle(WT) ?? Number.NaN + const seqs = [settle()] for (const ptyId of ['a', 'b', 'c']) { const next = structuredClone(h.session) next.tabsByWorktree[WT]![0]!.ptyId = ptyId h.replace(next) h.publisher.markDirty() await Promise.resolve() - seqs.push(h.publisher.settle(WT)) + seqs.push(settle()) } expect(seqs).toEqual([...seqs].sort((a, b) => a - b)) diff --git a/src/main/runtime/terminal-topology-publisher.ts b/src/main/runtime/terminal-topology-publisher.ts index edc9fcb0a676..7a4ce00e0a20 100644 --- a/src/main/runtime/terminal-topology-publisher.ts +++ b/src/main/runtime/terminal-topology-publisher.ts @@ -54,10 +54,13 @@ export class TerminalTopologyPublisher { } } - /** A publishSeq whose push includes every write made before this call. */ - settle(worktreeId?: string): number { + /** + * A publishSeq whose push includes every write made before this call; none for a worktree main + * publishes no slice for, since no push will ever carry it. + */ + settle(worktreeId?: string): number | undefined { this.flush() - return (worktreeId ? this.published.get(worktreeId)?.publishSeq : undefined) ?? this.lastSeq + return worktreeId === undefined ? this.lastSeq : this.published.get(worktreeId)?.publishSeq } /** Every current slice, for a window that loaded after pushes it never saw. */ diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts index 618200c4f8fa..b9292deed4d5 100644 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts @@ -141,3 +141,58 @@ describe('host snapshot fences before split-pane retirement', () => { function retiredSnapshotWithVersion(snapshotVersion: number): RuntimeMobileSessionTabsResult { return { ...retiredSnapshot(), snapshotVersion } } + +describe('a host snapshot that does not list a pane split here yet', () => { + beforeEach(() => { + resetWebSessionTabsSyncTestState() + clearWebSessionTerminalOrphanRecoveryForTests() + }) + + it('keeps the pane while the host has not recorded it', async () => { + let state = makeState() + // The host answers for a pane it has not recorded yet. + const call = vi.fn(async () => ({ + id: 'resolve', + ok: false as const, + error: { code: 'terminal_not_found', message: 'terminal_not_found' } + })) + const receive = async (incoming: RuntimeMobileSessionTabsResult): Promise => { + const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, incoming, ENV, { + call + }) + expect(recovered).not.toBeNull() + if (recovered) { + state = { ...state, ...applyFreshWebSessionTabsSnapshot(state, recovered, ENV) } + } + } + await receive(snapshot(2, [LEAF_ID])) + // The split writes the window's tree before any later host frame can apply. + state = { + ...state, + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [TAB_ID]: { + ...state.terminalLayoutsByTabId[TAB_ID], + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + } + } + } + } + const later = snapshot(3, [LEAF_ID]) + await receive({ ...later, tabs: later.tabs.map((tab) => ({ ...tab, title: 'vim' })) }) + + expect(call).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.resolvePane' })) + expect(collectLeafIds(state.terminalLayoutsByTabId[TAB_ID].root!)).toEqual(mountedLeaves) + expect( + planTerminalLiveLayoutRemovals( + state.terminalLayoutsByTabId[TAB_ID].root, + mountedLeaves, + new Set() + ) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts index ebd8dd18cc29..f0ccd701f76f 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -5,7 +5,8 @@ import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' -import { pendingTerminalLeafIds } from './terminal-pending-panes' +import { markTerminalPaneIfAheadOfMain, pendingTerminalLeafIds } from './terminal-pending-panes' +import { makeWorktree } from '../slices/store-test-helpers' const WT = 'repo::/wt' const LEAF_A = '11111111-1111-4111-8111-111111111111' @@ -99,6 +100,21 @@ describe('a tab this window creates', () => { }) }) +describe('a worktree a paired Orca server hosts', () => { + // Main publishes no slice for it, so nothing would ever settle an entry held here. + it('holds no pending entry for a tab or pane made here', () => { + useAppStore.setState({ + worktreesByRepo: { + repo: [makeWorktree({ id: WT, repoId: 'repo', hostId: 'runtime:env-1' })] + } + }) + const tab = state().createTab(WT) + markTerminalPaneIfAheadOfMain(state(), { worktreeId: WT, tabId: tab.id, leafId: LEAF_B }) + + expect(state().pendingTerminalPanes).toEqual([]) + }) +}) + describe('a tab this window closes', () => { it.each([ ['the reply', true], diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index cf0164f00d39..a3404df871e6 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -1,6 +1,8 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import { collectLeafIds } from '@/components/terminal-pane/terminal-pane-layout-tree' +import { isWebClientLocation } from '@/lib/web-client-location' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '../types' import type { TerminalSlice, TerminalStoreSet } from './terminal-state' @@ -64,12 +66,23 @@ export function pendingTerminalLeafIds( ) } -/** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ -export function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { +/** + * Main's slices carry only local and SSH worktrees. A web client's tabs and a `runtime:` host's + * come from the host's snapshot, which never settles a pending entry, so they hold none. + */ +export function isTerminalTabMirroredFromMain( + state: AppState, + worktreeId: string, + tabId: string +): boolean { const entry = state.unifiedTabsByWorktree[worktreeId]?.find( (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) ) - return parseExecutionHostId(entry?.executionHostId)?.kind === 'runtime' + return ( + !isWebClientLocation() && + getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) === null && + parseExecutionHostId(entry?.executionHostId)?.kind !== 'runtime' + ) } /** A pane this window made, which main's layout doesn't name yet, stays until main names it. */ @@ -78,7 +91,7 @@ export function markTerminalPaneIfAheadOfMain( pane: Required ): void { const named = collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId) - if (!named && !isRuntimeHostedTab(state, pane.worktreeId, pane.tabId)) { + if (!named && isTerminalTabMirroredFromMain(state, pane.worktreeId, pane.tabId)) { state.markPendingTerminalPane({ ...pane, change: 'add' }) } } diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index fd8c35f802eb..74ec487b8ef4 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -20,7 +20,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { ownsGlobalSelection } from '../global-selection-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { withPendingTerminalPane } from './terminal-pending-panes' +import { isTerminalTabMirroredFromMain, withPendingTerminalPane } from './terminal-pending-panes' import { getRemoteConnectionIdForWorktree, resolveCreatedTabShellOverride, @@ -231,10 +231,12 @@ export function createTerminalTabCreationActions( [worktreeId]: [...existing, tab] }, // Shown before main's topology names it; its pane's spawn tells main. - pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, { - worktreeId, - tabId: id, - change: 'add' + ...(isTerminalTabMirroredFromMain(s, worktreeId, id) && { + pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, { + worktreeId, + tabId: id, + change: 'add' + }) }), // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. unifiedTabsByWorktree: { diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 51940605a241..ad0d3e361695 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -14,7 +14,7 @@ import type { TerminalSlice, TerminalStoreSet } from './terminal-state' import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' import { isPendingTerminalTab, - isRuntimeHostedTab, + isTerminalTabMirroredFromMain, pendingAfterTerminalTopologySlice } from './terminal-pending-panes' @@ -113,7 +113,9 @@ export function mirrorTerminalTopologySlice( if (row) { return [mirrorTabRow(tab, row)] } - return isRuntimeHostedTab(state, worktreeId, tab.id) || isPending(tab.id, 'add') ? [tab] : [] + return !isTerminalTabMirroredFromMain(state, worktreeId, tab.id) || isPending(tab.id, 'add') + ? [tab] + : [] }) const currentIds = new Set(currentTabs.map((tab) => tab.id)) const added = slice.tabs From eee654422d0eb7726626b78e9f33b3f4c357a41b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 23:14:42 -0400 Subject: [PATCH 39/75] refactor(terminal): state the core's rules in its comments; reuse the leaf-set check - Comments cite the rule, not design labels (D1, D8, C1) or the deleted membership rebase. - placedSplitRoot compares the sender's tree with sameTerminalLeafSet. - tryGetPreferredHostId maps only the ambiguous-folder error to unverifiable; any other failure propagates to the publisher's log. - The commit module's header states what commits there today. --- .../terminal-topology/terminal-leaf-move.ts | 4 ++-- .../terminal-pane-placement-apply.ts | 14 +++++--------- .../terminal-topology/terminal-topology-commit.ts | 6 +++--- .../runtime-workspace-session-controller.ts | 11 ++++++++--- src/main/runtime/terminal-surface-close.ts | 5 ++--- .../terminal-mounted-layout-reconcile.test.ts | 2 +- src/renderer/src/lib/session-write-subscriber.ts | 2 +- .../src/store/terminals/terminal-pty-bindings.ts | 2 +- .../store/terminals/terminal-topology-mirror.ts | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index dbe99e20e5f3..75ddb2b47a6b 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -134,8 +134,8 @@ function moveLeafInPartition( /** * Moves one leaf and its binding into a new tab in the worktree's home partition, in a single * session write (STA-9259). The leaf id and the PTY are kept; only the tab half of the pane key - * changes, so every pane-keyed record follows it here instead of being rebuilt later by a renderer - * save that main's membership rebase would discard. `session` is null when nothing changes. + * changes, so every pane-keyed record follows it in this write; a window save carries presentation + * only and never rebuilds them. `session` is null when nothing changes. */ export function planTerminalLeafMove( home: WorkspaceSessionState, diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts index 51691bec2737..6a3aeb8cdcf7 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts @@ -1,4 +1,3 @@ -import { isDeepStrictEqual } from 'node:util' import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' import type { TerminalPaneLayoutNode, @@ -6,10 +5,8 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { omitUndefinedValues } from '../../../shared/rpc-contract/ui-update-value-tolerance-params' -import { - cloneLayoutNode, - collectLayoutLeafIdsInOrder -} from '../restoring-sessions/terminal-layout-normalization' +import { cloneLayoutNode } from '../restoring-sessions/terminal-layout-normalization' +import { sameTerminalLeafSet } from './terminal-layout-set' type SplitPlacement = Extract @@ -37,11 +34,10 @@ export function placedSplitRoot( leafId: string, { parentLeafId, direction, ratio, proposedRoot }: SplitPlacement ): TerminalPaneLayoutNode { - const expectedLeaves = [...collectLayoutLeafIdsInOrder(root), leafId].sort() - return proposedRoot && - isDeepStrictEqual(collectLayoutLeafIdsInOrder(proposedRoot).sort(), expectedLeaves) + const split = splitLayoutLeaf(root, parentLeafId, leafId, direction, ratio) + return proposedRoot && sameTerminalLeafSet(proposedRoot, split) ? cloneLayoutNode(proposedRoot) - : splitLayoutLeaf(root, parentLeafId, leafId, direction, ratio) + : split } /** Replaces `parentLeafId` with a split whose second child is the new leaf. */ diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 2cb6ff87ce7a..4fa45769e085 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -29,9 +29,9 @@ import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-l import { planTerminalLayoutSet } from './terminal-layout-set' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' -// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings, sleeping agents). -// Wraps the close and the pane move; the close transform still lives in runtime/ and other writers -// move here later. +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings, sleeping agents): +// every window-requested topology change commits and is traced here. A spawn's binding commits +// through `persistPtyBinding`, which `bindLeaf` also uses. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ type TerminalTopologyCommitKind = diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 67ae2cd8964c..495d0ba72ee9 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -14,6 +14,8 @@ import { isTerminalOwnerPartition } from '../persistence/terminal-topology/termi import type { RuntimeStore } from './runtime-store-contract' import type { TerminalTopologyOwners } from './terminal-topology-publisher' +const FOLDER_WORKSPACE_CONNECTION_AMBIGUOUS = 'folder_workspace_connection_ambiguous' + type RuntimeWorkspaceSessionDependencies = { getStore: () => RuntimeStore | null resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null @@ -55,12 +57,15 @@ export class RuntimeWorkspaceSessionController { : LOCAL_EXECUTION_HOST_ID } - /** An ambiguous folder throws; its owner is unverifiable, not absent. */ + /** An ambiguous folder's owner is unverifiable, not absent; any other failure propagates. */ private tryGetPreferredHostId(worktreeId: string, store: RuntimeStore): ExecutionHostId | null { try { return this.getPreferredHostId(worktreeId, store) - } catch { - return null + } catch (error) { + if (error instanceof Error && error.message === FOLDER_WORKSPACE_CONNECTION_AMBIGUOUS) { + return null + } + throw error } } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 636834a6178e..26993c16777f 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -77,7 +77,7 @@ function readLayoutOwnerLeafIds(copies: TerminalCloseLayoutCopies): readonly str /** * The only place main turns a pane close into its tab's close: `last-pane` sends callers down * the tab path (and its renderer pin guard); anything else closes that pane or nothing. - * D1 (main as the single layout writer) collapses the owner read to main's session layout alone. + * Main is the only layout writer, so the owner's copy is main's session layout alone. */ export function resolveTerminalCloseTarget( target: TerminalSurfaceCloseTarget, @@ -164,8 +164,7 @@ export type TerminalSurfaceCloseOptions = { allowMissing?: boolean force?: boolean reason?: RuntimeSessionTabCloseReason - /** The desktop renderer's own close: its layout owner already removed the tab. Goes away with - * D1, once main owns the terminal layout. */ + /** The desktop renderer's own close: its layout owner already removed the tab. */ closedByLayoutOwner?: boolean } diff --git a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts index 6ea2812361ca..043a384c7b05 100644 --- a/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-mounted-layout-reconcile.test.ts @@ -171,7 +171,7 @@ describe('reconcileMountedTerminalLayout', () => { it('rebuilds nothing when main publishes the tree a local split proposed', () => { const view = mount([A, B]) view.manager.splitPane(view.manager.getNumericIdForLeaf(B)!, 'horizontal', { leafId: NEW }) - // Main keeps the split's proposedRoot verbatim (C1), so its push names the DOM's own tree. + // Main keeps the split's proposedRoot verbatim, so its push names the DOM's own tree. const published = tree(view)! const panes = view.manager.getPanes().map((pane) => [pane.id, pane.container]) const observer = new MutationObserver(() => {}) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index c0a9948a9488..1b91196d3881 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -285,7 +285,7 @@ export function createSessionWriteSubscriber({ return } prev = next - // Why: main already holds what a mirror apply wrote; saving it back would echo (D8). + // Why: main already holds what a mirror apply wrote; saving it back would only echo it. if (mirrored) { return } diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index ff3f8d00fb5f..10dcaff250b9 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -82,7 +82,7 @@ export function createTerminalPtyBindingActions( : existingPtyIds.includes(ptyId) ? existingPtyIds : [...existingPtyIds, ptyId] - // Liveness only: a local or SSH pane's binding is main's, mirrored into the layout (D1). + // Liveness only: main owns a local or SSH pane's binding and mirrors it into the layout. const rotatedLayout = replacementPtyId && isRemoteRuntimePtyId(replacementPtyId) ? rotateRemotePaneBinding(s.terminalLayoutsByTabId[tabId], replacementPtyId, ptyId) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index e53b00a1a55f..9a42009a5f4f 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -23,7 +23,7 @@ import { /** * Main's row fields replace the window's; presentation stays, and so does `ptyId`. In the window it - * is the PTY the tab is attached to now (liveness, D1), not main's persisted binding. + * is the PTY the tab is attached to now (liveness), not main's persisted binding. */ function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { const next = withTopologyRow(current, { ...row, ptyId: current.ptyId }) From fed93954cf0a112b62b905a7bc9694385eb8c9a9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 23:32:11 -0400 Subject: [PATCH 40/75] test(terminal): type the absent-respawn fixture and drop the optional store call The stable-owner absent path called retirePtyBinding through an optional method only because some test stores lacked it; those stores now supply it. The keeps-pane fixture builds a checked WorkspaceSessionState instead of a cast. --- src/main/ipc/pty-dead-owner-respawn.test.ts | 11 ++++++++- .../pty-pane-reservation-settlement.test.ts | 1 + .../pty-persisted-incarnation-repair.test.ts | 2 ++ src/main/ipc/pty/pane/stable-owner.ts | 2 +- ...ble-pane-absence-death-certificate.test.ts | 1 + ...ble-pane-absent-respawn-keeps-pane.test.ts | 24 +++++++++++++------ .../loading-store/pty-binding-persistence.ts | 5 +++- 7 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 3d16b1f20c9d..b12ef3ea808f 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -3,6 +3,7 @@ import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixtur import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' +import { releaseTabRowPty } from '../persistence/loading-store/replaced-pane-binding' import { registerPtyHandlers, setLocalPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -109,6 +110,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -171,8 +173,13 @@ describe('registerPtyHandlers', () => { 'inc-proven-absent-owner', { hostExitConfirmed: true } ) - // The pane and its binding stay for the fresh spawn's bind to swap. + // The pane and its binding stay for the fresh spawn's bind to swap; only the row lets go. expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.retirePtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId, tabId, leafId, ptyId: 'pty-proven-absent-owner' }), + undefined, + releaseTabRowPty + ) }) it('does not poll after the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/probe-blip-owner' @@ -233,6 +240,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -357,6 +365,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index ceb05dc03e0e..ee4e26bddcd0 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -123,6 +123,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), upsertSshRemotePtyLease: vi.fn(), supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 5e3e27934dd2..9cf6e42cc5cf 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -279,6 +279,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => ({ id: 'dead-persisted-owner', @@ -441,6 +442,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index f4e61f0c0381..4cf081d6b6b3 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -287,7 +287,7 @@ export async function attachStablePaneOwner( if (worktreeId) { // The first pane to bind takes the row, as after the window's hydration. const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - await store?.retirePtyBinding?.({ ...owner, worktreeId }, hostId, releaseTabRowPty) + await store?.retirePtyBinding({ ...owner, worktreeId }, hostId, releaseTabRowPty) } return null } diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 49f7eeff8b32..7f89e95b5574 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -61,6 +61,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { session = next }, flushOrThrow: () => {}, + retirePtyBinding: async () => true, getRepos: () => [ { id: 'repo-1', diff --git a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts index ba7e7e344df1..3526215f6d05 100644 --- a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { SessionNotFoundError } from '../../../daemon/daemon-errors' import type { Store } from '../../../persistence' @@ -19,17 +20,28 @@ const SHELL_LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' const AGENT_LEAF = '2c4d3e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f' const SHELL_PTY = `${WORKTREE}@@aaaa0001` const AGENT_PTY = `${WORKTREE}@@aaaa0002` -const SPLIT = { +const SPLIT: TerminalPaneLayoutNode = { type: 'split', direction: 'vertical', first: { type: 'leaf', leafId: SHELL_LEAF }, second: { type: 'leaf', leafId: AGENT_LEAF } } +const ROW: TerminalTab = { + id: TAB, + worktreeId: WORKTREE, + ptyId: SHELL_PTY, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 +} + function sleptSplit(): { store: Store; read: () => WorkspaceSessionState } { - let session = { + let session: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE]: [{ id: TAB, worktreeId: WORKTREE, ptyId: SHELL_PTY }] }, + tabsByWorktree: { [WORKTREE]: [ROW] }, terminalLayoutsByTabId: { [TAB]: { root: SPLIT, @@ -38,7 +50,7 @@ function sleptSplit(): { store: Store; read: () => WorkspaceSessionState } { ptyIdsByLeafId: { [SHELL_LEAF]: SHELL_PTY, [AGENT_LEAF]: AGENT_PTY } } } - } as unknown as WorkspaceSessionState + } return { read: () => session, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence and catalog reads stable-pane adoption and exit bookkeeping use. @@ -124,8 +136,6 @@ describe('a stable pane respawned over an absent process', () => { expect(read().terminalLayoutsByTabId).toEqual(before.terminalLayoutsByTabId) // The row named the shell's gone process; the first pane to bind takes it. - expect(read().tabsByWorktree[WORKTREE]).toEqual([ - { id: TAB, worktreeId: WORKTREE, ptyId: null } - ]) + expect(read().tabsByWorktree[WORKTREE]).toEqual([{ ...ROW, ptyId: null }]) }) }) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index cdb0a6380c41..46ee6f2b84c2 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -79,7 +79,10 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ + /** + * Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any + * incarnation. `retire` narrows the edit when a replacing spawn's bind will swap the binding. + */ async retirePtyBinding( binding: Pick, hostId?: string | null, From 19034893ff4402ca0c1c22f2966be72d46b0c3c9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 00:25:00 -0400 Subject: [PATCH 41/75] fix(terminal): a window save drops local residue of an SSH-homed workspace, as before the core #26098 (on main) relies on the window's first save erasing a stray local copy of a workspace the repo catalog places on an SSH target. Window saves now keep main's rows, so that copy stayed. In an owner partition main keeps only the rows of workspaces homed there (or of unknown home); residue homed elsewhere is the window's to keep or drop, as before. Fixes ssh-startup-local-shadow. --- ...erminal-renderer-presentation-save.test.ts | 14 +++++++ .../terminal-renderer-presentation-save.ts | 37 ++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts index 8ab736f213ad..6f61ca581be1 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts @@ -329,4 +329,18 @@ describe('mergeRendererPresentationSave', () => { const fenced = mergeRendererPresentationSave(window, mainSession(), RUNTIME_HOST) expect(fenced.tabsByWorktree).toEqual(mainSession().tabsByWorktree) }) + + it('a save drops local residue of a workspace the catalog homes on SSH, as before the core', () => { + const prior = mainSession() + const window: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), tabsByWorktree: {} } + + const saved = mergeRendererPresentationSave(window, prior, 'local', () => 'ssh:target') + expect(saved.tabsByWorktree).toEqual({}) + expect(saved.terminalLayoutsByTabId).toEqual({}) + // The workspace's own partition, or an unknown home, keeps main's rows. + for (const home of ['local', null] as const) { + const kept = mergeRendererPresentationSave(window, prior, 'local', () => home) + expect(kept.tabsByWorktree).toEqual(prior.tabsByWorktree) + } + }) }) diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts index 0f4362dc159c..67de597a122b 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts @@ -1,4 +1,4 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup } from '../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { @@ -6,6 +6,9 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { withTopologyRow } from '../../../shared/terminal-topology-tab-row' +import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { pruneTabGroupLayoutAfterRetirement } from '../../runtime/mobile-session-terminal-retirement' import { projectTabRow } from '../../runtime/terminal-topology-projection' import type { Store } from '../loading-store/store' @@ -22,7 +25,8 @@ type RendererSaveStore = Pick< | 'setWorkspaceSession' | 'patchWorkspaceSession' | 'stageWorkspaceSessionBeforeUnload' -> +> & + Partial> // The window's session writes: `session:set`, `session:set-sync`, `session:patch` and the quit stage. export function setRendererSession( @@ -62,10 +66,28 @@ function overMainTopology( return mergeRendererPresentationSave( incoming, store.getWorkspaceSession(hostId), - resolveHostId(hostId) + resolveHostId(hostId), + (worktreeId) => catalogHomeHostId(store, worktreeId) ) } +/** The partition the repo catalog places a workspace on; null when the catalog can't say. */ +function catalogHomeHostId(store: RendererSaveStore, worktreeId: string): ExecutionHostId | null { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const workspace = store + .getFolderWorkspaces?.() + .find((entry) => entry.id === scope.folderWorkspaceId) + return workspace?.executionHostId + ? workspaceSessionPartitionHostId(workspace.executionHostId) + : null + } + const repo = store.getRepo?.( + getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) + ) + return repo ? workspaceSessionPartitionHostId(getRepoExecutionHostId(repo)) : null +} + /** * A window save brings presentation: titles, colours, order, focus, buffers, tab groups. Main keeps * what it authors: which tabs and panes exist, their creation fields, trees and PTY bindings, @@ -74,13 +96,16 @@ function overMainTopology( export function mergeRendererPresentationSave( incoming: T, prior: WorkspaceSessionState, - hostId: ExecutionHostId + hostId: ExecutionHostId, + homeHostIdOf: (worktreeId: string) => ExecutionHostId | null = () => null ): T { const ownerPartition = isTerminalOwnerPartition(hostId) // Another server feeds a `runtime:` partition's rows; main's stand there only for a repo it fenced. + // Rows homed on another partition are residue main never writes; the window's save decides them (#26098). const mainHolds = (worktreeId: string | undefined): boolean => - ownerPartition || - (worktreeId !== undefined && hasHostAuthoritativeTerminalMembership(prior, worktreeId)) + ownerPartition + ? worktreeId === undefined || (homeHostIdOf(worktreeId) ?? hostId) === hostId + : worktreeId !== undefined && hasHostAuthoritativeTerminalMembership(prior, worktreeId) return { ...incoming, clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree, From b0e8a0bef5ae37e7f1a0cd29440c52e193d5a990 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 01:01:19 -0400 Subject: [PATCH 42/75] fix(terminal): a woken split's tab row stays on the pane it named Waking a slept split reattaches each pane to a process the sleep stopped; the host answers absent and each pane spawns fresh. Main released the tab row on that absence, so whichever pane bound first took the row, and in some wakes that was the second pane. Main now leaves the row, like the pane and its binding, for the fresh spawn's bind to swap: the row moves to the new process of the pane it already named, in any bind order, as before the core. --- src/main/ipc/pty-dead-owner-respawn.test.ts | 9 +--- src/main/ipc/pty/pane/stable-owner.ts | 12 ++---- ...ble-pane-absent-respawn-keeps-pane.test.ts | 42 ++++++++++++++----- .../stable-pane-relay-absence-respawn.test.ts | 18 +++----- .../loading-store/pty-binding-persistence.ts | 10 ++--- .../loading-store/replaced-pane-binding.ts | 21 ++-------- .../ssh-expired-lease-pane-readoption.test.ts | 4 +- 7 files changed, 51 insertions(+), 65 deletions(-) diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index b12ef3ea808f..179803f2ca23 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -3,7 +3,6 @@ import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixtur import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' -import { releaseTabRowPty } from '../persistence/loading-store/replaced-pane-binding' import { registerPtyHandlers, setLocalPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -173,13 +172,9 @@ describe('registerPtyHandlers', () => { 'inc-proven-absent-owner', { hostExitConfirmed: true } ) - // The pane and its binding stay for the fresh spawn's bind to swap; only the row lets go. + // The pane, its binding and its row stay for the fresh spawn's bind to swap. expect(store.setWorkspaceSession).not.toHaveBeenCalled() - expect(store.retirePtyBinding).toHaveBeenCalledWith( - expect.objectContaining({ worktreeId, tabId, leafId, ptyId: 'pty-proven-absent-owner' }), - undefined, - releaseTabRowPty - ) + expect(store.retirePtyBinding).not.toHaveBeenCalled() }) it('does not poll after the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/probe-blip-owner' diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 4cf081d6b6b3..bf0acafc4653 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -17,7 +17,6 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' -import { releaseTabRowPty } from '../../../persistence/loading-store/replaced-pane-binding' export type StablePaneOwner = { handle?: string @@ -226,7 +225,7 @@ export async function persistAdmittedStablePaneBinding(args: { export async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions, store, worktreeId, connectionId } = args + const { owner, provider, runtime, spawnOptions } = args let result: PtySpawnResult try { result = await provider.spawn({ @@ -266,8 +265,8 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } - // Why 'replaced': the fresh spawn that follows takes the pane, so main keeps the pane and its - // binding until that spawn's bind swaps it, as for a restart. + // Why 'replaced': the fresh spawn that follows takes the pane, so main keeps the pane, its + // binding and the tab row naming it until that spawn's bind swaps them, as for a restart. const stops = runtime?.intentionalPtyStops const settleReplacement = stops?.mark(owner.ptyId, 'replaced', owner.incarnationId ?? null) // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its @@ -284,11 +283,6 @@ export async function attachStablePaneOwner( settleReplacement?.(true) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) - if (worktreeId) { - // The first pane to bind takes the row, as after the window's hydration. - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - await store?.retirePtyBinding({ ...owner, worktreeId }, hostId, releaseTabRowPty) - } return null } if ( diff --git a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts index 3526215f6d05..cf647ca7d175 100644 --- a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts @@ -1,15 +1,18 @@ // Waking a slept split after a relaunch reattaches each pane to a process the sleep stopped. The -// host answers absent and the pane spawns fresh, so main's copy must keep the pane and its binding -// until the fresh spawn's bind swaps it: dropping the pane lets that spawn re-mint the tab and -// reorder the split, and a pane shown unbound meanwhile reads as gone to the agent resume. +// host answers absent and the pane spawns fresh, so main's copy must keep the pane, its binding and +// the tab row until the fresh spawn's bind swaps them: dropping the pane lets that spawn re-mint +// the tab and reorder the split, a pane shown unbound meanwhile reads as gone to the agent resume, +// and a released row goes to whichever pane binds first. import { describe, expect, it, vi } from 'vitest' import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { SessionNotFoundError } from '../../../daemon/daemon-errors' import type { Store } from '../../../persistence' import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' +import { applyPtyBinding } from '../../../persistence/loading-store/pty-binding-session-update' import type { IPtyProvider } from '../../../providers/types' import { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { spawnForStablePane } from './stable-owner' @@ -59,11 +62,8 @@ function sleptSplit(): { store: Store; read: () => WorkspaceSessionState } { setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, - // The store's host-side binding retirement, applying the change the caller names. - retirePtyBinding: async ( - ...[binding, , retire = clearReplacedPaneBinding]: Parameters - ) => { - session = retire(session, { ...binding, parentTabId: binding.tabId }) + retirePtyBinding: async (...[binding]: Parameters) => { + session = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) return true }, flushOrThrow: () => {}, @@ -126,7 +126,7 @@ async function respawnOverAbsentProcess( } describe('a stable pane respawned over an absent process', () => { - it('keeps the panes and their bindings, releasing only the row of the gone process', async () => { + it('keeps the panes, their bindings and the row for the fresh binds to swap', async () => { const { store, read } = sleptSplit() const before = structuredClone(read()) const runtime = wakingRuntime(store) @@ -135,7 +135,27 @@ describe('a stable pane respawned over an absent process', () => { await respawnOverAbsentProcess(runtime, store, AGENT_LEAF, AGENT_PTY) expect(read().terminalLayoutsByTabId).toEqual(before.terminalLayoutsByTabId) - // The row named the shell's gone process; the first pane to bind takes it. - expect(read().tabsByWorktree[WORKTREE]).toEqual([{ ...ROW, ptyId: null }]) + expect(read().tabsByWorktree[WORKTREE]).toEqual([ROW]) }) + + it.each([ + ['shell', SHELL_LEAF, AGENT_LEAF], + ['agent', AGENT_LEAF, SHELL_LEAF] + ])( + 'moves the row to the fresh PTY of the pane it named, binding the %s first', + async (_, ...order) => { + const { store, read } = sleptSplit() + const runtime = wakingRuntime(store) + await respawnOverAbsentProcess(runtime, store, SHELL_LEAF, SHELL_PTY) + await respawnOverAbsentProcess(runtime, store, AGENT_LEAF, AGENT_PTY) + const session = structuredClone(read()) + const fresh = { [SHELL_LEAF]: `${SHELL_PTY}-fresh`, [AGENT_LEAF]: `${AGENT_PTY}-fresh` } + for (const leafId of order) { + const binding = { worktreeId: WORKTREE, tabId: TAB, leafId, ptyId: fresh[leafId] } + applyPtyBinding(binding, session, WORKTREE, makePaneKey(TAB, leafId)) + } + + expect(session.tabsByWorktree[WORKTREE]).toEqual([{ ...ROW, ptyId: fresh[SHELL_LEAF] }]) + } + ) }) diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index 9ec5988ab763..bb3bf673f9b0 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -78,11 +78,8 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, - // The store's host-side binding retirement, applying the change the caller names. - retirePtyBinding: async ( - ...[binding, , retire = clearReplacedPaneBinding]: Parameters - ) => { - session = retire(session, { ...binding, parentTabId: binding.tabId }) + retirePtyBinding: async (...[binding]: Parameters) => { + session = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) return true }, flushOrThrow: () => {} @@ -161,14 +158,14 @@ describe('stable pane adoption after the relay reports the PTY absent', () => { }) // Why a real store: "Reconnect lost every tab" is the regression this subsystem was reverted for - // twice. The fresh spawn takes the pane, so the absence leaves the tab, its layout and the - // bindings for that spawn's bind to swap; only the row lets go of the gone process. + // twice. The fresh spawn takes the pane, so the absence leaves the tab, its layout, the bindings + // and the row for that spawn's bind to swap. describe('with persistence actually reached', () => { it.each([[[LEAF, SIBLING_LEAF]], [[LEAF]]])( 'keeps the tab, its panes and their bindings (leaves %j)', async (leaves) => { const { store, read } = sessionStore(leaves) - const layouts = structuredClone(read().terminalLayoutsByTabId) + const before = structuredClone(read()) const { run, spawn } = spawnAfterAttachRejection( new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), { store, worktreeId: WORKTREE } @@ -178,10 +175,7 @@ describe('stable pane adoption after the relay reports the PTY absent', () => { expect(spawn).toHaveBeenCalledTimes(2) expect(result.owner).toBeNull() - expect(read().terminalLayoutsByTabId).toEqual(layouts) - expect(read().tabsByWorktree[WORKTREE]).toEqual([ - { id: 'tab-1', worktreeId: WORKTREE, ptyId: null } - ]) + expect(read()).toEqual(before) } ) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index a6a6b66cf0e6..7488df42ac39 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -85,14 +85,10 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - /** - * Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any - * incarnation. `retire` narrows the edit when a replacing spawn's bind will swap the binding. - */ + /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ async retirePtyBinding( binding: Pick, - hostId?: string | null, - retire = clearReplacedPaneBinding + hostId?: string | null ): Promise { const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolved = resolveHostId(hostId) @@ -123,7 +119,7 @@ export class PtyBindingPersistenceOperations { return { value: false, persist: false } } const before = cloneWorkspaceSessionState(session) - const retired = retire(session, { ...binding, parentTabId: binding.tabId }) + const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) // Host retirement must not run renderer snapshot repair, which would put the old binding back. publish(retired) const staged = cloneWorkspaceSessionState(retired) diff --git a/src/main/persistence/loading-store/replaced-pane-binding.ts b/src/main/persistence/loading-store/replaced-pane-binding.ts index 81f876abf14f..5cefc332677f 100644 --- a/src/main/persistence/loading-store/replaced-pane-binding.ts +++ b/src/main/persistence/loading-store/replaced-pane-binding.ts @@ -21,25 +21,12 @@ export function clearReplacedPaneBinding( ...session.terminalLayoutsByTabId, [surface.parentTabId]: { ...layout, ptyIdsByLeafId } }, - tabsByWorktree: releaseTabRowPty(session, surface).tabsByWorktree - } -} - -/** A tab row names a live attachment; a process that is gone leaves it for the next bind to take. */ -export function releaseTabRowPty( - session: WorkspaceSessionState, - surface: Pick -): WorkspaceSessionState { - const tabs = session.tabsByWorktree[surface.worktreeId] ?? [] - if (!tabs.some((tab) => tab.id === surface.parentTabId && tab.ptyId === surface.ptyId)) { - return session - } - return { - ...session, tabsByWorktree: { ...session.tabsByWorktree, - [surface.worktreeId]: tabs.map((tab) => - tab.id === surface.parentTabId ? { ...tab, ptyId: null } : tab + [surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) => + tab.id === surface.parentTabId && tab.ptyId === surface.ptyId + ? { ...tab, ptyId: null } + : tab ) } } diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index 642d9343425e..35ca9d1f3d60 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -155,12 +155,12 @@ describe('recovery through createTerminal reattaches before it respawns', () => // A null adoption is exactly what routes createTerminal to a fresh shell, so a pane whose // shell genuinely died still gets a working terminal. await expect(adoptStablePane(undefined, store, ADOPT_ARGS)).resolves.toBeNull() - // The pane keeps its binding for the fresh shell's bind to swap; only the row lets go. + // The pane and its row keep the binding for the fresh shell's bind to swap. expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toMatchObject({ ptyId: APP_PTY_ID }) expect(store.getWorkspaceSession(`ssh:${TARGET}`).tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBe( - null + APP_PTY_ID ) }) }) From de32ad8a2cab86b2eb1bf13125b835d98a0720db Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 01:04:28 -0400 Subject: [PATCH 43/75] fix(terminal): a reply names no push for an unresolved owner and the empty slice for a withdrawn one An uncatalogued repo with SSH terminal rows is unresolved, so its last slice stands instead of going empty. --- .../runtime-workspace-session-controller.ts | 39 ++++++++++++++++--- .../runtime/terminal-topology-owners.test.ts | 14 +++++++ .../terminal-topology-publisher.test.ts | 23 +++++++++++ .../runtime/terminal-topology-publisher.ts | 19 +++++++-- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 495d0ba72ee9..894ccd1e31b3 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -16,6 +16,15 @@ import type { TerminalTopologyOwners } from './terminal-topology-publisher' const FOLDER_WORKSPACE_CONNECTION_AMBIGUOUS = 'folder_workspace_connection_ambiguous' +/** The repo a workspace key belongs to in the catalog; null for a folder workspace. */ +function catalogRepoId(worktreeId: string): string | null { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return null + } + return getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) +} + type RuntimeWorkspaceSessionDependencies = { getStore: () => RuntimeStore | null resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null @@ -50,8 +59,8 @@ export class RuntimeWorkspaceSessionController { const connectionId = this.deps.resolveFolderConnectionId(workspace) return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } - const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - const repo = store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) + const repoId = catalogRepoId(worktreeId) + const repo = repoId ? store?.getRepo?.(repoId) : undefined return repo ? workspaceSessionPartitionHostId(getRepoExecutionHostId(repo)) : LOCAL_EXECUTION_HOST_ID @@ -159,13 +168,33 @@ export class RuntimeWorkspaceSessionController { /** The one `local`/`ssh:` partition a worktree's terminal rows live in; null when unresolved or another server's. */ getTerminalTopologyHomeHostId(worktreeId: string): ExecutionHostId | null { const store = this.deps.getStore() - const hostId = store ? this.tryGetPreferredHostId(worktreeId, store) : null + const hostId = store ? this.resolveTerminalTopologyHome(worktreeId, store) : null return hostId && isTerminalOwnerPartition(hostId) ? hostId : null } + /** The worktree's home partition; null when unverifiable rather than absent. */ + private resolveTerminalTopologyHome( + worktreeId: string, + store: RuntimeStore + ): ExecutionHostId | null { + const hostId = this.tryGetPreferredHostId(worktreeId, store) + const repoId = catalogRepoId(worktreeId) + // A repo missing from the catalog defaults to local, which SSH rows for it contradict. + if (hostId === LOCAL_EXECUTION_HOST_ID && repoId && !store.getRepo?.(repoId)) { + const hasSshRows = (store.getWorkspaceSessionHostIds?.() ?? []).some( + (partition) => + parseExecutionHostId(partition)?.kind === 'ssh' && + (store.getWorkspaceSession?.(partition).tabsByWorktree[worktreeId]?.length ?? 0) > 0 + ) + return hasSshRows ? null : hostId + } + return hostId + } + /** * Worktrees with terminal rows in their home partition. `runtime:` homes are another server's; - * a home that can't be resolved (a missing or ambiguous folder) maps to null. + * a home that can't be resolved (a missing or ambiguous folder, an uncatalogued repo with SSH + * rows) maps to null. */ getTerminalTopologyOwners(): TerminalTopologyOwners { const owners: TerminalTopologyOwners = new Map() @@ -179,7 +208,7 @@ export class RuntimeWorkspaceSessionController { } const session = store.getWorkspaceSession(hostId) for (const worktreeId of Object.keys(session.tabsByWorktree ?? {})) { - const homeHostId = this.tryGetPreferredHostId(worktreeId, store) + const homeHostId = this.resolveTerminalTopologyHome(worktreeId, store) if (!homeHostId) { owners.set(worktreeId, null) } else if (homeHostId === hostId) { diff --git a/src/main/runtime/terminal-topology-owners.test.ts b/src/main/runtime/terminal-topology-owners.test.ts index 9bbc6a0d0079..536c2518f223 100644 --- a/src/main/runtime/terminal-topology-owners.test.ts +++ b/src/main/runtime/terminal-topology-owners.test.ts @@ -10,6 +10,7 @@ const SSH_WT = 'repo-ssh::/remote/wt' const RUNTIME_WT = 'repo-runtime::/srv/wt' const AMBIGUOUS_FOLDER = 'folder:mixed' const SSH_FOLDER = 'folder:remote' +const UNCATALOGUED_WT = 'repo-gone::/wt' function sessionWith(worktreeIds: string[]): WorkspaceSessionState { const session = getDefaultWorkspaceSession() @@ -115,6 +116,19 @@ describe('terminal topology owners', () => { ) }) + it('marks an uncatalogued repo with SSH rows unresolved, and keeps one with only local rows', () => { + const ssh = sessionWith([UNCATALOGUED_WT]) + const controller = controllerFor(new Map([['ssh:c1', ssh]])) + + expect(controller.getTerminalTopologyOwners()).toEqual(new Map([[UNCATALOGUED_WT, null]])) + expect(controller.getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBeNull() + + const local = sessionWith([UNCATALOGUED_WT]) + expect(ownersFor(new Map([['local', local]]))).toEqual( + new Map([[UNCATALOGUED_WT, { hostId: 'local', session: local }]]) + ) + }) + it('resolves one home for a layout write, and none for a runtime or ambiguous worktree', () => { const controller = controllerFor(new Map([['local', sessionWith([SSH_WT])]])) diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index 91cf71111182..f3c744031bf6 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -177,6 +177,29 @@ describe('TerminalTopologyPublisher', () => { expect(h.publisher.settle('repo::/hosted-elsewhere')).toBeUndefined() }) + it('names the empty slice for a withdrawn worktree, not a later push for another', () => { + const h = harness(sessionWith([WT, WT_B])) + const next = sessionWith([WT_B]) + next.tabsByWorktree[WT_B]![0]!.ptyId = 'pty-b' + h.replace(next) + h.publisher.markDirty() + + const seq = h.publisher.settle(WT) + + expect(h.pushes.map(({ worktreeId, publishSeq }) => [worktreeId, publishSeq])).toEqual([ + [WT, seq], + [WT_B, h.publisher.settle()] + ]) + }) + + it('names no push for an unresolved worktree, whose writes its last slice does not hold', () => { + const h = harness(sessionWith([WT])) + h.unresolve(WT) + h.publisher.markDirty() + + expect(h.publisher.settle(WT)).toBeUndefined() + }) + it('keeps publishSeq monotonic across pushes', async () => { const h = harness(sessionWith([WT])) const settle = (): number => h.publisher.settle(WT) ?? Number.NaN diff --git a/src/main/runtime/terminal-topology-publisher.ts b/src/main/runtime/terminal-topology-publisher.ts index 7a4ce00e0a20..5c8d6fceef6d 100644 --- a/src/main/runtime/terminal-topology-publisher.ts +++ b/src/main/runtime/terminal-topology-publisher.ts @@ -20,6 +20,9 @@ export type TerminalTopologySink = (slice: TerminalTopologySlice) => void */ export class TerminalTopologyPublisher { private readonly published = new Map() + /** A withdrawn worktree's empty-slice seq, until it publishes again. */ + private readonly withdrawn = new Map() + private owners: TerminalTopologyOwners = new Map() private lastSeq = 0 private dirty = false private failureLogged = false @@ -56,11 +59,17 @@ export class TerminalTopologyPublisher { /** * A publishSeq whose push includes every write made before this call; none for a worktree main - * publishes no slice for, since no push will ever carry it. + * publishes no slice for, or whose owner is unresolved, since no push carries its writes. */ settle(worktreeId?: string): number | undefined { this.flush() - return worktreeId === undefined ? this.lastSeq : this.published.get(worktreeId)?.publishSeq + if (worktreeId === undefined) { + return this.lastSeq + } + if (this.owners.get(worktreeId) === null) { + return undefined + } + return this.published.get(worktreeId)?.publishSeq ?? this.withdrawn.get(worktreeId) } /** Every current slice, for a window that loaded after pushes it never saw. */ @@ -73,6 +82,7 @@ export class TerminalTopologyPublisher { private reconcile(): void { const owners = this.readOwners() + this.owners = owners const changed: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, owner] of owners) { if (!owner) { @@ -90,11 +100,14 @@ export class TerminalTopologyPublisher { } } for (const snapshot of removed) { + const publishSeq = ++this.lastSeq this.published.delete(snapshot.worktreeId) - this.sink(sequenced({ publishSeq: ++this.lastSeq, snapshot })) + this.withdrawn.set(snapshot.worktreeId, publishSeq) + this.sink(sequenced({ publishSeq, snapshot })) } for (const snapshot of changed) { const entry = { publishSeq: ++this.lastSeq, snapshot } + this.withdrawn.delete(snapshot.worktreeId) this.published.set(snapshot.worktreeId, entry) this.sink(sequenced(entry)) } From 229ebb3a4564d00201f3749b76cb661e7a770448 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 01:10:49 -0400 Subject: [PATCH 44/75] test(e2e): record the woken split's pane order as unstable on main The window lists a woken split's PTYs, and main saves its tab row, in the order the panes' respawns return. Main usually returns the first pane first, but not always (1 of 7 parity runs), so those paths are reported rather than failed. --- .../terminal-layout-parity-declared-differences.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/e2e/terminal-layout-parity-declared-differences.ts b/tests/e2e/terminal-layout-parity-declared-differences.ts index 21e24ad7e07c..e86b52361841 100644 --- a/tests/e2e/terminal-layout-parity-declared-differences.ts +++ b/tests/e2e/terminal-layout-parity-declared-differences.ts @@ -45,4 +45,16 @@ export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParit ] /** Paths main itself does not reproduce; reported but not failed. Remove an entry once main is fixed. */ -export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = [] +export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = [ + { + // The window lists a tab's PTYs, and on main saves its row, in the order the woken panes' + // respawns return; main usually but not always returns the first pane first. + scenario: 'sleep-quit-resume', + paths: [ + '[1].renderer.ptyIdsByTabId', + '[1].renderer.tabsByWorktree.#4::[0].ptyId', + '[1].persisted.local.tabsByWorktree.#4::[0].ptyId' + ], + evidence: 'main 61de2d8ec85 saved and listed the second pane first in 1 of 7 runs on macOS' + } +] From a31bb1059048c5742760be48e1c8f42f6f37c306 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 01:15:33 -0400 Subject: [PATCH 45/75] test(e2e): count main's woken-split pane order over 15 parity runs --- tests/e2e/terminal-layout-parity-declared-differences.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/terminal-layout-parity-declared-differences.ts b/tests/e2e/terminal-layout-parity-declared-differences.ts index e86b52361841..9e9fe2baf34b 100644 --- a/tests/e2e/terminal-layout-parity-declared-differences.ts +++ b/tests/e2e/terminal-layout-parity-declared-differences.ts @@ -48,13 +48,13 @@ export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParit export const TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN: readonly UnstableOnMainPath[] = [ { // The window lists a tab's PTYs, and on main saves its row, in the order the woken panes' - // respawns return; main usually but not always returns the first pane first. + // respawns return; main returns the first pane first only most of the time. scenario: 'sleep-quit-resume', paths: [ '[1].renderer.ptyIdsByTabId', '[1].renderer.tabsByWorktree.#4::[0].ptyId', '[1].persisted.local.tabsByWorktree.#4::[0].ptyId' ], - evidence: 'main 61de2d8ec85 saved and listed the second pane first in 1 of 7 runs on macOS' + evidence: 'main 61de2d8ec85 saved the second pane as the row in 5 of 15 runs on macOS' } ] From 67903baa400c28871fbad0fa50e42c13a21efb16 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 01:34:31 -0400 Subject: [PATCH 46/75] refactor(terminal): home an uncatalogued repo in the one partition holding its rows; a withdrawn slice stays published An SSH worktree whose repo left the catalog again takes writes in the partition its rows are in, instead of refusing them; only rows in two partitions stay unresolved. The publisher keeps a withdrawn worktree's empty slice in its published map, so settle reads one map and needs neither a withdrawn-seq map nor the last pass's owners. --- .../runtime-workspace-session-controller.ts | 23 +++++---- .../runtime/terminal-topology-owners.test.ts | 19 ++++++-- .../terminal-topology-publisher.test.ts | 6 ++- .../runtime/terminal-topology-publisher.ts | 48 +++++++------------ 4 files changed, 46 insertions(+), 50 deletions(-) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 894ccd1e31b3..f8d5e3608ea7 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -177,24 +177,23 @@ export class RuntimeWorkspaceSessionController { worktreeId: string, store: RuntimeStore ): ExecutionHostId | null { - const hostId = this.tryGetPreferredHostId(worktreeId, store) const repoId = catalogRepoId(worktreeId) - // A repo missing from the catalog defaults to local, which SSH rows for it contradict. - if (hostId === LOCAL_EXECUTION_HOST_ID && repoId && !store.getRepo?.(repoId)) { - const hasSshRows = (store.getWorkspaceSessionHostIds?.() ?? []).some( - (partition) => - parseExecutionHostId(partition)?.kind === 'ssh' && - (store.getWorkspaceSession?.(partition).tabsByWorktree[worktreeId]?.length ?? 0) > 0 - ) - return hasSshRows ? null : hostId + if (!repoId || store.getRepo?.(repoId)) { + return this.tryGetPreferredHostId(worktreeId, store) } - return hostId + // An uncatalogued repo's home is the one partition holding its rows; rows in two are ambiguous. + const holders = (store.getWorkspaceSessionHostIds?.() ?? []).filter( + (hostId) => + isTerminalOwnerPartition(hostId) && + (store.getWorkspaceSession?.(hostId).tabsByWorktree[worktreeId]?.length ?? 0) > 0 + ) + return holders.length > 1 ? null : (holders[0] ?? LOCAL_EXECUTION_HOST_ID) } /** * Worktrees with terminal rows in their home partition. `runtime:` homes are another server's; - * a home that can't be resolved (a missing or ambiguous folder, an uncatalogued repo with SSH - * rows) maps to null. + * a home that can't be resolved (a missing or ambiguous folder, an uncatalogued repo with rows in + * two partitions) maps to null. */ getTerminalTopologyOwners(): TerminalTopologyOwners { const owners: TerminalTopologyOwners = new Map() diff --git a/src/main/runtime/terminal-topology-owners.test.ts b/src/main/runtime/terminal-topology-owners.test.ts index 536c2518f223..28be2e0d8d80 100644 --- a/src/main/runtime/terminal-topology-owners.test.ts +++ b/src/main/runtime/terminal-topology-owners.test.ts @@ -116,17 +116,28 @@ describe('terminal topology owners', () => { ) }) - it('marks an uncatalogued repo with SSH rows unresolved, and keeps one with only local rows', () => { + it('homes an uncatalogued repo in the one partition holding its rows, and none when two do', () => { const ssh = sessionWith([UNCATALOGUED_WT]) const controller = controllerFor(new Map([['ssh:c1', ssh]])) - - expect(controller.getTerminalTopologyOwners()).toEqual(new Map([[UNCATALOGUED_WT, null]])) - expect(controller.getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBeNull() + expect(controller.getTerminalTopologyOwners()).toEqual( + new Map([[UNCATALOGUED_WT, { hostId: 'ssh:c1', session: ssh }]]) + ) + expect(controller.getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBe('ssh:c1') const local = sessionWith([UNCATALOGUED_WT]) expect(ownersFor(new Map([['local', local]]))).toEqual( new Map([[UNCATALOGUED_WT, { hostId: 'local', session: local }]]) ) + expect(controllerFor(new Map()).getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBe('local') + + const both = controllerFor( + new Map([ + ['local', sessionWith([UNCATALOGUED_WT])], + ['ssh:c1', sessionWith([UNCATALOGUED_WT])] + ]) + ) + expect(both.getTerminalTopologyOwners()).toEqual(new Map([[UNCATALOGUED_WT, null]])) + expect(both.getTerminalTopologyHomeHostId(UNCATALOGUED_WT)).toBeNull() }) it('resolves one home for a layout write, and none for a runtime or ambiguous worktree', () => { diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index f3c744031bf6..8079babe2fce 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -192,12 +192,14 @@ describe('TerminalTopologyPublisher', () => { ]) }) - it('names no push for an unresolved worktree, whose writes its last slice does not hold', () => { + it('names the last slice for an unresolved worktree, whose writes are refused', () => { const h = harness(sessionWith([WT])) + const seq = h.publisher.settle(WT) h.unresolve(WT) h.publisher.markDirty() - expect(h.publisher.settle(WT)).toBeUndefined() + expect(h.publisher.settle(WT)).toBe(seq) + expect(h.pushes).toEqual([]) }) it('keeps publishSeq monotonic across pushes', async () => { diff --git a/src/main/runtime/terminal-topology-publisher.ts b/src/main/runtime/terminal-topology-publisher.ts index 5c8d6fceef6d..e227abbeaa53 100644 --- a/src/main/runtime/terminal-topology-publisher.ts +++ b/src/main/runtime/terminal-topology-publisher.ts @@ -19,10 +19,8 @@ export type TerminalTopologySink = (slice: TerminalTopologySlice) => void * Compares by value because some persistence writers edit session objects in place. */ export class TerminalTopologyPublisher { + /** A withdrawn worktree keeps its empty slice, so a reply can still name that push. */ private readonly published = new Map() - /** A withdrawn worktree's empty-slice seq, until it publishes again. */ - private readonly withdrawn = new Map() - private owners: TerminalTopologyOwners = new Map() private lastSeq = 0 private dirty = false private failureLogged = false @@ -59,17 +57,12 @@ export class TerminalTopologyPublisher { /** * A publishSeq whose push includes every write made before this call; none for a worktree main - * publishes no slice for, or whose owner is unresolved, since no push carries its writes. + * publishes no slice for, since no push will ever carry it. An unresolved owner's writes are + * refused, so its last slice stands. */ settle(worktreeId?: string): number | undefined { this.flush() - if (worktreeId === undefined) { - return this.lastSeq - } - if (this.owners.get(worktreeId) === null) { - return undefined - } - return this.published.get(worktreeId)?.publishSeq ?? this.withdrawn.get(worktreeId) + return worktreeId === undefined ? this.lastSeq : this.published.get(worktreeId)?.publishSeq } /** Every current slice, for a window that loaded after pushes it never saw. */ @@ -82,34 +75,25 @@ export class TerminalTopologyPublisher { private reconcile(): void { const owners = this.readOwners() - this.owners = owners - const changed: UnsequencedTerminalTopologySlice[] = [] + const next: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, owner] of owners) { - if (!owner) { - continue // The last slice stands until the owner resolves. - } - const next = projectTerminalTopologySlice(owner.session, owner.hostId, worktreeId) - if (!isDeepStrictEqual(this.published.get(worktreeId)?.snapshot, next)) { - changed.push(structuredClone(next)) + // The last slice stands until the owner resolves. + if (owner) { + next.push(projectTerminalTopologySlice(owner.session, owner.hostId, worktreeId)) } } - const removed: UnsequencedTerminalTopologySlice[] = [] + const withdrawn: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, { snapshot }] of this.published) { if (!owners.has(worktreeId)) { - removed.push(emptyTerminalTopologySlice(snapshot.hostId, worktreeId, snapshot.revision)) + withdrawn.push(emptyTerminalTopologySlice(snapshot.hostId, worktreeId, snapshot.revision)) } } - for (const snapshot of removed) { - const publishSeq = ++this.lastSeq - this.published.delete(snapshot.worktreeId) - this.withdrawn.set(snapshot.worktreeId, publishSeq) - this.sink(sequenced({ publishSeq, snapshot })) - } - for (const snapshot of changed) { - const entry = { publishSeq: ++this.lastSeq, snapshot } - this.withdrawn.delete(snapshot.worktreeId) - this.published.set(snapshot.worktreeId, entry) - this.sink(sequenced(entry)) + for (const snapshot of [...withdrawn, ...next]) { + if (!isDeepStrictEqual(this.published.get(snapshot.worktreeId)?.snapshot, snapshot)) { + const entry = { publishSeq: ++this.lastSeq, snapshot: structuredClone(snapshot) } + this.published.set(snapshot.worktreeId, entry) + this.sink(sequenced(entry)) + } } } } From 5ada98f4f09486b411f128f5b45617d5bb6930c2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 02:52:54 -0400 Subject: [PATCH 47/75] fix(terminal): a tab or pane the window creates is committed to main before it spawns Creating a terminal tab or split pane is now a commit to main, like close and move: the tab row or leaf is recorded unbound in the worktree's home partition and the reply names the push holding it. The spawn later only binds. Every pending add clears through its reply, so a pane that never starts no longer blocks its tab's layout sends, and default tabs, failed spawns and SSH panes waiting for a connection survive quit. --- src/main/ipc/session.ts | 17 ++ .../loading-store/pty-binding-persistence.ts | 9 +- .../pty-binding-session-update.ts | 109 ++------- .../session-owner-fields.ts | 2 +- .../terminal-pane-placement-apply.ts | 84 ++++++- ...erminal-surface-create-quit-resume.test.ts | 230 ++++++++++++++++++ .../terminal-surface-create.ts | 51 ++++ .../terminal-topology-commit.ts | 54 ++-- src/preload/api/session-bridge.ts | 1 + src/preload/api/workspace-session-api.ts | 8 + .../terminal-pane-pane-created.ts | 32 +-- .../terminals/terminal-pending-panes.test.ts | 100 +++++++- .../store/terminals/terminal-pending-panes.ts | 49 +++- .../store/terminals/terminal-tab-creation.ts | 21 +- .../preload-api/web-workspace-session-api.ts | 2 + src/shared/terminal-pane-placement.ts | 2 +- src/shared/terminal-surface-create.ts | 30 +++ 17 files changed, 651 insertions(+), 150 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-surface-create.ts create mode 100644 src/shared/terminal-surface-create.ts diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index a6e1140c321b..973d4e4ad258 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -6,6 +6,10 @@ import { parseTerminalLayoutSetRequest, type TerminalLayoutSetResult } from '../../shared/terminal-layout-set' +import { + parseTerminalSurfaceCreateRequest, + type TerminalSurfaceCreateResult +} from '../../shared/terminal-surface-create' import { parseTerminalLeafBindRequest, type TerminalLeafBindResult @@ -116,6 +120,19 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic } ) + // A tab or pane the window created, held unbound until its spawn binds; the reply names the push. + ipcMain.handle('session:terminal-create-surface', async (_event, args: unknown) => { + const request = parseTerminalSurfaceCreateRequest(args) + if (!request) { + return { status: 'refused', reason: 'invalid_request' } satisfies TerminalSurfaceCreateResult + } + const hostId = runtime.getTerminalTopologyHomeHostId(request.worktreeId) + const result: TerminalSurfaceCreateResult = hostId + ? await store.createTerminalSurface(request, hostId) + : { status: 'refused', reason: 'home_unresolved' } + return { ...result, publishSeq: runtime.settleTerminalTopology(request.worktreeId) } + }) + // A gesture's geometry; the reply's publishSeq tells the window when main's push holds it. ipcMain.handle('session:terminal-set-layout', async (_event, args: unknown) => { const request = parseTerminalLayoutSetRequest(args) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 7488df42ac39..bcc62f2d4b22 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -30,7 +30,8 @@ import type { TerminalLayoutSetRequest, TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' -import { moveLeaf, setLayout } from '../terminal-topology/terminal-topology-commit' +import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' +import { createLeafOrTab, moveLeaf, setLayout } from '../terminal-topology/terminal-topology-commit' import { findTerminalBindingConflict } from '../terminal-topology/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< @@ -226,6 +227,12 @@ export class PtyBindingPersistenceOperations { return runtime.runDurableMutation(moveLeaf(request, hostId, topologyCommitContext(this))) } + /** A tab or pane the window created, recorded unbound in the worktree's home partition. */ + createTerminalSurface(request: TerminalSurfaceCreateRequest, hostId: ExecutionHostId) { + const { runtime } = this[ptyBindingPersistenceOperationsContext] + return runtime.runDurableMutation(createLeafOrTab(request, hostId, topologyCommitContext(this))) + } + /** A user's divider, direction or pane-order edit, written to the worktree's home partition. */ setTerminalTabLayout( request: TerminalLayoutSetRequest, diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index facd7e7d0758..655ba1fb94c8 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -1,16 +1,8 @@ import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { - cloneLayoutNode, - layoutContainsLeafId -} from '../restoring-sessions/terminal-layout-normalization' -import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' -import { - placedSplitRoot, - placedTerminalTab -} from '../terminal-topology/terminal-pane-placement-apply' +import { placeTerminalPane } from '../terminal-topology/terminal-pane-placement-apply' import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' import type { PersistPtyBindingArgs } from './pty-binding-persistence' @@ -33,17 +25,6 @@ export function applyPtyBinding( : undefined const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - const advanceTopologyFence = (): void => { - if (!reconciledIncarnation && !terminalMembershipChanged) { - return - } - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 - } - } if (args.incarnationId) { session.terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey, @@ -56,8 +37,18 @@ export function applyPtyBinding( delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) + // A spawn can beat the window's creation commit, so the binding can create its pane too. + const terminalMembershipChanged = placeTerminalPane( + session, + { + worktreeId: bindingWorktreeId, + tabId: args.tabId, + leafId: args.leafId, + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}) + }, + placement + ) + const tab = session.tabsByWorktree[bindingWorktreeId]?.find((t) => t.id === args.tabId) if (tab) { tab.ptyId = tabRowPtyIdAfterLeafBinding( tab, @@ -65,24 +56,12 @@ export function applyPtyBinding( args.leafId, args.ptyId ) - } else { - terminalMembershipChanged = true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const minted = createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - const nextTabs = [...(tabs ?? []), placedTerminalTab(minted, placement)] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + if (reconciledIncarnation || terminalMembershipChanged) { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 } } // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. @@ -100,53 +79,9 @@ export function applyPtyBinding( bindingWorktreeId ] } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - return - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + const layout = isTerminalLeafId(args.leafId) && session.terminalLayoutsByTabId[args.tabId] if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; a sender without placement gets a - // minimal leaf at the root so a crash can't strand the pane's binding. - layout.root = - placement?.kind === 'split' - ? placedSplitRoot(layout.root, args.leafId, placement) - : { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [args.leafId]: args.ptyId } } - advanceTopologyFence() } diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index 1ac9fec8935b..a0df9ec8bde5 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -22,7 +22,7 @@ export const OWNER_KEYED_SESSION_FIELDS_DELETED_WITH_THEIR_OWNER = export function createMinimalPersistedTerminalTab(args: { worktreeId: string tabId: string - ptyId: string + ptyId: string | null existingTabCount: number startupCwd?: string }): TerminalTab { diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts index 6a3aeb8cdcf7..517b9f2e43ea 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.ts @@ -4,12 +4,94 @@ import type { TerminalPaneSplitDirection, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' import { omitUndefinedValues } from '../../../shared/rpc-contract/ui-update-value-tolerance-params' -import { cloneLayoutNode } from '../restoring-sessions/terminal-layout-normalization' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' import { sameTerminalLeafSet } from './terminal-layout-set' type SplitPlacement = Extract +export type TerminalPaneToPlace = { + worktreeId: string + tabId: string + /** Absent for a tab created before its first pane. */ + leafId?: string + startupCwd?: string +} + +/** + * Records a new tab row and leaf, unbound, where `placement` puts them: the creation half of both + * the window's creation commit and a spawn's binding. Mutates `session`; true when membership changed. + */ +export function placeTerminalPane( + session: WorkspaceSessionState, + { worktreeId, tabId, leafId, startupCwd }: TerminalPaneToPlace, + placement: TerminalPanePlacement | undefined +): boolean { + let changed = false + const tabs = session.tabsByWorktree?.[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + changed = true + const minted = createMinimalPersistedTerminalTab({ + worktreeId, + tabId, + ptyId: null, + existingTabCount: tabs?.length ?? 0, + ...(startupCwd ? { startupCwd } : {}) + }) + session.tabsByWorktree = { + ...session.tabsByWorktree, + [worktreeId]: [...(tabs ?? []), placedTerminalTab(minted, placement)] + } + session.activeWorktreeId ??= worktreeId + session.activeTabId ??= tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [worktreeId]: session.activeTabIdByWorktree?.[worktreeId] ?? tabId + } + } + if (leafId === undefined || !isTerminalLeafId(leafId)) { + return changed + } + const layout = session.terminalLayoutsByTabId?.[tabId] + if (!layout) { + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [tabId]: { root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null } + } + return true + } + if (!layout.root) { + layout.root = { type: 'leaf', leafId } + layout.activeLeafId = leafId + layout.expandedLeafId = null + return true + } + if (layoutContainsLeafId(layout.root, leafId)) { + return changed + } + // A sender without placement gets a minimal leaf at the root, so a crash can't strand its pane. + layout.root = + placement?.kind === 'split' + ? placedSplitRoot(layout.root, leafId, placement) + : { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId } + } + layout.activeLeafId = leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + return true +} + /** * The minted row with the sender's creation fields over it; a field it omits keeps the minted * value, except `startupCwd`, which is the window's to set and never the spawn's cwd. diff --git a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts new file mode 100644 index 000000000000..d59ef76670f0 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts @@ -0,0 +1,230 @@ +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 type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({ + syncHandlers: new Map void>(), + invokeHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + on: ( + channel: string, + handler: (event: { returnValue?: unknown }, ...args: unknown[]) => void + ) => syncHandlers.set(channel, handler), + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => + invokeHandlers.set(channel, handler) + } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +import { registerRendererShutdownCheckpointHandler } from '../../ipc/renderer-shutdown-checkpoint' +import { registerSessionHandlers } from '../../ipc/session' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' + +// A tab or pane the window creates is main's from creation, so it outlives a spawn that fails, +// never starts, or waits on an SSH connection, through quit and relaunch. + +const SSH_WORKTREE = 'repo-remote::/fixture/remote' +const SSH_HOST: ExecutionHostId = 'ssh:build-host' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +const directories: string[] = [] + +afterEach(() => { + syncHandlers.clear() + invokeHandlers.clear() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function row(id: string, worktreeId: string, customTitle: string | null = null): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + customTitle, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +async function windowOnStore() { + const directory = mkdtempSync(join(tmpdir(), 'orca-surface-create-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + registerSessionHandlers(store, new OrcaRuntimeService(store)) + registerRendererShutdownCheckpointHandler(store) + return { + directory, + store, + create: (request: TerminalSurfaceCreateRequest) => + invokeHandlers.get('session:terminal-create-surface')!({}, request), + /** The window's quit: its session as it shows it, staged over main's topology. */ + stageQuit: (state: WorkspaceSessionState, hostId?: ExecutionHostId) => { + const event: { returnValue?: unknown } = {} + syncHandlers.get('app:stage-before-unload-sync')!(event, { + sessions: [{ state: structuredClone(state), ...(hostId ? { hostId } : {}) }], + ui: {} + }) + return event.returnValue + } + } +} + +function newTab(worktreeId: string, tab: TerminalTab, leafId?: string) { + return { + worktreeId, + tabId: tab.id, + ...(leafId ? { leafId } : {}), + placement: { kind: 'new-tab' as const, row: { title: tab.title, createdAt: tab.createdAt } } + } +} + +describe('a tab or pane the window creates, quit before it binds', () => { + it('keeps a background worktree’s default tabs, never mounted, and their applied mark', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tabs = [row('tab-default-1', WORKTREE, 'renamed'), row('tab-default-2', WORKTREE)] + for (const tab of tabs) { + await expect(create(newTab(WORKTREE, tab))).resolves.toMatchObject({ status: 'committed' }) + } + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [WORKTREE]: tabs } + window.defaultTerminalTabsAppliedByWorktreeId = { [WORKTREE]: true } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]?.map((tab) => [tab.id, tab.customTitle])).toEqual([ + ['tab-default-1', 'renamed'], + ['tab-default-2', null] + ]) + expect(session.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(true) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a split whose spawn failed, with its title, and a later spawn only binds it', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab = row('tab-split', WORKTREE) + // The tab first, then its first pane once it mounts. + await create(newTab(WORKTREE, tab)) + await create(newTab(WORKTREE, tab, LEFT)) + const split = { + worktreeId: WORKTREE, + tabId: tab.id, + leafId: RIGHT, + placement: { kind: 'split' as const, parentLeafId: LEFT, direction: 'horizontal' as const } + } + await expect(create(split)).resolves.toMatchObject({ status: 'committed' }) + // Idempotent: the same creation again changes nothing. + const before = structuredClone(store.getWorkspaceSession()) + await expect(create(split)).resolves.toMatchObject({ status: 'committed' }) + expect(store.getWorkspaceSession()).toEqual(before) + + const window = structuredClone(store.getWorkspaceSession()) + window.terminalLayoutsByTabId[tab.id] = { + ...window.terminalLayoutsByTabId[tab.id], + titlesByLeafId: { [RIGHT]: 'build watch' } + } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const expectedRoot = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } + const layout = relaunched.getWorkspaceSession().terminalLayoutsByTabId[tab.id] + expect(layout?.root).toEqual(expectedRoot) + expect(layout?.titlesByLeafId).toEqual({ [RIGHT]: 'build watch' }) + + // The spawn's placement for a pane main already holds only binds it. + await expect( + relaunched.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: tab.id, + leafId: RIGHT, + ptyId: 'pty-right', + placement: split.placement + }) + ).resolves.toBe(true) + const bound = relaunched.getWorkspaceSession().terminalLayoutsByTabId[tab.id] + expect(bound?.root).toEqual(expectedRoot) + expect(bound?.ptyIdsByLeafId).toEqual({ [RIGHT]: 'pty-right' }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps an SSH pane waiting for its connection, in the SSH host’s partition', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab = row('tab-ssh', SSH_WORKTREE) + await expect(create(newTab(SSH_WORKTREE, tab, LEFT))).resolves.toMatchObject({ + status: 'committed' + }) + expect(stageQuit(store.getWorkspaceSession(SSH_HOST), SSH_HOST)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession(SSH_HOST) + expect(session.tabsByWorktree[SSH_WORKTREE]?.map((entry) => entry.id)).toEqual([tab.id]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + expect(relaunched.getWorkspaceSession().tabsByWorktree[SSH_WORKTREE]).toBeUndefined() + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('refuses a tab main recorded as closed, and a split of a pane it does not hold', async () => { + const { store, create } = await windowOnStore() + const tab = row('tab-closed', WORKTREE) + await create(newTab(WORKTREE, tab, LEFT)) + await invokeHandlers.get('session:close-terminal-surface')!( + {}, + { worktreeId: WORKTREE, target: { kind: 'tab', tabId: tab.id } } + ) + await expect(create(newTab(WORKTREE, tab, LEFT))).resolves.toMatchObject({ + status: 'refused', + reason: 'tab_not_held' + }) + const open = row('tab-open', WORKTREE) + await create(newTab(WORKTREE, open, LEFT)) + await expect( + create({ + worktreeId: WORKTREE, + tabId: open.id, + leafId: RIGHT, + placement: { kind: 'split', parentLeafId: RIGHT, direction: 'vertical' } + }) + ).resolves.toMatchObject({ status: 'refused', reason: 'parent_missing' }) + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE]?.map((entry) => entry.id)).toEqual([ + open.id + ]) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-surface-create.ts b/src/main/persistence/terminal-topology/terminal-surface-create.ts new file mode 100644 index 000000000000..499c1cb9d05f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-surface-create.ts @@ -0,0 +1,51 @@ +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' +import type { + TerminalSurfaceCreateRequest, + TerminalSurfaceCreateResult +} from '../../../shared/terminal-surface-create' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { placeTerminalPane } from './terminal-pane-placement-apply' +import { advanceTerminalTopologyRevision } from './terminal-topology-membership' + +/** + * Records a tab or pane the window created in the worktree's home partition, unbound; its spawn + * later only binds. A new tab needs a `new-tab` placement and an id never closed; a pane joins an + * empty tree, or a split of a pane main holds. `session` is null when nothing changes. + */ +export function planTerminalSurfaceCreate( + home: WorkspaceSessionState, + request: TerminalSurfaceCreateRequest +): { result: TerminalSurfaceCreateResult; session: WorkspaceSessionState | null } { + const { worktreeId, tabId, leafId, placement } = request + const refuse = (reason: 'tab_not_held' | 'parent_missing') => ({ + result: { status: 'refused', reason } as const, + session: null + }) + const tabHeld = home.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true + if ( + !tabHeld && + (placement.kind !== 'new-tab' || + hasClosedTerminalTabRecord(home.closedTerminalTabTombstonesByTabId, tabId)) + ) { + return refuse('tab_not_held') + } + const root = home.terminalLayoutsByTabId?.[tabId]?.root ?? null + if (tabHeld && (leafId === undefined || layoutContainsLeafId(root, leafId))) { + return { result: { status: 'committed' }, session: null } + } + if ( + leafId !== undefined && + root && + !(placement.kind === 'split' && layoutContainsLeafId(root, placement.parentLeafId)) + ) { + return refuse('parent_missing') + } + const session = cloneWorkspaceSessionState(home) + placeTerminalPane(session, { worktreeId, tabId, leafId }, placement) + return { + result: { status: 'committed' }, + session: advanceTerminalTopologyRevision(session, worktreeId) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 4fa45769e085..c256d1571aa1 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -12,6 +12,7 @@ import type { TerminalLayoutSetResult } from '../../../shared/terminal-layout-set' import type { TerminalLeafBindRequest } from '../../../shared/terminal-leaf-bind' +import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' import type { TerminalSleepingRecordChanges } from '../../../shared/terminal-topology-slice' import type { WorkspaceSessionPatch, @@ -27,14 +28,17 @@ import type { DurableProfileStateMutation } from '../loading-store/store-runtime import type { Store } from '../loading-store/store' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { planTerminalLayoutSet } from './terminal-layout-set' +import { planTerminalSurfaceCreate } from './terminal-surface-create' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings, sleeping agents): -// every window-requested topology change commits and is traced here. A spawn's binding commits -// through `persistPtyBinding`, which `bindLeaf` also uses. +// every window-requested topology change, creation included, commits and is traced here. A spawn's +// binding commits through `persistPtyBinding`, which `bindLeaf` also uses. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ type TerminalTopologyCommitKind = + | 'create_tab' + | 'create_leaf' | 'close_leaf' | 'close_tab' | 'move_leaf' @@ -52,6 +56,19 @@ export function closeLeafOrTab( ) } +/** Records a tab or pane the window created, unbound, in `hostId`, the worktree's home. */ +export function createLeafOrTab( + request: TerminalSurfaceCreateRequest, + hostId: ExecutionHostId, + context: TerminalTopologyCommitContext +) { + return traced( + request.placement.kind === 'new-tab' ? 'create_tab' : 'create_leaf', + commitPlan((home) => planTerminalSurfaceCreate(home, request), hostId, context), + (result) => (result.status === 'refused' ? result.reason : undefined) + ) +} + /** Moves a leaf, its binding and its pane-keyed records into a new tab in `hostId`, the worktree's home. */ export function moveLeaf( request: TerminalLeafMoveRequest, @@ -85,18 +102,7 @@ export function setLayout( ): () => DurableProfileStateMutation { return traced( 'set_layout', - () => { - const planned = planTerminalLayoutSet(context.getSession(hostId), request) - if (!planned.session) { - return { value: planned.result, persist: false } - } - const rollback = writeRestorable( - () => context.getSession(hostId), - (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), - planned.session - ) - return { value: planned.result, rollback } - }, + commitPlan((home) => planTerminalLayoutSet(home, request), hostId, context), (result) => (result.status === 'refused' ? result.reason : undefined) ) } @@ -267,6 +273,26 @@ function writeRestorable(read: () => V, write: (value: V) => void, next: V): } } +/** Writes the session `plan` makes of `hostId`'s; a null session writes nothing. */ +function commitPlan( + plan: (home: WorkspaceSessionState) => { result: T; session: WorkspaceSessionState | null }, + hostId: ExecutionHostId, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return () => { + const planned = plan(context.getSession(hostId)) + if (!planned.session) { + return { value: planned.result, persist: false } + } + const rollback = writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + planned.session + ) + return { value: planned.result, rollback } + } +} + function commitLeafMove( request: TerminalLeafMoveRequest, hostId: ExecutionHostId, diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index e1358cba8439..948f3517d503 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -8,6 +8,7 @@ export const sessionApi = { listHostIds: () => ipcRenderer.invoke('session:list-host-ids'), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + createTerminalSurface: (args) => ipcRenderer.invoke('session:terminal-create-surface', args), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), commitTerminalSleepingRecords: (changes) => ipcRenderer.invoke('session:commit-terminal-sleeping-records', changes), diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 51ca09b4a7a0..e3306493e17f 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -9,6 +9,10 @@ import type { TerminalLayoutSetRequest, TerminalLayoutSetResult } from '../../shared/terminal-layout-set' +import type { + TerminalSurfaceCreateRequest, + TerminalSurfaceCreateResult +} from '../../shared/terminal-surface-create' import type { TerminalLeafBindRequest, TerminalLeafBindResult @@ -34,6 +38,10 @@ export type WorkspaceSessionApi = { listHostIds: () => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + /** Commits a terminal tab or split pane this window created into main's membership, unbound. */ + createTerminalSurface: ( + args: TerminalSurfaceCreateRequest + ) => Promise /** Commits a terminal tab or split-pane close into main's membership. */ closeTerminalSurface: (args: { worktreeId: string diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index d2b7b0b8554a..4b264e232e8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -1,7 +1,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types' import { useAppStore } from '@/store' -import { markTerminalPaneIfAheadOfMain } from '@/store/terminals/terminal-pending-panes' +import { commitTerminalPaneIfAheadOfMain } from '@/store/terminals/terminal-pending-panes' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createOsc52OscHandler } from './osc52-clipboard' import { @@ -50,11 +50,21 @@ export function createTerminalPaneCreatedHandler( deps const { deferredSplitHandoffs } = context const paneKey = makePaneKey(deps.tabId, pane.leafId) - markTerminalPaneIfAheadOfMain(useAppStore.getState(), { - worktreeId: deps.worktreeId, - tabId: deps.tabId, - leafId: pane.leafId - }) + const placement = + spawnHints?.placement && + completePaneSpawnPlacement(spawnHints.placement, { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + container: deps.containerRef.current + }) + if (placement) { + commitTerminalPaneIfAheadOfMain(useAppStore.getState(), { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + leafId: pane.leafId, + placement + }) + } const restoredPtyId = ptyDeps.restoredPtyIdByLeafId?.[pane.leafId] const hasAuthoritativeSpawnHint = Boolean(spawnHints?.cwd || spawnHints?.ptyId || restoredPtyId) let effectiveSpawnHints = spawnHints @@ -209,15 +219,7 @@ export function createTerminalPaneCreatedHandler( : {}), ...(effectiveSpawnHints?.cwd ? { cwd: effectiveSpawnHints.cwd } : {}), ...(effectiveSpawnHints?.cwdPromise ? { cwdPromise: effectiveSpawnHints.cwdPromise } : {}), - ...(effectiveSpawnHints?.placement - ? { - placement: completePaneSpawnPlacement(effectiveSpawnHints.placement, { - worktreeId: deps.worktreeId, - tabId: deps.tabId, - container: deps.containerRef.current - }) - } - : {}), + ...(placement ? { placement } : {}), restoredPtyIdByLeafId: effectiveSpawnHints?.ptyId ? { ...ptyDeps.restoredPtyIdByLeafId, diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts index f0ccd701f76f..573c6f6a58e8 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -5,7 +5,11 @@ import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' -import { markTerminalPaneIfAheadOfMain, pendingTerminalLeafIds } from './terminal-pending-panes' +import { + commitTerminalPaneIfAheadOfMain, + pendingTerminalLeafIds, + terminalTabPanesNamed +} from './terminal-pending-panes' import { makeWorktree } from '../slices/store-test-helpers' const WT = 'repo::/wt' @@ -44,20 +48,25 @@ function slice(publishSeq: number, tabs: Record): TerminalTopo } } -/** Main's close replies are released by the test, so either order can be driven. */ -function stubCloseReplies(): { +/** Main's replies are released by the test, so either order can be driven. */ +function stubReplies(method: 'closeTerminalSurface' | 'createTerminalSurface'): { reply: (answer: TerminalTopologyReply | Error) => void - closeTerminalSurface: ReturnType + calls: ReturnType } { const pending: ((answer: TerminalTopologyReply | Error) => void)[] = [] - const closeTerminalSurface = vi.fn( + const calls = vi.fn( () => new Promise((resolve, reject) => { pending.push((answer) => (answer instanceof Error ? reject(answer) : resolve(answer))) }) ) - vi.stubGlobal('window', { api: { session: { closeTerminalSurface } } }) - return { reply: (answer) => pending.shift()?.(answer), closeTerminalSurface } + vi.stubGlobal('window', { api: { session: { [method]: calls } } }) + return { reply: (answer) => pending.shift()?.(answer), calls } +} + +const stubCloseReplies = () => { + const { reply, calls } = stubReplies('closeTerminalSurface') + return { reply, closeTerminalSurface: calls } } const settled = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -90,6 +99,23 @@ describe('a tab this window creates', () => { expect(tabIds()).toEqual(['a']) }) + it('is committed to main as a new tab, and settles by the reply even if main never names it', async () => { + const main = stubReplies('createTerminalSurface') + const tab = state().createTab(WT) + expect(main.calls).toHaveBeenCalledWith({ + worktreeId: WT, + tabId: tab.id, + placement: { kind: 'new-tab', row: expect.objectContaining({ title: tab.title }) } + }) + + // Main refused it (say, closed elsewhere): the push holding the reply drops it here. + main.reply({ publishSeq: 2 }) + await settled() + apply(slice(2, { a: [LEAF_A] })) + expect(tabIds()).toEqual(['a']) + expect(state().pendingTerminalPanes).toEqual([]) + }) + it('keeps a pane main has not named yet, and only that one', () => { state().markPendingTerminalPane({ worktreeId: WT, tabId: 'a', leafId: LEAF_B, change: 'add' }) expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'add')).toEqual( @@ -108,8 +134,16 @@ describe('a worktree a paired Orca server hosts', () => { repo: [makeWorktree({ id: WT, repoId: 'repo', hostId: 'runtime:env-1' })] } }) + const create = stubReplies('createTerminalSurface') const tab = state().createTab(WT) - markTerminalPaneIfAheadOfMain(state(), { worktreeId: WT, tabId: tab.id, leafId: LEAF_B }) + commitTerminalPaneIfAheadOfMain(state(), { + worktreeId: WT, + tabId: tab.id, + leafId: LEAF_B, + placement: { kind: 'new-tab' } + }) + + expect(create.calls).not.toHaveBeenCalled() expect(state().pendingTerminalPanes).toEqual([]) }) @@ -172,3 +206,53 @@ describe('a pane this window closes', () => { expect(state().pendingTerminalPanes).toEqual([]) }) }) + +describe('a split pane this window creates', () => { + const split = { + worktreeId: WT, + tabId: 'a', + leafId: LEAF_B, + placement: { kind: 'split' as const, parentLeafId: LEAF_A, direction: 'vertical' as const } + } + + it('is committed to main before any spawn, and settles by the reply', async () => { + const main = stubReplies('createTerminalSurface') + commitTerminalPaneIfAheadOfMain(state(), split) + expect(main.calls).toHaveBeenCalledWith(split) + expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'add')).toEqual( + new Set([LEAF_B]) + ) + main.reply({ publishSeq: 2 }) + await settled() + apply(slice(2, { a: [LEAF_A, LEAF_B] })) + expect(state().pendingTerminalPanes).toEqual([]) + }) + + // A pane whose process never starts used to hold its tab's layout sends, and their listener, forever. + it('never starting does not hold its tab’s layout sends or leak a store listener', async () => { + const main = stubReplies('createTerminalSurface') + let listeners = 0 + const subscribe = (listener: () => void): (() => void) => { + listeners += 1 + const unsubscribe = useAppStore.subscribe(listener) + return () => { + listeners -= 1 + unsubscribe() + } + } + commitTerminalPaneIfAheadOfMain(state(), split) + let sent = false + void terminalTabPanesNamed(subscribe, state, 'a').then(() => (sent = true)) + await settled() + expect(sent).toBe(false) + expect(listeners).toBe(1) + + // Settled by the reply's push alone, whether or not main named the pane. + main.reply({ publishSeq: 2 }) + await settled() + apply(slice(2, { a: [LEAF_A] })) + await settled() + expect(sent).toBe(true) + expect(listeners).toBe(0) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index 098b3249300c..98e133e7b7d5 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -1,5 +1,6 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalSurfaceCreateRequest } from '../../../../shared/terminal-surface-create' import type { TerminalTopologyReply, TerminalTopologySlice @@ -15,10 +16,10 @@ export type PendingTerminalPaneKey = { worktreeId: string; tabId: string; leafId /** * A tab or pane this window shows (`add`) or hides (`remove`), or a tab tree it keeps (`layout`, - * a user's geometry edit), before main's topology does. An add stands until a slice names it; a - * remove or layout until the slice holding main's reply (`publishSeq`). A layout also ends once - * main's tab has other panes than its tree, apart from panes added here and not yet named, since - * main refuses it then. + * a user's geometry edit), before main's topology does. Each stands until the slice holding main's + * reply (`publishSeq`); an add also ends once a slice names it. A layout also ends once main's tab + * has other panes than its tree, apart from panes added here and not yet named, since main + * refuses it then. */ export type PendingTerminalPane = PendingTerminalPaneKey & { publishSeq?: number } & ( | { change: 'add' | 'remove' } @@ -122,14 +123,38 @@ export function isTerminalTabMirroredFromMain( ) } -/** A pane this window made, which main's layout doesn't name yet, stays until main names it. */ -export function markTerminalPaneIfAheadOfMain( +/** + * Commits a tab or pane this window created into main, unbound, and shows it here until the push + * holding main's reply. The entry names the tab for a new tab, else the pane. + */ +export function commitTerminalSurfaceCreate( + state: AppState, + request: TerminalSurfaceCreateRequest +): void { + const { worktreeId, tabId, leafId, placement } = request + if (!isTerminalTabMirroredFromMain(state, worktreeId, tabId)) { + return + } + commitPendingTerminalChange( + state, + { + worktreeId, + tabId, + ...(placement.kind !== 'new-tab' && leafId ? { leafId } : {}), + change: 'add' + }, + // Why optional: an older preload can linger through an in-place renderer reload. + () => globalThis.window?.api?.session?.createTerminalSurface?.(request) + ) +} + +/** A pane this window made, which main's layout doesn't name yet, is created there. */ +export function commitTerminalPaneIfAheadOfMain( state: AppState, - pane: Required + pane: TerminalSurfaceCreateRequest & { leafId: string } ): void { - const named = collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId) - if (!named && isTerminalTabMirroredFromMain(state, pane.worktreeId, pane.tabId)) { - state.markPendingTerminalPane({ ...pane, change: 'add' }) + if (!collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId)) { + commitTerminalSurfaceCreate(state, pane) } } @@ -146,8 +171,8 @@ export function pendingAfterTerminalTopologySlice( if (entry.worktreeId !== slice.worktreeId) { return false } - if (entry.change === 'add') { - return named(entry) + if (entry.change === 'add' && named(entry)) { + return true } // Panes added here that main hasn't named yet don't make a gesture's tree stale. const unnamed = new Set( diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 74ec487b8ef4..89e3e8233e3b 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -20,7 +20,8 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { ownsGlobalSelection } from '../global-selection-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { isTerminalTabMirroredFromMain, withPendingTerminalPane } from './terminal-pending-panes' +import { commitTerminalSurfaceCreate } from './terminal-pending-panes' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' import { getRemoteConnectionIdForWorktree, resolveCreatedTabShellOverride, @@ -62,6 +63,7 @@ export function createTerminalTabCreationActions( return { createTab: (worktreeId, targetGroupId, shellOverride, options) => { let tab!: TerminalTab + let initialLeafId: string | undefined set((s) => { const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) @@ -90,7 +92,7 @@ export function createTerminalTabCreationActions( : undefined // Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation. // Why a bare leaf id too: a host launch lays out its pane before the process it attaches to exists. - const initialLeafId = + initialLeafId = options?.initialPtyId || options?.pendingStartup || requestedInitialLeafId ? (requestedInitialLeafId ?? createBrowserUuid()) : undefined @@ -230,14 +232,6 @@ export function createTerminalTabCreationActions( ...orphanCleanupPatch.tabsByWorktree, [worktreeId]: [...existing, tab] }, - // Shown before main's topology names it; its pane's spawn tells main. - ...(isTerminalTabMirroredFromMain(s, worktreeId, id) && { - pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, { - worktreeId, - tabId: id, - change: 'add' - }) - }), // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, @@ -293,6 +287,13 @@ export function createTerminalTabCreationActions( } } }) + // Main records the tab now, so it outlives a spawn that fails or never starts. + commitTerminalSurfaceCreate(get(), { + worktreeId, + tabId: tab.id, + ...(initialLeafId ? { leafId: initialLeafId } : {}), + placement: { kind: 'new-tab', row: terminalPanePlacementRow(tab) } + }) if (options?.initialPtyId) { // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. clearWorktreeSleepIntent(worktreeId) diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 80a9599397e9..f7fc3b18c28d 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -81,6 +81,8 @@ export function createWebWorkspaceSessionApi(): Partial { }) ) }, + // Why refused: web tabs and panes are created on the host through its session-tab RPCs. + createTerminalSurface: async () => ({ status: 'refused', reason: 'tab_not_held' }), // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. closeTerminalSurface: async () => ({}), // Web clients keep sleeping records in their saved session. diff --git a/src/shared/terminal-pane-placement.ts b/src/shared/terminal-pane-placement.ts index 9627f6d9edc6..6b8f084c5c23 100644 --- a/src/shared/terminal-pane-placement.ts +++ b/src/shared/terminal-pane-placement.ts @@ -39,7 +39,7 @@ const SplitPlacement = z.object({ // An existing tab whose layout is still empty. const RootPlacement = z.object({ kind: z.literal('root') }) -const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ +export const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ NewTabPlacement, SplitPlacement, RootPlacement diff --git a/src/shared/terminal-surface-create.ts b/src/shared/terminal-surface-create.ts new file mode 100644 index 000000000000..eb902aa3b6f0 --- /dev/null +++ b/src/shared/terminal-surface-create.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { isTerminalLeafId } from './stable-pane-id' +import { TerminalPanePlacementSchema } from './terminal-pane-placement' + +const id = z.string().min(1).max(512) +const TerminalSurfaceCreateRequestSchema = z.object({ + worktreeId: id, + tabId: id, + /** Absent for a tab created before its first pane exists. */ + leafId: id.refine((leafId): boolean => isTerminalLeafId(leafId)).optional(), + placement: TerminalPanePlacementSchema +}) + +/** A tab or pane the window created, recorded unbound in main before any process starts. */ +export type TerminalSurfaceCreateRequest = z.infer + +export type TerminalSurfaceCreateResult = + | { status: 'committed' } + | { + status: 'refused' + reason: 'invalid_request' | 'home_unresolved' | 'tab_not_held' | 'parent_missing' + } + +/** Null for anything malformed. */ +export function parseTerminalSurfaceCreateRequest( + value: unknown +): TerminalSurfaceCreateRequest | null { + const parsed = TerminalSurfaceCreateRequestSchema.safeParse(value) + return parsed.success ? parsed.data : null +} From a61b1abfe67b3f00919eeee45452bb808e45592c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 02:57:07 -0400 Subject: [PATCH 48/75] fix(ssh): an SSH import keeps main's rows newer than the window's merge; exports coalesce a burst An import patched the window's merged copy over main's ssh: partition, so a tab or binding main committed after the window read its mirror was dropped. The import now carries the window's mirror seq per worktree; on a worktree main has published past, main's rows stand and the pull only adds host tabs main neither holds nor closed, and the kept rows export. Exports coalesce on a trailing edge, so a split's binding write and the window's save after it upload one host patch. --- .../remote-workspace-export-test-harness.ts | 5 + src/main/ipc/remote-workspace-export.ts | 25 ++--- .../ipc/remote-workspace-two-desktops.test.ts | 39 +++++++- src/main/ipc/remote-workspace.test.ts | 48 ++++++---- src/main/ipc/remote-workspace.ts | 24 +++-- .../terminal-peer-import-rows.ts | 48 ++++++++++ .../terminal-topology-commit.test.ts | 94 ++++++++++++++++++- .../terminal-topology-commit.ts | 25 +++-- .../hooks/remote-workspace-snapshot-apply.ts | 1 + src/shared/remote-workspace-types.ts | 2 + 10 files changed, 262 insertions(+), 49 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-peer-import-rows.ts diff --git a/src/main/ipc/remote-workspace-export-test-harness.ts b/src/main/ipc/remote-workspace-export-test-harness.ts index ba592a07025b..fed2d0af4bd1 100644 --- a/src/main/ipc/remote-workspace-export-test-harness.ts +++ b/src/main/ipc/remote-workspace-export-test-harness.ts @@ -10,6 +10,11 @@ import type { import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { registerRemoteWorkspaceHandlers } from './remote-workspace' +/** Past main's export coalescing window and its starvation cap; tests run it on fake timers. */ +export async function passExportWindow(): Promise { + await vi.advanceTimersByTimeAsync(5_000) +} + export const EMPTY_WORKSPACE_SESSION: WorkspaceSessionState = { activeRepoId: null, activeWorktreeId: null, diff --git a/src/main/ipc/remote-workspace-export.ts b/src/main/ipc/remote-workspace-export.ts index 238243b90095..8b7b90b8c164 100644 --- a/src/main/ipc/remote-workspace-export.ts +++ b/src/main/ipc/remote-workspace-export.ts @@ -18,6 +18,7 @@ import { type WorktreeOwnerResolver } from './remote-workspace-target-session-export' import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' +import { createKeyedTrailingEdgeCoalescer } from '../runtime/keyed-trailing-edge-coalescer' import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' import { cachedRemoteWorkspaceSnapshotAuthorizesRevision, @@ -25,6 +26,12 @@ import { } from './remote-workspace-snapshot-cache' import { remoteWorkspaceSessionsMatch } from './remote-workspace-snapshot-normalization' +/** Outlasts the window's 150ms save debounce after a write main made for it. */ +const EXPORT_QUIET_MS = 500 +const EXPORT_MAX_WAIT_MS = 2_000 +/** One coalesced export covers every agreed host, so ownership resolves once for all of them. */ +const EXPORT_KEY = 'all-hosts' + /** * What this desktop and a host agree the host holds; null when the host's copy is unknown. It holds * only over the connection it was reached on: after a reconnect the host may have moved on, and @@ -53,7 +60,6 @@ export function createRemoteWorkspaceExports( sendPushStatus: (event: RemoteWorkspacePushStatusEvent) => void ): RemoteWorkspaceExports { const agreements = new Map() - let exportQueued = false const readWorktreeOwners = (): WorktreeOwnerResolver => createWorktreeOwnerResolver(createRepoRowExecutionHostLookup(store.getRepos())) @@ -134,17 +140,12 @@ export function createRemoteWorkspaceExports( ) } - const exportChanged = (): void => { - // A write burst in one turn exports once. - if (exportQueued) { - return - } - exportQueued = true - queueMicrotask(() => { - exportQueued = false - void exportAll() - }) - } + // A write burst exports once: a split's binding write and the window's save after it are one patch. + const exportBurst = createKeyedTrailingEdgeCoalescer(() => void exportAll(), { + flushMs: EXPORT_QUIET_MS, + maxWaitMs: EXPORT_MAX_WAIT_MS + }) + const exportChanged = (): void => exportBurst.schedule(EXPORT_KEY) const recordPull: RemoteWorkspaceExports['recordPull'] = ({ targetId, diff --git a/src/main/ipc/remote-workspace-two-desktops.test.ts b/src/main/ipc/remote-workspace-two-desktops.test.ts index c2a9618576eb..42db5a6e0db7 100644 --- a/src/main/ipc/remote-workspace-two-desktops.test.ts +++ b/src/main/ipc/remote-workspace-two-desktops.test.ts @@ -6,7 +6,7 @@ * Each desktop is its own module graph (own snapshot cache, patch queue and CLIENT_ID), as two * processes would be. The relay is an in-memory workspace with revisions. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import type { RemoteWorkspaceChangedEvent, @@ -222,9 +222,10 @@ async function bootDesktop(): Promise { return desktop } +/** Lets every export, the peer's import of it, and any export that causes run out. */ async function settle(): Promise { for (let turn = 0; turn < 5; turn += 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.advanceTimersByTimeAsync(5_000) } } @@ -232,7 +233,12 @@ describe('two desktops, one relay', () => { let a: Desktop let b: Desktop + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(async () => { + vi.useFakeTimers() relay.revision = 3 relay.session = { activeWorktreePath: null, @@ -275,6 +281,35 @@ describe('two desktops, one relay', () => { expect(relay.revision).toBe(5) }) + it("exports a split's binding write and the window's save after it as one patch", async () => { + const split = withSplit( + { ...a.driver.readSession(), tabsByWorktree: { [WORKTREE_ID]: [tab('tab-a')] } }, + 'tab-a' + ) + const layout = split.terminalLayoutsByTabId['tab-a'] + // Main binds the new pane's PTY, then the window saves its focus on that pane. + a.driver.write({ + ...split, + terminalLayoutsByTabId: { + 'tab-a': { ...layout, ptyIdsByLeafId: { 'tab-a-a': 'pty-1', 'tab-a-b': 'pty-2' } } + } + }) + await vi.advanceTimersByTimeAsync(200) + a.driver.write({ + terminalLayoutsByTabId: { + 'tab-a': { + ...a.driver.readSession().terminalLayoutsByTabId['tab-a'], + activeLeafId: 'tab-a-b' + } + } + }) + await settle() + + expect(relay.patches).toEqual([{ clientId: a.clientId, baseRevision: 3 }]) + expect(relay.session.terminalLayoutsByTabId['tab-a']).toMatchObject({ activeLeafId: 'tab-a-b' }) + expect(b.imports).toBe(1) + }) + it('uploads a tab created during a download once, and the desktops converge', async () => { // B stands on the shared worktree and A does not, so each merge keeps a different active // worktree; that difference alone must not bounce uploads between them. diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index f1d83f3a7424..b963fb180003 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { @@ -55,6 +55,7 @@ vi.mock('./remote-workspace-events', () => ({ import { _resetRemoteWorkspaceCachesForTests } from './remote-workspace' import { createRemoteWorkspaceExportDriver, + passExportWindow, type RemoteWorkspaceExportDriver } from './remote-workspace-export-test-harness' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' @@ -212,7 +213,18 @@ describe('main exports a session write to the hosts it agrees with', () => { ) } + async function pushesAfterExportWindow() { + const pushes = driver.nextPushes() + await passExportWindow() + return pushes + } + + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() _resetRemoteWorkspaceCachesForTests() requestByTargetId.clear() muxByTargetId.clear() @@ -292,7 +304,7 @@ describe('main exports a session write to the hosts it agrees with', () => { ) driver.write({ ...baseSession, tabsByWorktree: worktrees }) - await driver.nextPushes() + await pushesAfterExportWindow() expect(getReposMock).toHaveBeenCalledTimes(1) }) @@ -311,7 +323,7 @@ describe('main exports a session write to the hosts it agrees with', () => { ) driver.write({ ...baseSession, tabsByWorktree: worktrees }) - await driver.nextPushes() + await pushesAfterExportWindow() expect(getReposMock).toHaveBeenCalledTimes(1) // 6 worktree keys resolved once each, regardless of how many targets are exported to. @@ -327,7 +339,7 @@ describe('main exports a session write to the hosts it agrees with', () => { getReposMock.mockClear() driver.write(sessionWithTab) - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(getReposMock).not.toHaveBeenCalled() expect(driver.pushes).toEqual([]) @@ -350,7 +362,7 @@ describe('main exports a session write to the hosts it agrees with', () => { }) driver.write(sessionWithTab) - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(getActiveMultiplexerMock).not.toHaveBeenCalled() expect(driver.pushes).toEqual([]) @@ -361,7 +373,7 @@ describe('main exports a session write to the hosts it agrees with', () => { driver.agree('missing-target', { revision: 7, hostObservationToken: 'unreachable' }) driver.write(sessionWithTab) - const [push] = await driver.nextPushes() + const [push] = await pushesAfterExportWindow() expect(push).toMatchObject({ targetId: 'target-1', result: { ok: true } }) expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2') @@ -378,7 +390,7 @@ describe('main exports a session write to the hosts it agrees with', () => { await agreeWith('target-1') driver.write(sessionWithTab) - await driver.nextPushes() + await pushesAfterExportWindow() expect(patchRequests('target-1')).toEqual([ [ @@ -403,7 +415,7 @@ describe('main exports a session write to the hosts it agrees with', () => { driver.write(sessionWithTab) - await expect(driver.nextPushes()).resolves.toMatchObject([ + await expect(pushesAfterExportWindow()).resolves.toMatchObject([ { targetId: 'target-1', result: { ok: true } } ]) }) @@ -418,11 +430,11 @@ describe('main exports a session write to the hosts it agrees with', () => { }) // The window saves back what it applied from the import. driver.write(sessionWithTab) - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(patchRequests('target-1')).toEqual([]) driver.write({ ...sessionWithTab, activeTabId: null }) - await driver.nextPushes() + await pushesAfterExportWindow() expect(patchRequests('target-1')).toHaveLength(1) }) @@ -433,11 +445,11 @@ describe('main exports a session write to the hosts it agrees with', () => { ?.mockImplementationOnce(async () => ({ ok: false, reason: 'unavailable' })) driver.write(sessionWithTab) - await expect(driver.nextPushes()).resolves.toMatchObject([ + await expect(pushesAfterExportWindow()).resolves.toMatchObject([ { result: { ok: false, reason: 'unavailable' } } ]) driver.write({ ...sessionWithTab, activeTabId: null }) - await expect(driver.nextPushes()).resolves.toMatchObject([{ result: { ok: true } }]) + await expect(pushesAfterExportWindow()).resolves.toMatchObject([{ result: { ok: true } }]) expect(patchRequests('target-1')).toHaveLength(2) }) @@ -449,12 +461,12 @@ describe('main exports a session write to the hosts it agrees with', () => { muxByTargetId.delete('target-1') driver.write(sessionWithTab) - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(patchRequests('target-1')).toEqual([]) await agreeWith('target-1') driver.write({ ...sessionWithTab, activeTabId: null }) - await driver.nextPushes() + await pushesAfterExportWindow() expect(patchRequests('target-1')).toHaveLength(1) }) @@ -474,7 +486,7 @@ describe('main exports a session write to the hosts it agrees with', () => { await vi.waitFor(() => expect(patchRequests('target-1')).toHaveLength(1)) driver.importPeer({ ...observed, targetId: 'target-1', outcome: 'synced', session: {} }) finishPatch({ ok: false, reason: 'stale-revision', snapshot: snapshot(emptyRemoteSession, 9) }) - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(driver.pushes).toEqual([]) }) @@ -485,12 +497,12 @@ describe('main exports a session write to the hosts it agrees with', () => { driver.agree('target-1', observed, 'conflict') driver.write(sessionWithTab) - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(patchRequests('target-1')).toEqual([]) driver.agree('target-1', observed) driver.write({ ...sessionWithTab, activeTabId: null }) - await driver.nextPushes() + await pushesAfterExportWindow() expect(patchRequests('target-1')).toHaveLength(1) }) @@ -511,7 +523,7 @@ describe('main exports a session write to the hosts it agrees with', () => { driver.agree('target-1', observed, 'kept-local') - await new Promise((resolve) => setTimeout(resolve, 0)) + await passExportWindow() expect(patchRequests('target-1')).toHaveLength(hasTabs ? 1 : 0) expect(driver.pushes).toMatchObject( hasTabs ? [{ targetId: 'target-1', authority: { revision: 0 }, result: { ok: true } }] : [] diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 2ca5b9c38885..bedba0602275 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -13,6 +13,7 @@ import { toSshExecutionHostId } from '../../shared/execution-host' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' import { importPeerTopology } from '../persistence/terminal-topology/terminal-topology-commit' import { createRemoteWorkspaceExports } from './remote-workspace-export' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' import { CLIENT_ID, type RemoteWorkspaceClientNameSource } from './remote-workspace-client-identity' @@ -132,7 +133,8 @@ export function handleRemoteWorkspaceNotification( export function registerRemoteWorkspaceHandlers( store: Store, getMainWindow: () => BrowserWindow | null, - clientNameSource: RemoteWorkspaceClientNameSource + runtime: RemoteWorkspaceClientNameSource & + Partial> ): void { mainWindowGetter = getMainWindow unregisterRemoteWorkspaceNotifications?.() @@ -164,10 +166,20 @@ export function registerRemoteWorkspaceHandlers( if (!pull || !isValidPeerImport(pull)) { return } - if (!isFrozenOrcadSourceSessionPartition(store, toSshExecutionHostId(pull.targetId))) { - importPeerTopology(store, pull.targetId, pull.session) - } - exports.recordPull(pull) + const keptMainRows = + !isFrozenOrcadSourceSessionPartition(store, toSshExecutionHostId(pull.targetId)) && + importPeerTopology( + store, + pull.targetId, + pull.session, + (worktreeId) => + (runtime.settleTerminalTopology?.(worktreeId) ?? 0) > + (pull.mirroredTopologySeqByWorktree?.[worktreeId] ?? 0) + ) + // Rows main kept over the pull are news to the host, so they export. + exports.recordPull( + keptMainRows && pull.outcome === 'synced' ? { ...pull, outcome: 'kept-local' } : pull + ) } ) @@ -183,7 +195,7 @@ export function registerRemoteWorkspaceHandlers( ipcMain.handle( 'remoteWorkspace:listConnectedClients', async (_event, args?: { targetIds?: string[] }) => - listRemoteWorkspaceConnectedClients(args, clientNameSource) + listRemoteWorkspaceConnectedClients(args, runtime) ) ipcMain.handle('remoteWorkspace:clientId', () => CLIENT_ID) diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts b/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts new file mode 100644 index 000000000000..e7fccbc14628 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts @@ -0,0 +1,48 @@ +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' + +/** + * A pull's rows over main's: on a worktree main has published past what the window merged against, + * main's tabs and their layouts stand, and the pull adds only tabs main neither holds nor closed. + */ +export function overNewerMainRows( + pull: WorkspaceSessionPatch, + main: WorkspaceSessionState, + publishedSince: (worktreeId: string) => boolean +): WorkspaceSessionPatch { + if (!pull.tabsByWorktree) { + return pull + } + const tabsByWorktree = { ...pull.tabsByWorktree } + const layouts = { ...pull.terminalLayoutsByTabId } + const worktreeIds = new Set([ + ...Object.keys(main.tabsByWorktree), + ...Object.keys(pull.tabsByWorktree) + ]) + for (const worktreeId of [...worktreeIds].filter(publishedSince)) { + const mainTabs = main.tabsByWorktree[worktreeId] ?? [] + const held = new Set(mainTabs.map((tab) => tab.id)) + const hostOnly = (pull.tabsByWorktree[worktreeId] ?? []).filter( + (tab) => + !held.has(tab.id) && + !hasClosedTerminalTabRecord(main.closedTerminalTabTombstonesByTabId, tab.id, worktreeId) + ) + for (const tab of mainTabs) { + const layout = main.terminalLayoutsByTabId[tab.id] + if (layout) { + layouts[tab.id] = layout + } else { + delete layouts[tab.id] + } + } + tabsByWorktree[worktreeId] = [...mainTabs, ...hostOnly] + } + return { + ...pull, + tabsByWorktree, + ...(pull.terminalLayoutsByTabId && { terminalLayoutsByTabId: layouts }) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts index 75df4b8371a5..42539af8aa88 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -152,8 +152,9 @@ describe('persistence.terminal-topology span', () => { it("commits an SSH import into that target's partition alone", () => { const patchWorkspaceSession = vi.fn() - importPeerTopology({ patchWorkspaceSession }, 'target-1', { tabsByWorktree: {} }) - importPeerTopology({ patchWorkspaceSession }, 'target-1', {}) + const store = { getWorkspaceSession: () => session(), patchWorkspaceSession } + importPeerTopology(store, 'target-1', { tabsByWorktree: {} }, () => false) + importPeerTopology(store, 'target-1', {}, () => false) expect(patchWorkspaceSession.mock.calls).toEqual([[{ tabsByWorktree: {} }, HOST_ID]]) expect(records.map((record) => record.attributes)).toEqual([ @@ -166,3 +167,92 @@ describe('persistence.terminal-topology span', () => { ]) }) }) + +describe('an SSH import against a mirror main has published past', () => { + const NEW_TAB = 'tab-created-by-cli' + const HOST_TAB = 'tab-from-peer' + + // What main committed after the window read its mirror: a CLI-created tab, and the split's + // second pane bound to its PTY. + function mainAfterWindowRead(): WorkspaceSessionState { + const prior = session() + return { + ...prior, + tabsByWorktree: { + [WORKTREE_ID]: [...prior.tabsByWorktree[WORKTREE_ID], tab(NEW_TAB, 'pty-new')] + }, + terminalLayoutsByTabId: { + ...prior.terminalLayoutsByTabId, + [SPLIT_TAB]: { + ...prior.terminalLayoutsByTabId[SPLIT_TAB], + ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2-bound-late' } + } + } + } + } + + // The window's merge of the host snapshot over its older mirror: no CLI tab, the stale binding, + // and a tab a peer created on the host. + function windowPull() { + const read = session() + return { + tabsByWorktree: { + [WORKTREE_ID]: [...read.tabsByWorktree[WORKTREE_ID], tab(HOST_TAB, 'pty-peer')] + }, + terminalLayoutsByTabId: read.terminalLayoutsByTabId + } + } + + function importInto(main: WorkspaceSessionState, publishedSince: boolean) { + let written: Partial = {} + const kept = importPeerTopology( + { + getWorkspaceSession: () => main, + patchWorkspaceSession: (patch) => { + written = patch + } + }, + 'target-1', + windowPull(), + () => publishedSince + ) + return { kept, written } + } + + it("keeps main's newer rows and adds the host's new tab", () => { + const { kept, written } = importInto(mainAfterWindowRead(), true) + + expect(written.tabsByWorktree?.[WORKTREE_ID]?.map((row) => row.id)).toEqual([ + SPLIT_TAB, + PINNED_TAB, + NEW_TAB, + HOST_TAB + ]) + expect(written.terminalLayoutsByTabId?.[SPLIT_TAB]?.ptyIdsByLeafId?.[LEAF_2]).toBe( + 'pty-2-bound-late' + ) + expect(kept).toBe(true) + }) + + it('does not revive a tab main closed after the window read it', () => { + const main = mainAfterWindowRead() + const { written } = importInto( + { + ...main, + closedTerminalTabTombstonesByTabId: { + [HOST_TAB]: { closedAt: Date.now(), worktreeId: WORKTREE_ID, reason: 'user' } + } + }, + true + ) + + expect(written.tabsByWorktree?.[WORKTREE_ID]?.map((row) => row.id)).not.toContain(HOST_TAB) + }) + + it('applies the pull as the window merged it when main has published nothing since', () => { + const { kept, written } = importInto(mainAfterWindowRead(), false) + + expect(written).toEqual(windowPull()) + expect(kept).toBe(false) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 4fa45769e085..9fcb6d69d960 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -28,6 +28,7 @@ import type { Store } from '../loading-store/store' import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' import { planTerminalLayoutSet } from './terminal-layout-set' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' +import { overNewerMainRows } from './terminal-peer-import-rows' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings, sleeping agents): // every window-requested topology change commits and is traced here. A spawn's binding commits @@ -147,23 +148,29 @@ export function commitSleepingRecords( } } -/** A window's pull from an SSH host, written only to that host's partition, its worktrees' home. */ +/** + * A window's pull from an SSH host, written only to that host's partition, its worktrees' home. + * Main's rows newer than the window's merge stand; returns whether any did, as the host lacks them. + */ export function importPeerTopology( - store: Pick, + store: Pick, targetId: string, - session: WorkspaceSessionPatch -): void { - traced( + session: WorkspaceSessionPatch, + publishedSince: (worktreeId: string) => boolean +): boolean { + return traced( 'import_peer_topology', () => { if (Object.keys(session).length === 0) { - return { value: undefined, persist: false } + return { value: false, persist: false } } - store.patchWorkspaceSession(session, toSshExecutionHostId(targetId)) - return { value: undefined } + const hostId = toSshExecutionHostId(targetId) + const patch = overNewerMainRows(session, store.getWorkspaceSession(hostId), publishedSince) + store.patchWorkspaceSession(patch, hostId) + return { value: !structuralValuesEqual(patch, session) } }, () => undefined - )() + )().value } /** A sleeping agent's record lands beside its tab, in its worktree's home partition. */ diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index cf51b3c71ab4..1fae134fe98f 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -261,6 +261,7 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ : mergeKeptLocalState(merged, remoteSession, worktreeIds, localLayoutTabIds) ? 'kept-local' : 'synced', + mirroredTopologySeqByWorktree: state.terminalTopologySeqByWorktree, // Only the target's partition: the merge carries the window's copy of every other host's rows. session: buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore).find( diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index fa1718407790..507b39433690 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -83,6 +83,8 @@ export type RemoteWorkspacePeerImport = RemoteWorkspaceExportAuthority & { outcome: 'synced' | 'kept-local' | 'conflict' /** The import's patch to the target's own `ssh:` partition; empty when the host had nothing. */ session: WorkspaceSessionPatch + /** The window's mirror (`publishSeq` per worktree) the patch was merged against. */ + mirroredTopologySeqByWorktree?: Record } /** Main's report of one export it ran, for the window's sync status. */ From b6a77c62a27635e121c4175840e6a92298900efc Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:20:55 -0400 Subject: [PATCH 49/75] fix(terminal): a paired client cannot adopt the PTY of a pane the desktop just closed Closing one pane of a split commits the close before its PTY exits. In that window a paired web client saw a live PTY its host no longer listed and adopted it as an orphan, rewriting the tab to that pane alone; the exit then retired the tab and the surviving pane was re-adopted as a new tab at the end, untitled. A window save used to write the tab back; main is now the only writer, so adoption refuses a PTY main was asked to stop, as it refuses a closed tab. --- ...e-adopt-terminal-orphans-from-inventory.ts | 1 + ...output-and-worker-recovery-part-07.spec.ts | 53 +++++++++++++++++++ .../runtime-terminal-orphan-adoption.ts | 4 ++ 3 files changed, 58 insertions(+) diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 57158256605a..4a14989efe9e 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -63,6 +63,7 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, getLeaves: (ptyId) => this.getLeavesForPty(ptyId), getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)), + isStopRequested: (ptyId) => this.isPtyStopRequested(ptyId), getDisplayTitle: (pty) => getLatestPtyTitle(this.getPtyDisplayRecord(pty)), replayPersistedSurface: (pty, tabId, paneKey) => recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index b9f605ccf910..0d404c286604 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -198,6 +198,59 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('terminal_orphan_surface_retired') }) + // A desktop pane close commits before its PTY exits; a paired client sees that PTY as an orphan. + it('refuses to adopt the PTY of a pane closed while its stop is pending', async () => { + const makeRuntime = (): OrcaRuntimeService => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 } + }) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-closing', + incarnationId: 'inc-closing', + terminalHandle: 'term_closing', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + return runtime + } + const request = { + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 7, + claims: [ + { + terminal: 'term_closing', + ptyId: 'pty-closing', + incarnationId: 'inc-closing', + tabId: 'tab-closing', + leafId: HEADLESS_LEAF_ID + } + ] + } + const closing = makeRuntime() + closing.markPtyStopRequested('pty-closing') + + await expect(closing.adoptTerminalOrphans(request)).rejects.toThrow( + 'terminal_orphan_surface_retired' + ) + await expect(makeRuntime().adoptTerminalOrphans(request)).resolves.toMatchObject({ + adopted: true + }) + }) + it('keeps orphaned list and show writability aligned with the send gate', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 610cc7ae6dd6..8de75c62af17 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -20,6 +20,8 @@ type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[] getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined + /** Main was asked to stop the PTY, as a pane close does before its process exits. */ + isStopRequested: (ptyId: string) => boolean /** The title display surfaces show for the PTY, which an adopted tab persists. */ getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null /** Replays a binding the session already held: names the pane without claiming the graph holds it. */ @@ -189,7 +191,9 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { throw new Error('terminal_orphan_surface_occupied') } // Why the close record too: it outlives a host restart, which the client's retirement proofs do not. + // Why the stop: a closed pane's PTY outlives the close until it exits, and is not an orphan. if ( + ports.isStopRequested(claim.ptyId) || session.terminalSurfaceTombstonesByPaneKey?.[paneKey] || hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId) ) { From 7c8c7d55bb3cf1279fc0755d9331cd96867debfd Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:21:03 -0400 Subject: [PATCH 50/75] fix(terminal): a tab main creates shows the title its creator gave it `orca terminal create --title` reached the window through the reveal, which stamps a title only on a tab it creates. Main's mirror now creates that tab first, with no custom title, so the tab, `terminal list` and the saved profile all said "Terminal N". The create's title is now in the tab row main mints (as a window's own create already puts its row there), and a slice carries each tab's saved custom title and colour, which the mirror shows on a tab it has not shown before. A live title change still publishes nothing. A background create names no title, as before. --- .../runtime/orca-runtime-create-terminal.ts | 2 +- ...nal-creation-and-readiness-part-04.spec.ts | 23 +++ .../runtime-terminal-spawn-placement.ts | 15 +- .../terminal-topology-projection.test.ts | 4 +- .../runtime/terminal-topology-projection.ts | 9 +- .../terminal-topology-publisher.test.ts | 5 +- ...rminal-topology-runtime-publishing.test.ts | 8 +- .../terminal-pane-tab-detach-commit.test.ts | 1 + ...etup-split-activation-single-owner.test.ts | 1 + .../src/lib/terminal-topology-follow.test.ts | 1 + .../terminals/terminal-pending-layout.test.ts | 1 + .../terminals/terminal-pending-panes.test.ts | 1 + .../terminal-topology-mirror.test.ts | 22 +++ .../terminals/terminal-topology-mirror.ts | 18 ++- src/shared/terminal-topology-slice.ts | 4 + .../e2e/terminal-tab-title-and-place.spec.ts | 136 ++++++++++++++++++ 16 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/terminal-tab-title-and-place.spec.ts diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 8c4ed9ac7bf3..01b39c76b889 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - placement: dependencies.runtimeNewTabPlacement(), + placement: dependencies.runtimeNewTabPlacement(launchOpts.title, presentation), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index bd1c09dded0e..534891782482 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -250,6 +250,29 @@ describe('OrcaRuntimeService', () => { ) }) + // The title is presentation for the tab its reveal shows, so main's row carries it from the spawn. + it("names a titled create's tab in its placement, unless the create is in the background", async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-titled' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { title: 'cli-made' }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker-1', + presentation: 'background' + }) + + expect(spawn.mock.calls.map(([args]) => args.placement)).toEqual([ + { kind: 'new-tab', row: { customTitle: 'cli-made' } }, + { kind: 'new-tab' } + ]) + }) + // Why (flipped by the aug20 "windows 2" incident): #8646 scoped the persisted // binding to windowless promotion, which left a host-initiated terminal on a // host running the full app with neither a persisted tab nor runtime diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index ae7503c712bf..b2d0a168e938 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -1,9 +1,18 @@ import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' +import type { RuntimeTerminalPresentation } from '../../shared/runtime-terminal-contracts' -/** A main-side create always opens its own tab. */ -export function runtimeNewTabPlacement(): TerminalPanePlacement { - return { kind: 'new-tab' } +/** + * A main-side create always opens its own tab. Its title names the tab its reveal shows; a + * background create reveals none, so it names none. + */ +export function runtimeNewTabPlacement( + title: string | null | undefined, + presentation: RuntimeTerminalPresentation | undefined +): TerminalPanePlacement { + return title && presentation !== 'background' + ? { kind: 'new-tab', row: { customTitle: title } } + : { kind: 'new-tab' } } export function runtimeSplitPlacement( diff --git a/src/main/runtime/terminal-topology-projection.test.ts b/src/main/runtime/terminal-topology-projection.test.ts index 34e3e0211a77..3eb8b1201177 100644 --- a/src/main/runtime/terminal-topology-projection.test.ts +++ b/src/main/runtime/terminal-topology-projection.test.ts @@ -84,7 +84,7 @@ function splitAgentAndShellSession(): WorkspaceSessionState { } describe('projectTerminalTopologySlice', () => { - it('keeps topology and creation fields of a split agent+shell tab and drops presentation', () => { + it("keeps a split agent+shell tab's row to topology and creation fields; its saved presentation rides apart", () => { const session = splitAgentAndShellSession() const before = structuredClone(session) @@ -102,6 +102,7 @@ describe('projectTerminalTopologySlice', () => { createdAt: 10 } ], + presentation: { 'tab-agent': { customTitle: 'presentation only', color: 'red' } }, layouts: { 'tab-agent': { root: session.terminalLayoutsByTabId['tab-agent']!.root, @@ -145,6 +146,7 @@ describe('projectTerminalTopologySlice', () => { tabs: [ { id: 'tab-f', worktreeId: folder, ptyId: null, createdAt: 10, startupCwd: '/tmp/f/sub' } ], + presentation: { 'tab-f': { customTitle: null, color: null } }, layouts: {}, sleeping: {} }) diff --git a/src/main/runtime/terminal-topology-projection.ts b/src/main/runtime/terminal-topology-projection.ts index e4ad01a8cac9..145cac4e4a07 100644 --- a/src/main/runtime/terminal-topology-projection.ts +++ b/src/main/runtime/terminal-topology-projection.ts @@ -34,7 +34,11 @@ export function projectTerminalTopologySlice( hostId: ExecutionHostId, worktreeId: string ): UnsequencedTerminalTopologySlice { - const tabs = (session.tabsByWorktree?.[worktreeId] ?? []).map(projectTabRow) + const rows = session.tabsByWorktree?.[worktreeId] ?? [] + const tabs = rows.map(projectTabRow) + const presentation = Object.fromEntries( + rows.map(({ id, customTitle, color }) => [id, { customTitle, color }]) + ) const layouts: Record = {} for (const tab of tabs) { const layout = session.terminalLayoutsByTabId?.[tab.id] @@ -58,6 +62,7 @@ export function projectTerminalTopologySlice( worktreeId, revision: session.terminalTopologyRevisionByRepoId?.[getRepoIdFromWorktreeId(worktreeId)] ?? 0, tabs, + presentation, layouts, sleeping } @@ -69,5 +74,5 @@ export function emptyTerminalTopologySlice( worktreeId: string, revision: number ): UnsequencedTerminalTopologySlice { - return { hostId, worktreeId, revision, tabs: [], layouts: {}, sleeping: {} } + return { hostId, worktreeId, revision, tabs: [], presentation: {}, layouts: {}, sleeping: {} } } diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index 8079babe2fce..d1bb470fc1ef 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -122,10 +122,10 @@ describe('TerminalTopologyPublisher', () => { expect(h.pushes[0]!.layouts[`tab-${WT}`]!.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'pty-2' }) }) - it('sees an in-place edit and ignores an equal rewrite or a presentation-only change', async () => { + it('sees an in-place edit and ignores an equal rewrite or a live title change', async () => { const h = harness(sessionWith([WT])) h.replace({ ...structuredClone(h.session) }) - h.session.tabsByWorktree[WT]![0]!.customTitle = 'renamed' + h.session.tabsByWorktree[WT]![0]!.title = 'vim' h.publisher.markDirty() await Promise.resolve() expect(h.pushes).toEqual([]) @@ -153,6 +153,7 @@ describe('TerminalTopologyPublisher', () => { publishSeq: 3, revision: 0, tabs: [], + presentation: {}, layouts: {}, sleeping: {} } diff --git a/src/main/runtime/terminal-topology-runtime-publishing.test.ts b/src/main/runtime/terminal-topology-runtime-publishing.test.ts index 2448f9885d35..3f58bc9881e2 100644 --- a/src/main/runtime/terminal-topology-runtime-publishing.test.ts +++ b/src/main/runtime/terminal-topology-runtime-publishing.test.ts @@ -111,13 +111,13 @@ describe('main publishes terminal topology to the window', () => { expect(runtime.settleTerminalTopology(WT)).toBe(pushes[0]!.publishSeq) }) - it('pushes nothing for an identical or presentation-only write', async () => { + it('pushes nothing for an identical write or a live title change', async () => { const { write, pushes } = setup() write(sessionWithPty('pty-1')) - const renamed = sessionWithPty('pty-1') - renamed.tabsByWorktree[WT]![0]!.customTitle = 'renamed' - write(renamed) + const retitled = sessionWithPty('pty-1') + retitled.tabsByWorktree[WT]![0]!.title = 'vim' + write(retitled) await Promise.resolve() expect(pushes).toEqual([]) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts index f7a33ca7a75f..82294088975e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts @@ -49,6 +49,7 @@ function movedSlice(publishSeq: number, targetTabId: string): TerminalTopologySl { id: SOURCE_TAB_ID, ptyId: 'pty-left', worktreeId: WORKTREE_ID, createdAt: 1 }, { id: targetTabId, ptyId: PTY, worktreeId: WORKTREE_ID, createdAt: 2 } ], + presentation: {}, layouts: { [SOURCE_TAB_ID]: { root: { type: 'leaf', leafId: LEAF_1 }, diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index 263fed966ec9..d05ac17d5742 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -193,6 +193,7 @@ function pushSetupSplitSlice( publishSeq, revision: 1, tabs: [{ id: TAB_B, ptyId: primaryPtyId, worktreeId, createdAt: 1 }], + presentation: {}, layouts: { [TAB_B]: { root: { diff --git a/src/renderer/src/lib/terminal-topology-follow.test.ts b/src/renderer/src/lib/terminal-topology-follow.test.ts index b365534850a7..b525db93c672 100644 --- a/src/renderer/src/lib/terminal-topology-follow.test.ts +++ b/src/renderer/src/lib/terminal-topology-follow.test.ts @@ -13,6 +13,7 @@ function slice(publishSeq: number, tabIds: string[]): TerminalTopologySlice { publishSeq, revision: 1, tabs: tabIds.map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + presentation: {}, layouts: {}, sleeping: {} } diff --git a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts index 06d6d1f05876..96198550e78a 100644 --- a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts @@ -49,6 +49,7 @@ function slice(publishSeq: number, root: TerminalPaneLayoutNode): TerminalTopolo publishSeq, revision: 1, tabs: [{ id: TAB, ptyId: 'pty-a', worktreeId: WT, createdAt: 1 }], + presentation: {}, layouts: { [TAB]: { root, ptyIdsByLeafId: { [A]: 'pty-a', [B]: 'pty-b' } } }, sleeping: {} } diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts index f0ccd701f76f..750db29ea535 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -25,6 +25,7 @@ function slice(publishSeq: number, tabs: Record): TerminalTopo publishSeq, revision: 1, tabs: Object.keys(tabs).map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + presentation: {}, layouts: Object.fromEntries( Object.entries(tabs).map(([id, [first, second]]) => [ id, diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts index adf8c23af094..33bd4a65274f 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -67,6 +67,7 @@ function slice(publishSeq: number, overrides: Partial = { publishSeq, revision: 1, tabs: [row('a'), row('b')], + presentation: {}, layouts: { a: { root: { type: 'leaf', leafId: LEAF_A }, ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } }, b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } @@ -308,6 +309,27 @@ describe('applyTerminalTopologySlice', () => { expect(state().ptyIdsByTabId).not.toHaveProperty('c') }) + it("shows main's saved title and colour on a tab main created, and keeps the window's on its own", () => { + state().setTabCustomTitle('a', 'renamed here') + apply( + slice(2, { + tabs: [...slice(2).tabs, row('c', { defaultTitle: 'Terminal 3' })], + presentation: { + a: { customTitle: 'stale in main', color: 'blue' }, + c: { customTitle: 'cli-made', color: 'red' } + } + }) + ) + + expect( + state().tabsByWorktree[WT].map(({ id, customTitle, color }) => [id, customTitle, color]) + ).toEqual([ + ['a', 'renamed here', '#f00'], + ['b', null, null], + ['c', 'cli-made', 'red'] + ]) + }) + it('does not add a unified entry a tab already has under another id', () => { useAppStore.setState({ unifiedTabsByWorktree: { diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 9a42009a5f4f..6b9fd0c6e8c0 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -2,6 +2,7 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-sessio import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { + TerminalTabSavedPresentation, TerminalTopologyLayout, TerminalTopologySlice, TerminalTopologyTabRow @@ -30,13 +31,20 @@ function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): Termin return structuralValuesEqual(next, current) ? current : next } -/** A tab main created; its ptyId seeds the window's attachment, as a revealed tab's would. */ -function newTabRow(row: TerminalTopologyTabRow, sortOrder: number): TerminalTab { +/** + * A tab main created; its ptyId seeds the window's attachment, as a revealed tab's would, and the + * presentation main saved (a title the creator gave it) is what the window first shows. + */ +function newTabRow( + row: TerminalTopologyTabRow, + presentation: TerminalTabSavedPresentation | undefined, + sortOrder: number +): TerminalTab { return { ...row, title: row.defaultTitle ?? 'Terminal', - customTitle: null, - color: null, + customTitle: presentation?.customTitle ?? null, + color: presentation?.color ?? null, sortOrder } } @@ -106,7 +114,7 @@ export function mirrorTerminalTopologySlice( const currentIds = new Set(currentTabs.map((tab) => tab.id)) const added = slice.tabs .filter((row) => !currentIds.has(row.id) && !isPending(row.id, 'remove')) - .map((row, index) => newTabRow(row, kept.length + index)) + .map((row, index) => newTabRow(row, slice.presentation[row.id], kept.length + index)) const tabs = [...kept, ...added] const tabsChanged = tabs.length !== currentTabs.length || tabs.some((tab, index) => tab !== currentTabs[index]) diff --git a/src/shared/terminal-topology-slice.ts b/src/shared/terminal-topology-slice.ts index 4d5b8d8c4678..59a13904e57e 100644 --- a/src/shared/terminal-topology-slice.ts +++ b/src/shared/terminal-topology-slice.ts @@ -17,6 +17,9 @@ export type TerminalTopologyTabRow = Pick< | 'quickCommandLabel' > +/** The presentation main last saved for a tab; a window shows it on a tab it has not shown yet. */ +export type TerminalTabSavedPresentation = Pick + export type TerminalTopologyLayout = Pick< TerminalLayoutSnapshot, 'root' | 'ptyIdsByLeafId' | 'titlesByLeafId' @@ -30,6 +33,7 @@ export type TerminalTopologySlice = { publishSeq: number revision: number tabs: TerminalTopologyTabRow[] + presentation: Record layouts: Record sleeping: Record } diff --git a/tests/e2e/terminal-tab-title-and-place.spec.ts b/tests/e2e/terminal-tab-title-and-place.spec.ts new file mode 100644 index 000000000000..f96e0927e79c --- /dev/null +++ b/tests/e2e/terminal-tab-title-and-place.spec.ts @@ -0,0 +1,136 @@ +/** + * Main writes the terminal layout and windows mirror it, so a tab main creates or edits must + * reach the window with the title and place the user would see on the old window-written path: + * a CLI `--title`, and a split pane closed on the desktop while a web client is paired. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { runCompiledOrcaCli } from './helpers/compiled-orca-cli' +import { waitForPairedClientWorktree } from './helpers/paired-client-host-session' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { createTerminalTabFromMenu, SORTABLE_TAB } from './helpers/terminal-tab-menu' + +type TabRow = { id: string; title: string } + +async function activeWorktreeId(page: Page): Promise { + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const worktreeId = await page.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('No active worktree') + } + return worktreeId +} + +/** The tab bar as the user reads it: order and shown title. */ +function readTabs(page: Page, worktreeId: string): Promise { + return page.evaluate((wt) => { + const state = window.__store!.getState() + const key = + Object.keys(state.tabsByWorktree).find( + (candidate) => candidate === wt || candidate.includes(wt) + ) ?? wt + return (state.tabsByWorktree[key] ?? []).map((tab) => ({ + id: tab.id, + title: tab.customTitle ?? tab.title + })) + }, worktreeId) +} + +test('a CLI-created terminal shows the title it was created with', async ({ + electronApp, + orcaPage +}) => { + const worktreeId = await activeWorktreeId(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + + const created = await runCompiledOrcaCli(userDataDir, [ + 'terminal', + 'create', + '--worktree', + `id:${worktreeId}`, + '--title', + 'cli-made', + '--json' + ]) + expect(created.code).toBe(0) + + await expect + .poll(async () => (await readTabs(orcaPage, worktreeId)).map((tab) => tab.title), { + timeout: 15_000 + }) + .toContain('cli-made') + const listed = await runCompiledOrcaCli(userDataDir, [ + 'terminal', + 'list', + '--worktree', + `id:${worktreeId}`, + '--json' + ]) + expect(listed.stdout).toContain('"cli-made"') +}) + +test('closing a split pane on the desktop keeps its tab in place while a web client is paired', async ({ + electronApp, + orcaPage +}) => { + test.skip(process.env.ORCA_E2E_WEB_CLIENT !== '1', 'Needs the paired web client build') + const worktreeId = await activeWorktreeId(orcaPage) + const client = await launchPairedWebClient( + electronApp, + await createRuntimeDesktopPairingOffer(orcaPage) + ) + try { + await waitForPairedClientWorktree(client.page, worktreeId) + await client.page.evaluate((wt) => { + const state = window.__store!.getState() + state.setActiveView?.('terminal') + state.setActiveWorktree(wt) + }, worktreeId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2, 30_000) + await createTerminalTabFromMenu(orcaPage) + await waitForPaneCount(orcaPage, 1, 30_000) + const before = await readTabs(orcaPage, worktreeId) + expect(before).toHaveLength(2) + await expect + .poll(() => readTabs(client.page, worktreeId).then((tabs) => tabs.map((tab) => tab.title)), { + timeout: 15_000 + }) + .toEqual(before.map((tab) => tab.title)) + + await orcaPage.locator(SORTABLE_TAB).first().click() + await waitForPaneCount(orcaPage, 2, 30_000) + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + const confirm = orcaPage.getByRole('button', { name: 'Stop and Close' }) + if (await confirm.isVisible({ timeout: 1_000 }).catch(() => false)) { + await confirm.click() + } + await waitForPaneCount(orcaPage, 1, 30_000) + + // Why a hold: the regression withdrew the tab when the closed pane's PTY exited, then + // re-added it last and untitled, seconds after the close. + await orcaPage.waitForTimeout(5_000) + expect(await readTabs(orcaPage, worktreeId)).toEqual(before) + await expect + .poll(() => readTabs(client.page, worktreeId).then((tabs) => tabs.map((tab) => tab.title)), { + timeout: 15_000 + }) + .toEqual(before.map((tab) => tab.title)) + } finally { + await client.dispose() + } +}) From ba197a4aa3e4a2dd3e7504b910aea631a4328dbc Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:21:10 -0400 Subject: [PATCH 51/75] fix(terminal): main keeps one pane per PTY in a tab it loads A saved tab with two panes bound to one PTY was repaired by the window: its layout normalizer kept one pane and the window's save wrote that back. Main now writes the layout and the window mirrors it, so the profile kept both panes. Main's persistence normalizer, which already makes pane ids unique, now applies the same one-pane-per-PTY rule first, reusing the window's normalizer (moved to shared). --- config/reliability-gates.jsonc | 8 +++--- config/scripts/ci-shard-timings.json | 2 +- .../terminal-layout-normalization.ts | 11 ++++++++ ...workspace-pane-normalization-index.test.ts | 27 +++++++++++++++++++ .../codex-detached-pane-restart.ts | 2 +- .../terminal-pane/terminal-layout-leaf-ids.ts | 2 +- .../terminal-layout-pty-ownership.test.ts | 2 +- .../terminal-live-layout-reconciliation.ts | 2 +- ...ssion-split-pane-retirement-fences.test.ts | 2 +- .../web-session-tabs-sync/terminal-build.ts | 2 +- .../store/terminals/terminal-layout-state.ts | 2 +- .../store/terminals/terminal-pending-panes.ts | 2 +- .../workspace-terminal-layout-plan.ts | 2 +- ...erminal-layout-pty-ownership-depth.test.ts | 2 +- .../terminal-layout-pty-ownership.ts | 2 +- .../terminal-pane-layout-tree.ts | 2 +- 16 files changed, 55 insertions(+), 17 deletions(-) rename src/{renderer/src/components/terminal-pane => shared}/terminal-layout-pty-ownership-depth.test.ts (92%) rename src/{renderer/src/components/terminal-pane => shared}/terminal-layout-pty-ownership.ts (99%) rename src/{renderer/src/components/terminal-pane => shared}/terminal-pane-layout-tree.ts (95%) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 289dcec99597..24fba040db61 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8573,13 +8573,13 @@ "invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.", "oracle": "Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Unit contracts require the same one-owner result through persisted replay, store hydration, remote mirroring, rootless and repeated-leaf layouts, and deeply nested input.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/shared/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "src/shared/terminal-layout-pty-ownership-depth.test.ts", "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", "src/renderer/src/runtime/web-session-tabs-sync.test.ts", @@ -8594,7 +8594,7 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "file": "src/shared/terminal-layout-pty-ownership-depth.test.ts", "assertions": ["prunes deeply nested duplicate ownership without recursive stack growth"] }, { @@ -8625,7 +8625,7 @@ "date": "2026-08-01", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/shared/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.33, "summary": "Five focused files passed 92 ownership, replay, hydration, depth, and remote-mirroring tests." diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 0badaf6a7583..e64ff3122745 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -7294,7 +7294,7 @@ "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 62, "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 39, "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 328, - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 103, + "src/shared/terminal-layout-pty-ownership-depth.test.ts": 103, "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 111, "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 1448, "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 226, diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts index 232c4b2aa395..b7504a1c47d3 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -4,6 +4,7 @@ import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { normalizeTerminalLayoutPtyOwnership } from '../../../shared/terminal-layout-pty-ownership' export type LayoutLeafNormalization = { snapshot: TerminalLayoutSnapshot @@ -117,9 +118,19 @@ export function leafRecordEquivalent( return leftEntries.every(([key, value]) => rightRecord[key] === value) } +/** One pane per PTY in a tab, as a window keeps it (#11757), then one stable id per pane. */ export function normalizeTerminalLayoutSnapshotForPersistence( snapshot: TerminalLayoutSnapshot, preferredLayout?: TerminalLayoutSnapshot +): LayoutLeafNormalization { + const owned = normalizeTerminalLayoutPtyOwnership(snapshot) + const leaves = normalizeLayoutLeafIds(owned.snapshot, preferredLayout) + return { ...leaves, changed: owned.changed || leaves.changed } +} + +function normalizeLayoutLeafIds( + snapshot: TerminalLayoutSnapshot, + preferredLayout?: TerminalLayoutSnapshot ): LayoutLeafNormalization { let inputSnapshot = snapshot let changed = false diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts index 9cebe041544d..ceb1fde7d1a8 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts @@ -181,4 +181,31 @@ describe('workspace pane normalization tab index', () => { expect(idReads).toBe(0) }) + + it('keeps one pane of a tab whose saved layout binds two to one PTY', () => { + const second = '00000000-0000-4000-8000-000000000002' + const layout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: STABLE_LEAF_ID }, + second: { type: 'leaf', leafId: second } + }, + activeLeafId: second, + expandedLeafId: null, + ptyIdsByLeafId: { [STABLE_LEAF_ID]: 'pty-1', [second]: 'pty-1' } + } + + const result = normalizeWorkspaceSessionPaneIdentities( + workspaceSession({ wt: [terminalTab('tab', 'wt', 'pty-1')] }, { tab: layout }) + ) + + expect(result.changed).toBe(true) + expect(result.session.terminalLayoutsByTabId.tab).toEqual({ + root: { type: 'leaf', leafId: second }, + activeLeafId: second, + expandedLeafId: null, + ptyIdsByLeafId: { [second]: 'pty-1' } + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index b68cdd9a4a62..ba5cab00fd02 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -25,7 +25,7 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher' -import { collectLeafIds } from './terminal-pane-layout-tree' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry' diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index 7ea60830f9ca..b61c511c2cc1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -4,7 +4,7 @@ import type { } from '../../../../shared/terminal-tab-types' import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id' import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id' -import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' +import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' const EMPTY_TERMINAL_LAYOUT: TerminalLayoutSnapshot = { root: null, diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts index ddacb05ccd88..c162de1fc675 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { normalizeTerminalLayoutSnapshot } from './terminal-layout-leaf-ids' -import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' +import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' const LEAF_1 = '11111111-1111-4111-8111-111111111111' const LEAF_2 = '22222222-2222-4222-8222-222222222222' diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index b2e817d606bc..b3bb4845225a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -4,7 +4,7 @@ import type { TerminalPaneSplitDirection } from '../../../../shared/terminal-tab-types' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { collectLeafIds } from './terminal-pane-layout-tree' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' import { removeLeafFromTree } from './terminal-layout-leaf-detach' export type TerminalLiveLayoutInsertion = { diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts index b9292deed4d5..ef90efd95aa6 100644 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { collectLeafIds } from '../components/terminal-pane/terminal-pane-layout-tree' +import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree' import { planTerminalLiveLayoutRemovals } from '../components/terminal-pane/terminal-live-layout-reconciliation' import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync' import { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index aacb445bbe3f..c387a8345906 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -1,6 +1,6 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from '../runtime-terminal-stream' diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 4e2246b7d0a5..17b7b46c705e 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -5,7 +5,7 @@ import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission import { normalizeTerminalLayoutPtyOwnership, resolveTerminalLayoutPtyOwnershipTransfers -} from '@/components/terminal-pane/terminal-layout-pty-ownership' +} from '../../../../shared/terminal-layout-pty-ownership' import { transferDirectSshPaneDetachLedger } from '../slices/direct-ssh-terminal-authority-ledger' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index 098b3249300c..7e348415bba2 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -4,7 +4,7 @@ import type { TerminalTopologyReply, TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' -import { collectLeafIds } from '@/components/terminal-pane/terminal-pane-layout-tree' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' import { isWebClientLocation } from '@/lib/web-client-location' import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '../types' diff --git a/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts index a97af259580f..a6d858fbf47a 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts @@ -5,7 +5,7 @@ import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' -import { resolveTerminalLayoutPtyOwnershipTransfers } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { resolveTerminalLayoutPtyOwnershipTransfers } from '../../../../shared/terminal-layout-pty-ownership' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import type { TerminalLayoutPtyOwnershipTransfer } from './workspace-terminal-hydration-patch' diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts b/src/shared/terminal-layout-pty-ownership-depth.test.ts similarity index 92% rename from src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts rename to src/shared/terminal-layout-pty-ownership-depth.test.ts index a22eb50b97f0..73f07b2c0d7d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts +++ b/src/shared/terminal-layout-pty-ownership-depth.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' import { normalizeTerminalLayoutPtyOwnership } from './terminal-layout-pty-ownership' it('prunes deeply nested duplicate ownership without recursive stack growth', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts b/src/shared/terminal-layout-pty-ownership.ts similarity index 99% rename from src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts rename to src/shared/terminal-layout-pty-ownership.ts index 001be213fab1..b317e0894db1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts +++ b/src/shared/terminal-layout-pty-ownership.ts @@ -1,4 +1,4 @@ -import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot } from './terminal-tab-types' import { collectLeafIds, pruneLeaves } from './terminal-pane-layout-tree' type TerminalLayoutPtyOwnershipNormalization = { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts b/src/shared/terminal-pane-layout-tree.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts rename to src/shared/terminal-pane-layout-tree.ts index 5e36a9904ba4..b89749c812d8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-tree.ts +++ b/src/shared/terminal-pane-layout-tree.ts @@ -1,4 +1,4 @@ -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' export function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { if (!node) { From 84f464788f51552944b6fd2e74c888fc702edb68 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:21:16 -0400 Subject: [PATCH 52/75] test(e2e): seed the retention control record beside a tab main holds Main keeps a sleeping-agent record only beside a tab it holds, so the control record under a random tab id was refused and the test saw one record, not two. The control now names a gone pane of a live split tab and is committed without a status, which is what a closed pane leaves. --- .../e2e/local-cli-terminal-retention.spec.ts | 85 +++++++++++++------ 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/tests/e2e/local-cli-terminal-retention.spec.ts b/tests/e2e/local-cli-terminal-retention.spec.ts index 19eceb6e868c..a17f7a9a78e3 100644 --- a/tests/e2e/local-cli-terminal-retention.spec.ts +++ b/tests/e2e/local-cli-terminal-retention.spec.ts @@ -28,6 +28,7 @@ import { RuntimeClient } from '../../src/cli/runtime-client' import { makePaneKey } from '../../src/shared/stable-pane-id' import { expect, test } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { splitActiveTerminalPane } from './helpers/terminal' import { createHostCliTerminal, createHostRendererTerminalTab, @@ -176,36 +177,70 @@ test('keeps a locally created CLI terminal, and never resumes it as a ghost', as await proveSameLivePty(call, unrelated, 'unrelated-alive') const cliPaneKey = makePaneKey(cli.tabId, cli.leafId) - // The control: a record whose pane is gone, i.e. what the pruned CLI tab - // would leave behind. Its replay is the proof the sweep ran at all. - const absentPaneKey = makePaneKey(randomUUID(), randomUUID()) + // The control: a record whose pane is gone, so its replay is the proof the + // sweep ran at all. Main keeps a record only beside a tab it holds, so its + // tab stays; split, so neither of its live PTYs is taken for the gone pane. + await splitActiveTerminalPane(orcaPage, 'vertical') + await expect + .poll( + () => + orcaPage.evaluate( + (tabId) => window.__store?.getState().ptyIdsByTabId[tabId]?.length ?? 0, + secondRendererTabId + ), + { timeout: 30_000, message: 'the control tab never split into two live panes' } + ) + .toBe(2) + const absentPaneKey = makePaneKey(secondRendererTabId, randomUUID()) // Why: a real agent reports its provider session over the hook server; // writing the same store entry keeps this hermetic on the identical path. await orcaPage.evaluate( - ({ panes, worktreeId }) => { - for (const { paneKey, sessionId } of panes) { - window.__store?.getState().setAgentStatus( - paneKey, - { - state: 'working', - prompt: 'local cli retention', - agentType: 'claude' - }, - 'Claude', - undefined, - { worktreeId }, - { providerSession: { key: 'session_id', id: sessionId } } - ) - } + ({ paneKey, sessionId, worktreeId }) => { + window.__store?.getState().setAgentStatus( + paneKey, + { + state: 'working', + prompt: 'local cli retention', + agentType: 'claude' + }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId } } + ) }, - { - panes: [ - { paneKey: cliPaneKey, sessionId: CLI_PANE_SESSION_ID }, - { paneKey: absentPaneKey, sessionId: ABSENT_PANE_SESSION_ID } - ], - worktreeId - } + { paneKey: cliPaneKey, sessionId: CLI_PANE_SESSION_ID, worktreeId } + ) + await expect + .poll( + () => + orcaPage.evaluate( + (paneKey) => + window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] !== undefined, + cliPaneKey + ), + { timeout: 30_000, message: 'the seeded provider session never became a sleeping record' } + ) + .toBe(true) + // Why committed directly: a closed pane reports no status, so its record is + // all that is left of it; a live status would claim the session instead. + await orcaPage.evaluate( + ({ cliPaneKey, absentPaneKey, tabId, sessionId }) => { + const template = window.__store!.getState().sleepingAgentSessionsByPaneKey[cliPaneKey]! + return window.api.session.commitTerminalSleepingRecords({ + sleep: { + [absentPaneKey]: { + ...template, + paneKey: absentPaneKey, + tabId, + providerSession: { key: 'session_id', id: sessionId } + } + }, + wake: [] + }) + }, + { cliPaneKey, absentPaneKey, tabId: secondRendererTabId, sessionId: ABSENT_PANE_SESSION_ID } ) await expect .poll( From 9a257dc4fe63e4ba7ec8acfaadf5a28f8edd40fc Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 03:29:15 -0400 Subject: [PATCH 53/75] fix(terminal): a new tab's pending entry publishes with the tab, in one store update --- .../store/terminals/terminal-pending-panes.ts | 75 +++++++++++++------ .../store/terminals/terminal-tab-creation.ts | 30 +++++--- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index 98e133e7b7d5..d604983d6cd4 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -1,5 +1,6 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' import type { TerminalSurfaceCreateRequest } from '../../../../shared/terminal-surface-create' import type { TerminalTopologyReply, @@ -123,28 +124,44 @@ export function isTerminalTabMirroredFromMain( ) } -/** - * Commits a tab or pane this window created into main, unbound, and shows it here until the push - * holding main's reply. The entry names the tab for a new tab, else the pane. - */ -export function commitTerminalSurfaceCreate( - state: AppState, +/** A creation's pending entry: the tab for a new tab, else the pane. */ +function pendingTerminalSurfaceCreate({ + worktreeId, + tabId, + leafId, + placement +}: TerminalSurfaceCreateRequest): PendingTerminalPane { + return { + worktreeId, + tabId, + ...(placement.kind !== 'new-tab' && leafId ? { leafId } : {}), + change: 'add' + } +} + +/** A new tab's creation commit, and the entry that shows it here until main's reply. */ +export function newTerminalTabCreate( + tab: TerminalTab, + leafId: string | undefined +): { request: TerminalSurfaceCreateRequest; entry: PendingTerminalPane } { + const request: TerminalSurfaceCreateRequest = { + worktreeId: tab.worktreeId, + tabId: tab.id, + ...(leafId ? { leafId } : {}), + placement: { kind: 'new-tab', row: terminalPanePlacementRow(tab) } + } + return { request, entry: pendingTerminalSurfaceCreate(request) } +} + +/** Commits a creation this window shows as `entry` into main, unbound; main's reply settles it. */ +export function sendTerminalSurfaceCreate( + store: Pick, + entry: PendingTerminalPane, request: TerminalSurfaceCreateRequest ): void { - const { worktreeId, tabId, leafId, placement } = request - if (!isTerminalTabMirroredFromMain(state, worktreeId, tabId)) { - return - } - commitPendingTerminalChange( - state, - { - worktreeId, - tabId, - ...(placement.kind !== 'new-tab' && leafId ? { leafId } : {}), - change: 'add' - }, + settlePendingTerminalChangeOnReply(store, entry, () => // Why optional: an older preload can linger through an in-place renderer reload. - () => globalThis.window?.api?.session?.createTerminalSurface?.(request) + globalThis.window?.api?.session?.createTerminalSurface?.(request) ) } @@ -153,8 +170,13 @@ export function commitTerminalPaneIfAheadOfMain( state: AppState, pane: TerminalSurfaceCreateRequest & { leafId: string } ): void { - if (!collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId)) { - commitTerminalSurfaceCreate(state, pane) + if ( + !collectLeafIds(state.terminalLayoutsByTabId[pane.tabId]?.root).includes(pane.leafId) && + isTerminalTabMirroredFromMain(state, pane.worktreeId, pane.tabId) + ) { + const entry = pendingTerminalSurfaceCreate(pane) + state.markPendingTerminalPane(entry) + sendTerminalSurfaceCreate(state, entry, pane) } } @@ -234,6 +256,15 @@ export function commitPendingTerminalChange( send: () => Promise | undefined ): void { store.markPendingTerminalPane(entry) + settlePendingTerminalChangeOnReply(store, entry, send) +} + +/** Settles `entry`, which this window already shows, once `send`'s reply names main's push. */ +function settlePendingTerminalChangeOnReply( + store: Pick, + entry: PendingTerminalPane, + send: () => Promise | undefined +): void { void Promise.resolve(send()).then( (reply) => store.settlePendingTerminalPane(entry, reply?.publishSeq), (error: unknown) => { diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 89e3e8233e3b..71fce9ab3e25 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -20,8 +20,12 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { ownsGlobalSelection } from '../global-selection-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { commitTerminalSurfaceCreate } from './terminal-pending-panes' -import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' +import { + isTerminalTabMirroredFromMain, + newTerminalTabCreate, + sendTerminalSurfaceCreate, + withPendingTerminalPane +} from './terminal-pending-panes' import { getRemoteConnectionIdForWorktree, resolveCreatedTabShellOverride, @@ -63,7 +67,7 @@ export function createTerminalTabCreationActions( return { createTab: (worktreeId, targetGroupId, shellOverride, options) => { let tab!: TerminalTab - let initialLeafId: string | undefined + let created: ReturnType | undefined set((s) => { const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) @@ -92,7 +96,7 @@ export function createTerminalTabCreationActions( : undefined // Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation. // Why a bare leaf id too: a host launch lays out its pane before the process it attaches to exists. - initialLeafId = + const initialLeafId = options?.initialPtyId || options?.pendingStartup || requestedInitialLeafId ? (requestedInitialLeafId ?? createBrowserUuid()) : undefined @@ -140,6 +144,10 @@ export function createTerminalTabCreationActions( // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) } + // Main records the tab now, so it outlives a spawn that fails or never starts. + if (isTerminalTabMirroredFromMain(s, worktreeId, id)) { + created = newTerminalTabCreate(tab, initialLeafId) + } if (options?.launchAgent === 'zcode') { // Why here: this is where a ZCode launch is first known, and it runs before the // pane connects, so the explanation can beat the stack trace to the screen. @@ -232,6 +240,10 @@ export function createTerminalTabCreationActions( ...orphanCleanupPatch.tabsByWorktree, [worktreeId]: [...existing, tab] }, + // Shown before main's push names it; the reply to `created` settles it. + ...(created && { + pendingTerminalPanes: withPendingTerminalPane(s.pendingTerminalPanes, created.entry) + }), // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, @@ -287,13 +299,9 @@ export function createTerminalTabCreationActions( } } }) - // Main records the tab now, so it outlives a spawn that fails or never starts. - commitTerminalSurfaceCreate(get(), { - worktreeId, - tabId: tab.id, - ...(initialLeafId ? { leafId: initialLeafId } : {}), - placement: { kind: 'new-tab', row: terminalPanePlacementRow(tab) } - }) + if (created) { + sendTerminalSurfaceCreate(get(), created.entry, created.request) + } if (options?.initialPtyId) { // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. clearWorktreeSleepIntent(worktreeId) From fcd89e0625b9c8dff1d52f5987a59e8804f0ecdf Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:00:01 -0400 Subject: [PATCH 54/75] fix(terminal): a new tab's placement carries its host-runtime fallback to main --- .../terminal-topology/terminal-pane-placement-apply.test.ts | 3 ++- .../terminal-pane/terminal-pane-spawn-placement.test.ts | 4 +++- src/renderer/src/lib/terminal-pane-placement-row.ts | 6 ++++-- src/shared/terminal-pane-placement.test.ts | 3 ++- src/shared/terminal-pane-placement.ts | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts index 0b0f7b01ab6c..7f701ac7da6c 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-apply.test.ts @@ -99,7 +99,8 @@ describe('new-tab placement', () => { shellOverride: 'zsh', quickCommandLabel: 'pnpm dev', launchAgent: 'claude' as const, - viewMode: 'chat' as const + viewMode: 'chat' as const, + forceHostRuntime: true } const placed = bind(session(), { kind: 'new-tab', row }) expect(placed.tabsByWorktree[WORKTREE]).toEqual([ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts index eec7df33470b..925b037e28ae 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts @@ -13,6 +13,7 @@ const TAB = { sortOrder: 1, createdAt: 42, shellOverride: 'wsl.exe', + forceHostRuntime: true, isPinned: true } @@ -52,7 +53,8 @@ describe('completePaneSpawnPlacement', () => { customTitle: 'Build', color: '#f97316', createdAt: 42, - shellOverride: 'wsl.exe' + shellOverride: 'wsl.exe', + forceHostRuntime: true } }) }) diff --git a/src/renderer/src/lib/terminal-pane-placement-row.ts b/src/renderer/src/lib/terminal-pane-placement-row.ts index 53ae435a200d..5b8d4fd850d1 100644 --- a/src/renderer/src/lib/terminal-pane-placement-row.ts +++ b/src/renderer/src/lib/terminal-pane-placement-row.ts @@ -12,7 +12,8 @@ export function terminalPanePlacementRow(tab: TerminalTab): TerminalPaneNewTabRo shellOverride, quickCommandLabel, launchAgent, - viewMode + viewMode, + forceHostRuntime } = tab return { title, @@ -24,6 +25,7 @@ export function terminalPanePlacementRow(tab: TerminalTab): TerminalPaneNewTabRo shellOverride, quickCommandLabel, launchAgent, - viewMode + viewMode, + forceHostRuntime } } diff --git a/src/shared/terminal-pane-placement.test.ts b/src/shared/terminal-pane-placement.test.ts index 1ff106cf846c..3c2b3fe5e656 100644 --- a/src/shared/terminal-pane-placement.test.ts +++ b/src/shared/terminal-pane-placement.test.ts @@ -20,7 +20,8 @@ describe('parseTerminalPanePlacement', () => { customTitle: null, color: '#f97316', createdAt: 1, - viewMode: 'chat' + viewMode: 'chat', + forceHostRuntime: true } } const split = { diff --git a/src/shared/terminal-pane-placement.ts b/src/shared/terminal-pane-placement.ts index 6b8f084c5c23..648e755ad5be 100644 --- a/src/shared/terminal-pane-placement.ts +++ b/src/shared/terminal-pane-placement.ts @@ -15,7 +15,7 @@ const NewTabRow = terminalTabSchema launchAgent: true, viewMode: true }) - .extend({ shellOverride: z.string().optional() }) + .extend({ shellOverride: z.string().optional(), forceHostRuntime: z.boolean().optional() }) .partial() // Optional fields degrade alone: a malformed one is dropped, not the whole placement. From ccb412edfd9250fba08bbf75c6df689bef723597 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:03:17 -0400 Subject: [PATCH 55/75] test(terminal): pin window-save presentation fields and pruning the core keeps Covers inventory rows A5, A14, A19, A20, A44, A45, B16, B17, B26, B36. --- ...ce-window-save-presentation-fields.test.ts | 219 ++++++++++++++++++ .../terminal-layout-normalization.test.ts | 41 ++++ 2 files changed, 260 insertions(+) create mode 100644 src/main/persistence-window-save-presentation-fields.test.ts diff --git a/src/main/persistence-window-save-presentation-fields.test.ts b/src/main/persistence-window-save-presentation-fields.test.ts new file mode 100644 index 000000000000..4f584cb57d16 --- /dev/null +++ b/src/main/persistence-window-save-presentation-fields.test.ts @@ -0,0 +1,219 @@ +import { + closeTestStores, + createStore, + makeTerminalTab, + testState +} from './persistence-test-harness' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { Tab } from '../shared/tab-types' +import type { TerminalLayoutSnapshot } from '../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { setRendererSession } from './persistence/terminal-topology/terminal-renderer-presentation-save' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const WORKTREE = 'repo1::/worktree' + +const mainLayout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.5, + first: { type: 'leaf', leafId: TEST_LEAF_1 }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-held', [TEST_LEAF_2]: 'pty-held-2' } +} + +function unified(id: string, groupId: string): Tab { + return { + id, + entityId: id, + groupId, + worktreeId: WORKTREE, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +/** What main holds: a split tab and a legacy tab with no layout, in a fenced repo. */ +function mainSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeWorktreeId: WORKTREE, + tabsByWorktree: { + [WORKTREE]: [ + makeTerminalTab({ id: 'held', worktreeId: WORKTREE, ptyId: 'pty-held' }), + makeTerminalTab({ id: 'bare', worktreeId: WORKTREE, ptyId: null, sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { held: mainLayout }, + unifiedTabs: { [WORKTREE]: [unified('held', 'group-a'), unified('bare', 'group-a')] }, + tabGroups: { + [WORKTREE]: [ + { id: 'group-a', worktreeId: WORKTREE, activeTabId: 'held', tabOrder: ['held', 'bare'] } + ] + }, + activeTabIdByWorktree: { [WORKTREE]: 'held' }, + terminalPtyIncarnationsByPaneKey: { [`held:${TEST_LEAF_1}`]: 'incarnation-main' }, + terminalTopologyRevisionByRepoId: { repo1: 1 } + } +} + +describe('a window save keeps what the window presents and drops what main does not hold', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-window-save-presentation-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps pin, view mode, generated and AI Vault titles, launch pane fate and chat pane', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + const presentation = { + isPinned: true, + viewMode: 'chat', + generatedTitle: 'Fix the flaky test', + aiVaultTitle: { agent: 'codex', sessionId: 'session-1', title: 'Vault title' }, + agentLaunchPane: { leafId: TEST_LEAF_1, outcome: { kind: 'unconfirmed' } } + } as const + + setRendererSession(store, { + ...mainSession(), + tabsByWorktree: { + [WORKTREE]: [ + makeTerminalTab({ id: 'held', worktreeId: WORKTREE, ptyId: 'pty-held', ...presentation }), + makeTerminalTab({ id: 'bare', worktreeId: WORKTREE, ptyId: null, sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { held: { ...mainLayout, chatLeafId: TEST_LEAF_2 } } + }) + + const saved = store.getWorkspaceSession() + expect(saved.tabsByWorktree[WORKTREE]?.[0]).toMatchObject(presentation) + expect(saved.terminalLayoutsByTabId.held?.chatLeafId).toBe(TEST_LEAF_2) + }) + + it('a window layout for a tab main holds without one is not saved', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + terminalLayoutsByTabId: { + held: mainLayout, + bare: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: null, + expandedLeafId: null + } + } + }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId).not.toHaveProperty('bare') + }) + + it("a mixed-version window's incarnations and surface tombstones are not saved", async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + terminalPtyIncarnationsByPaneKey: { [`held:${TEST_LEAF_2}`]: 'incarnation-window' }, + terminalSurfaceTombstonesByPaneKey: { + [`held:${TEST_LEAF_2}`]: { + worktreeId: WORKTREE, + parentTabId: 'held', + leafId: TEST_LEAF_2, + ptyId: 'pty-held-2', + incarnationId: 'incarnation-window', + retiredAt: 2 + } + } + }) + + const saved = store.getWorkspaceSession() + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ + [`held:${TEST_LEAF_1}`]: 'incarnation-main' + }) + expect(saved.terminalSurfaceTombstonesByPaneKey ?? {}).toEqual({}) + expect(saved.terminalLayoutsByTabId.held?.root).toEqual(mainLayout.root) + }) + + it('a createTab-era empty layout gets back the panes main holds', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + terminalLayoutsByTabId: { held: { root: null, activeLeafId: null, expandedLeafId: null } } + }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId.held?.root).toEqual(mainLayout.root) + }) + + it('a group left holding only a tab main dropped vanishes, with its split slot and recents', async () => { + const store = await createStore() + store.setWorkspaceSession(mainSession()) + + setRendererSession(store, { + ...mainSession(), + unifiedTabs: { + [WORKTREE]: [ + unified('held', 'group-a'), + unified('bare', 'group-a'), + unified('gone', 'group-b') + ] + }, + tabGroups: { + [WORKTREE]: [ + { + id: 'group-a', + worktreeId: WORKTREE, + activeTabId: 'held', + tabOrder: ['held', 'bare'], + recentTabIds: ['gone'] + }, + { id: 'group-b', worktreeId: WORKTREE, activeTabId: 'gone', tabOrder: ['gone'] } + ] + }, + tabGroupLayouts: { + [WORKTREE]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-b' } + } + } + }) + + const saved = store.getWorkspaceSession() + expect(saved.tabGroups?.[WORKTREE]).toEqual([ + { + id: 'group-a', + worktreeId: WORKTREE, + activeTabId: 'held', + tabOrder: ['held', 'bare'], + recentTabIds: [] + } + ]) + expect(saved.tabGroupLayouts?.[WORKTREE]).toEqual({ type: 'leaf', groupId: 'group-a' }) + }) +}) diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts index cfcdac1d8054..bd529f741b05 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts @@ -53,3 +53,44 @@ describe('normalizeTerminalLayoutSnapshotForPersistence', () => { expect(collectLayoutLeafIdsInOrder(normalized.snapshot.root)).toEqual([STABLE_A, STABLE_B]) }) }) + +describe('normalizeTerminalLayoutSnapshotForPersistence pane metadata', () => { + it('keeps a null focused pane and remaps the expanded and chat panes with their leaf', () => { + const normalized = normalizeTerminalLayoutSnapshotForPersistence( + { + ...splitOf('pane:1', 'pane:2'), + activeLeafId: null, + expandedLeafId: 'pane:2', + chatLeafId: 'pane:1' + }, + splitOf(STABLE_A, STABLE_B) + ) + + expect(normalized.snapshot).toMatchObject({ + activeLeafId: null, + expandedLeafId: STABLE_B, + chatLeafId: STABLE_A + }) + }) + + it('drops an expanded or chat pane that names a leaf no longer in the tree', () => { + const normalized = normalizeTerminalLayoutSnapshotForPersistence({ + ...splitOf(STABLE_A, STABLE_B), + expandedLeafId: 'pane:gone', + chatLeafId: 'pane:gone' + }) + + expect(normalized.snapshot.expandedLeafId).toBeNull() + expect(normalized.snapshot).not.toHaveProperty('chatLeafId') + }) + + it('gives a createTab-era empty layout the prior tree back', () => { + const normalized = normalizeTerminalLayoutSnapshotForPersistence( + { root: null, activeLeafId: null, expandedLeafId: null }, + { ...splitOf(STABLE_A, STABLE_B), activeLeafId: STABLE_B } + ) + + expect(collectLayoutLeafIdsInOrder(normalized.snapshot.root)).toEqual([STABLE_A, STABLE_B]) + expect(normalized.snapshot.activeLeafId).toBe(STABLE_B) + }) +}) From bc7384cc7cd8bcb7836f2b2237bcb4612c0709b6 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:03:19 -0400 Subject: [PATCH 56/75] fix(terminal): a push keeps the pane titles the window shows --- .../terminal-topology-mirror.test.ts | 42 +++++++++++++++++-- .../terminals/terminal-topology-mirror.ts | 25 ++++------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts index 33bd4a65274f..7b5e00b1b8b9 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -164,12 +164,43 @@ describe('applyTerminalTopologySlice', () => { activeLeafId: LEAF_A, expandedLeafId: null, ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, - titlesByLeafId: { [LEAF_C]: 'logs' }, + // Pane titles are the window's presentation, like its buffers. buffersByLeafId: { [LEAF_A]: 'scrollback' } }) expect(state().ptyIdsByTabId).toBe(ptyIdsByTabId) }) + it("keeps a pane title the window set before main's push lands, and saves it", () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + state().setTabLayout('a', { + ...leafLayout(LEAF_A, 'pty-a'), + titlesByLeafId: { [LEAF_A]: 'build' } + }) + // A push inside the save debounce, carrying the title main last saved. + apply( + slice(2, { + layouts: { + ...slice(2).layouts, + a: { ...slice(2).layouts.a, titlesByLeafId: { [LEAF_A]: 'old' } } + } + }) + ) + vi.advanceTimersByTime(1_000) + + expect(state().terminalLayoutsByTabId.a.titlesByLeafId).toEqual({ [LEAF_A]: 'build' }) + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.terminalLayoutsByTabId.a.titlesByLeafId).toEqual({ + [LEAF_A]: 'build' + }) + dispose() + }) + it('clears an optional row field main dropped', () => { apply(slice(2, { tabs: [row('a', { launchAgent: 'codex' }), row('b')] })) apply(slice(3)) @@ -275,7 +306,11 @@ describe('applyTerminalTopologySlice', () => { tabs: [...slice(2).tabs, row('c', { defaultTitle: 'Terminal 3', launchAgent: 'claude' })], layouts: { ...slice(2).layouts, - c: { root: { type: 'leaf', leafId: LEAF_C }, ptyIdsByLeafId: { [LEAF_C]: 'pty-c' } } + c: { + root: { type: 'leaf', leafId: LEAF_C }, + ptyIdsByLeafId: { [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' } + } } }) ) @@ -294,7 +329,8 @@ describe('applyTerminalTopologySlice', () => { }) expect(state().terminalLayoutsByTabId.c).toMatchObject({ root: { type: 'leaf', leafId: LEAF_C }, - activeLeafId: LEAF_C + activeLeafId: LEAF_C, + titlesByLeafId: { [LEAF_C]: 'logs' } }) expect(state().unifiedTabsByWorktree[WT].at(-1)).toMatchObject({ id: 'c', diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 6b9fd0c6e8c0..b4e478f2930a 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -49,31 +49,24 @@ function newTabRow( } } +/** Pane titles are presentation: main's seed a layout new to the window, as a new tab's title does. */ function mirrorLayout( current: TerminalLayoutSnapshot | undefined, layout: TerminalTopologyLayout ): TerminalLayoutSnapshot { + if (!current) { + const activeLeafId = resolvePtyBoundActiveLeafId({ ...layout, activeLeafId: null }) + return { ...emptyLayoutSnapshot(), ...layout, activeLeafId } + } if ( - current && terminalLayoutNodeEqual(current.root, layout.root) && - sameStringRecord(current.ptyIdsByLeafId, layout.ptyIdsByLeafId) && - sameStringRecord(current.titlesByLeafId, layout.titlesByLeafId) + sameStringRecord(current.ptyIdsByLeafId, layout.ptyIdsByLeafId) ) { return current } - const { - ptyIdsByLeafId: _ptyIds, - titlesByLeafId: _titles, - ...presentation - } = current ?? emptyLayoutSnapshot() - const activeLeafId = current - ? presentation.activeLeafId - : resolvePtyBoundActiveLeafId({ - root: layout.root, - activeLeafId: null, - ptyIdsByLeafId: layout.ptyIdsByLeafId - }) - return { ...presentation, ...layout, activeLeafId } + const { ptyIdsByLeafId: _ptyIds, ...presentation } = current + const { titlesByLeafId: _mainTitles, ...topology } = layout + return { ...presentation, ...topology } } function mirrorSleepingRecords( From 39884063a95285b98d4671d8b5b699530876be07 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:07:29 -0400 Subject: [PATCH 57/75] fix(terminal): a save keeps main's scrollback for a pane the window has not attached --- ...erminal-renderer-presentation-save.test.ts | 28 +++++++++++++++++++ .../terminal-renderer-presentation-save.ts | 26 +++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts index 6f61ca581be1..8c4789ece653 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.test.ts @@ -193,6 +193,34 @@ describe('mergeRendererPresentationSave', () => { expect(saved.unifiedTabs).toEqual(prior.unifiedTabs) }) + it("keeps main's scrollback for a pane the window has not attached yet", () => { + const prior = mainSession() + prior.terminalLayoutsByTabId.tab = { + ...splitLayout, + scrollbackRefsByLeafId: { [LEFT]: 'ref-left', [RIGHT]: 'ref-right' }, + buffersByLeafId: { [LEFT]: 'left-buffer' } + } + // A snapshot from before the left pane respawned; the right one is attached and saves its own. + const window: WorkspaceSessionState = { + ...prior, + terminalLayoutsByTabId: { + tab: { + ...splitLayout, + ptyIdsByLeafId: { [RIGHT]: 'pty-right' }, + buffersByLeafId: { [RIGHT]: 'right-buffer' } + } + } + } + + const saved = mergeRendererPresentationSave(window, prior, 'local') + + expect(saved.terminalLayoutsByTabId.tab).toEqual({ + ...splitLayout, + scrollbackRefsByLeafId: { [LEFT]: 'ref-left' }, + buffersByLeafId: { [LEFT]: 'left-buffer', [RIGHT]: 'right-buffer' } + }) + }) + it("a save cannot change a tab's creation fields", () => { const prior = mainSession() prior.tabsByWorktree[WORKTREE] = [ diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts index 67de597a122b..1d865b4a2e71 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts @@ -185,7 +185,10 @@ function mainLayouts( return layouts } -/** Per-leaf presentation applies only to the panes main holds; a different pane set is stale. */ +/** + * Per-leaf presentation applies only to the panes main holds; a different pane set is stale. A pane + * main binds that the window has not attached yet saved no scrollback, so main's stands. + */ function presentationLayout( main: TerminalLayoutSnapshot, window: TerminalLayoutSnapshot | undefined @@ -193,7 +196,26 @@ function presentationLayout( if (!window || !sameTerminalLeafSet(main.root, window.root)) { return main } - return { ...window, root: main.root, ptyIdsByLeafId: main.ptyIdsByLeafId } + const unattached = new Set( + Object.keys(main.ptyIdsByLeafId ?? {}).filter((leafId) => !window.ptyIdsByLeafId?.[leafId]) + ) + return { + ...window, + root: main.root, + ptyIdsByLeafId: main.ptyIdsByLeafId, + ...withMainLeafRecords('buffersByLeafId', unattached, main, window), + ...withMainLeafRecords('scrollbackRefsByLeafId', unattached, main, window) + } +} + +function withMainLeafRecords( + field: 'buffersByLeafId' | 'scrollbackRefsByLeafId', + leafIds: ReadonlySet, + main: TerminalLayoutSnapshot, + window: TerminalLayoutSnapshot +): Partial { + const kept = Object.entries(main[field] ?? {}).filter(([leafId]) => leafIds.has(leafId)) + return kept.length > 0 ? { [field]: { ...Object.fromEntries(kept), ...window[field] } } : {} } /** The window's tab bar, holding exactly main's terminal tabs. */ From 4d2607912a1912a8941d824e524612b349bfa625 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:07:36 -0400 Subject: [PATCH 58/75] test(persistence): pin main-side save steps the core keeps unchanged Covers inventory rows A25, A40, A41, A50, A55, A56. --- ...ersistence-pane-identity-migration.test.ts | 35 ++++++++ ...persistence-split-pane-incarnation.test.ts | 51 +++++++++++ ...ce-terminal-membership-window-save.test.ts | 29 +++++++ .../persisted-state-redundancy.test.ts | 11 +++ .../orcad-source-export.test.ts | 18 ++++ ...rminal-peer-import-partition-write.test.ts | 86 +++++++++++++++++++ 6 files changed, 230 insertions(+) create mode 100644 src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts diff --git a/src/main/persistence-pane-identity-migration.test.ts b/src/main/persistence-pane-identity-migration.test.ts index c39a93636a39..611ac14192ef 100644 --- a/src/main/persistence-pane-identity-migration.test.ts +++ b/src/main/persistence-pane-identity-migration.test.ts @@ -12,6 +12,8 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { agentHookServer } from './agent-hooks/server' +import { getMigrationUnsupportedPtySnapshot } from './agent-hooks/migration-unsupported-pty-state' +import { getDefaultWorkspaceSession } from '../shared/constants' import { TEST_LEAF_1, @@ -645,4 +647,37 @@ describe('Store', () => { }) ) }) + + it('a save of a legacy numeric pane layout flags no live PTY as needing a restart', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'legacy-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'legacy-pty' } + } + } + }) + + const root = store.getWorkspaceSession().terminalLayoutsByTabId.tab1.root + expect(root?.type === 'leaf' && isTerminalLeafId(root.leafId)).toBe(true) + expect(getMigrationUnsupportedPtySnapshot()).toEqual([]) + }) }) diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index 9ad634e3d0a3..f4374a41750c 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -380,4 +380,55 @@ describe('Store', () => { 'inc-stale' ) }) + + it('a fresh spawn into a retired pane clears its tombstone, so the next reattach is accepted', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-old' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-old' } + } + }, + terminalSurfaceTombstonesByPaneKey: { + [paneKey]: { + worktreeId: 'wt1', + parentTabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-old', + incarnationId: 'inc-old', + retiredAt: 1 + } + } + }) + const reattach = { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-new', + mayCreate: false, + mayReviveRetiredSurface: false + } + expect(await store.persistPtyBinding(reattach)).toBe(false) + + expect( + await store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-new', + incarnationId: 'inc-new' + }) + ).toBe(true) + + expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(await store.persistPtyBinding(reattach)).toBe(true) + }) }) diff --git a/src/main/persistence-terminal-membership-window-save.test.ts b/src/main/persistence-terminal-membership-window-save.test.ts index 4ae149f9c2b3..ded79d938c1c 100644 --- a/src/main/persistence-terminal-membership-window-save.test.ts +++ b/src/main/persistence-terminal-membership-window-save.test.ts @@ -161,4 +161,33 @@ describe('terminal membership main holds survives a stale window save', () => { expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) }) + + // Main's own writers read and write the store directly; nothing rebases their edit away. + it("writes a main writer's unfenced tab removal as is, in a repo main already fenced", async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...rendererSession(), + terminalTopologyRevisionByRepoId: { repo1: 1 } + }) + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2, + ptyId: 'host-pty' + }) + const fenced = store.getWorkspaceSession() + expect(fenced.terminalTopologyRevisionByRepoId?.repo1).toBeGreaterThan(0) + + const { 'host-tab': _removed, ...layouts } = fenced.terminalLayoutsByTabId + store.setWorkspaceSession({ + ...fenced, + tabsByWorktree: { + [WORKTREE]: fenced.tabsByWorktree[WORKTREE].filter((tab) => tab.id !== 'host-tab') + }, + terminalLayoutsByTabId: layouts + }) + + expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).not.toHaveProperty('host-tab') + }) }) diff --git a/src/main/persistence/loading-store/persisted-state-redundancy.test.ts b/src/main/persistence/loading-store/persisted-state-redundancy.test.ts index 8b9414f4fb8d..9f0fe2dc6c0d 100644 --- a/src/main/persistence/loading-store/persisted-state-redundancy.test.ts +++ b/src/main/persistence/loading-store/persisted-state-redundancy.test.ts @@ -248,4 +248,15 @@ describe('persisted-state redundancy', () => { ) } }) + + it("a host partition write does not keep a copy of local's browser history", () => { + const store = openStore(tempDataFile()) + const history = browserHistory() + store.setWorkspaceSession(session({ browserUrlHistory: history })) + + store.setWorkspaceSession(session({ browserUrlHistory: history }), 'runtime:env-0') + + expect(store.getWorkspaceSession('runtime:env-0')).not.toHaveProperty('browserUrlHistory') + expect(store.getWorkspaceSession().browserUrlHistory).toEqual(history) + }) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts index 6ad39576d5fa..19022b7dad03 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts @@ -163,6 +163,24 @@ describe('exporting a relay-hosted SSH target from the profile store', () => { expect(readTerminalScrollbackStoredBytesSync(ref, storage)).toBeNull() }) + it('a save that drops a tab keeps the scrollback file a pending migration still sends', () => { + const store = sourceStore() + const storage = { snapshotRoot: getProfileTerminalScrollbackSnapshotRoot(dataFiles.at(-1)!) } + store.setWorkspaceSession(getDefaultWorkspaceSession(), toSshExecutionHostId(TARGET.id)) + // An older build left this SSH workspace's tab in the local partition. + store.setWorkspaceSession(dormantSession('legacy output\r\n')) + const manifest = createOrcadMigrationManifest(store, TARGET) + const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' + expect(readTerminalScrollbackStoredBytesSync(ref, storage)).not.toBeNull() + store.syncOrcadMigrationScrollbackRetention([manifest]) + + store.setWorkspaceSession(getDefaultWorkspaceSession()) + + expect(readTerminalScrollbackStoredBytesSync(ref, storage)).not.toBeNull() + store.syncOrcadMigrationScrollbackRetention([]) + expect(readTerminalScrollbackStoredBytesSync(ref, storage)).toBeNull() + }) + it('refuses a chunk for a manifest whose digest does not match its contents', () => { const store = sourceStore() const manifest = createOrcadMigrationManifest(store, TARGET) diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts new file mode 100644 index 000000000000..4015c73ab4ee --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' +import { Store } from '../loading-store/store' +import { importPeerTopology } from './terminal-topology-commit' + +const HOST_ID = 'ssh:target-1' +const WORKTREE_ID = 'ssh-repo::/srv/app' +const LEAF = '11111111-1111-4111-8111-111111111111' + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openStore(): Store { + const directory = mkdtempSync(join(tmpdir(), 'terminal-peer-import-')) + directories.push(directory) + return createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) +} + +function tab(id: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function leafLayout(tabId: string): WorkspaceSessionState['terminalLayoutsByTabId'][string] { + return { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: `pty-${tabId}` } + } +} + +describe('a host pull into an SSH partition main has published nothing since', () => { + // Cross-check: the import is a plain patch, so rows and every record the pull names are the host's. + it("replaces main's rows and the pane records the pull names", () => { + const store = openStore() + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('main-tab')] }, + terminalLayoutsByTabId: { 'main-tab': leafLayout('main-tab') }, + terminalPtyIncarnationsByPaneKey: { [`main-tab:${LEAF}`]: 'main-incarnation' }, + terminalTopologyRevisionByRepoId: { 'ssh-repo': 1 } + }, + HOST_ID + ) + + importPeerTopology( + store, + 'target-1', + { + tabsByWorktree: { [WORKTREE_ID]: [tab('host-tab')] }, + terminalLayoutsByTabId: { 'host-tab': leafLayout('host-tab') }, + terminalPtyIncarnationsByPaneKey: { [`host-tab:${LEAF}`]: 'host-incarnation' } + }, + () => false + ) + + const saved = store.getWorkspaceSession(HOST_ID) + expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual(['host-tab']) + expect(Object.keys(saved.terminalLayoutsByTabId)).toEqual(['host-tab']) + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ + [`host-tab:${LEAF}`]: 'host-incarnation' + }) + expect(saved.terminalTopologyRevisionByRepoId).toEqual({ 'ssh-repo': 1 }) + }) +}) From f5a6ed32cab9d9f344f23e63a0f08733dfb21422 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:08:01 -0400 Subject: [PATCH 59/75] fix(terminal): group sleeping records once per publish and apply, restore main's slice on a refused change, keep a stale client's focus out, apply old close records on load - main reconcile and the window's apply batch read sleeping-agent records once, not once per worktree - a refused or rolled-back topology commit re-applies main's slice for the worktree at once and toasts - a headless client's stale pane tree leaves main's whole layout, as on main - close records an older build left are applied and cleared at load, as main's first write did --- ...y-terminal-surface-tombstones-load.test.ts | 75 +++++++++++++++++++ .../normalize-loaded-state-collections.ts | 3 +- .../workspace-session-partitions.ts | 5 +- ...session-terminal-persistence-retirement.ts | 14 ++++ ...time-persist-headless-session-tab-props.ts | 8 +- .../mobile-session-tabs-part-06.spec.ts | 13 ++-- .../runtime/terminal-topology-projection.ts | 26 +++++-- .../terminal-topology-publisher.test.ts | 39 ++++++++++ .../runtime/terminal-topology-publisher.ts | 9 ++- .../app-shell/use-app-startup-hydration.ts | 2 +- .../terminal-pane-tab-detach-commit.test.ts | 10 +-- .../terminal-pane/terminal-pane-tab-detach.ts | 1 + src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + ...etup-split-activation-single-owner.test.ts | 42 ++++++----- .../src/lib/terminal-topology-follow.test.ts | 4 +- .../src/lib/terminal-topology-follow.ts | 10 +-- .../src/store/terminals/terminal-actions.ts | 6 +- .../terminals/terminal-pending-layout.test.ts | 2 +- .../terminals/terminal-pending-panes.test.ts | 49 ++++++++---- .../store/terminals/terminal-pending-panes.ts | 54 ++++++++++--- .../terminal-surface-close-intent.ts | 7 +- .../terminal-topology-mirror.test.ts | 49 +++++++++++- .../terminals/terminal-topology-mirror.ts | 51 +++++++++---- 28 files changed, 398 insertions(+), 99 deletions(-) create mode 100644 src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts diff --git a/src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts b/src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts new file mode 100644 index 000000000000..f3ec10209f1b --- /dev/null +++ b/src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { hasHostAuthoritativeTerminalMembership } from '../terminal-topology/terminal-topology-membership' +import { normalizeLoadedLocalSession } from './normalize-loaded-state-collections' +import { parseWorkspaceSessionsByHostId } from './workspace-session-partitions' + +const WORKTREE_ID = 'repo::/worktree' + +/** A profile an older build saved: its close record was left for the next write to apply. */ +function sessionWithUnappliedClose(incarnationId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'closed-tab', + ptyId: 'pty-1', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'closed-tab': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { 'closed-tab:leaf-1': 'incarnation-a' }, + terminalSurfaceTombstonesByPaneKey: { + 'closed-tab:leaf-1': { + worktreeId: WORKTREE_ID, + parentTabId: 'closed-tab', + leafId: 'leaf-1', + ptyId: 'pty-1', + incarnationId, + retiredAt: 42 + } + } + } +} + +describe('loading a session an older build left close records in', () => { + it('applies them, so the closed tab stays closed, then drops them', () => { + const defaults = getDefaultPersistedState('/home/user') + const loaded = normalizeLoadedLocalSession( + { ...defaults, workspaceSession: sessionWithUnappliedClose('incarnation-a') }, + defaults, + () => {} + ) + + expect(loaded.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(loaded.terminalLayoutsByTabId['closed-tab']).toBeUndefined() + expect(loaded.terminalSurfaceTombstonesByPaneKey).toEqual({}) + }) + + it('drops one it cannot apply, so a runtime partition does not read as host-authoritative', () => { + const { partitions } = parseWorkspaceSessionsByHostId( + { 'runtime:env-1': sessionWithUnappliedClose('incarnation-b') }, + getDefaultWorkspaceSession() + ) + const loaded = partitions['runtime:env-1'] + + expect(loaded?.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(loaded?.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(hasHostAuthoritativeTerminalMembership(loaded, WORKTREE_ID)).toBe(false) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 16c5b424def8..19d522223f7e 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -5,6 +5,7 @@ import { } from '../../../shared/project-catalog-row-normalization' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' import { backfillAutomationRunNumbers, pruneAutomationRuns @@ -35,7 +36,7 @@ export function normalizeLoadedLocalSession( // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. markNeedsSave() } - return { ...defaults.workspaceSession, ...result.value } + return applyLegacyTerminalSurfaceTombstones({ ...defaults.workspaceSession, ...result.value }) } export function normalizeLoadedHostSessions( diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts index f2b5bd291740..a0b1f1a9de70 100644 --- a/src/main/persistence/loading-store/workspace-session-partitions.ts +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -6,6 +6,7 @@ import { } from '../../../shared/execution-host' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership' +import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' export function workspaceSessionSalvageLogDetails(result: { droppedCount: number @@ -54,10 +55,10 @@ export function parseWorkspaceSessionsByHostId( } // Runs before the defaults spread, so a field the type requires comes back at its default // rather than going missing. - partitions[hostId] = { + partitions[hostId] = applyLegacyTerminalSurfaceTombstones({ ...defaults, ...withoutRedundantGlobalFields(result.value, localSession) - } + }) } return { partitions, repaired } } diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index ff18e6b4a40d..34a0313e927e 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -218,3 +218,17 @@ export function retireTerminalSurfaceFromPersistence( paneKey ) } + +/** Applies the close records an older build left for its next write to apply, then drops them. */ +export function applyLegacyTerminalSurfaceTombstones( + session: WorkspaceSessionState +): WorkspaceSessionState { + const tombstones = Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {}) + if (tombstones.length === 0) { + return session + } + return { + ...tombstones.reduce(retireTerminalSurfaceFromPersistence, session), + terminalSurfaceTombstonesByPaneKey: {} + } +} diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 7ca89ab8534c..f28e9263e674 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -131,10 +131,14 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi if (!existing) { return undefined } + const root = args.root ?? existing.root + // A tree with other panes is stale, and its focus and titles may name panes main no longer holds. + if (!sameTerminalLeafSet(existing.root, root)) { + return existing + } const layout = { ...cloneTerminalLayoutSnapshot(existing), - // A tree with other panes is stale, so main's stands; the client's focus and titles still apply. - root: args.root && sameTerminalLeafSet(existing.root, args.root) ? args.root : existing.root, + root, expandedLeafId: args.expandedLeafId, ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts index 73246869f065..af483ec5514b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts @@ -295,10 +295,11 @@ describe('OrcaRuntimeService', () => { }) }) - it('keeps a client title and expand edit when its pane tree is stale', async () => { + it("keeps main's whole layout when a client's pane tree is stale", async () => { const session = makeWorkspaceSessionWithHeadlessTerminal() const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService(runtimeStore as never) + const before = structuredClone(getSession().terminalLayoutsByTabId['host-tab']) await runtime.updateMobileSessionPaneLayout(`id:${TEST_WORKTREE_ID}`, { tabId: 'host-tab', @@ -308,14 +309,12 @@ describe('OrcaRuntimeService', () => { first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, second: { type: 'leaf', leafId: 'leaf-gone' } }, - expandedLeafId: HEADLESS_LEAF_ID, - titlesByLeafId: { [HEADLESS_LEAF_ID]: 'Pane A' } + expandedLeafId: 'leaf-gone', + chatLeafId: 'leaf-gone', + titlesByLeafId: { 'leaf-gone': 'Pane B' } }) - const persisted = getSession().terminalLayoutsByTabId['host-tab']! - expect(persisted.root).toEqual({ type: 'leaf', leafId: HEADLESS_LEAF_ID }) - expect(persisted.expandedLeafId).toBe(HEADLESS_LEAF_ID) - expect(persisted.titlesByLeafId).toEqual({ [HEADLESS_LEAF_ID]: 'Pane A' }) + expect(getSession().terminalLayoutsByTabId['host-tab']).toEqual(before) }) it('persists headless tab color + pin and surfaces them through a cold rehydrate', async () => { diff --git a/src/main/runtime/terminal-topology-projection.ts b/src/main/runtime/terminal-topology-projection.ts index 145cac4e4a07..517c2f62ee2f 100644 --- a/src/main/runtime/terminal-topology-projection.ts +++ b/src/main/runtime/terminal-topology-projection.ts @@ -28,11 +28,27 @@ export function projectTabRow(tab: TerminalTab): TerminalTopologyTabRow { } } +export type SleepingRecordsByWorktree = Map> + +/** One pass over a partition's sleeping-agent records, so projecting each worktree needn't rescan. */ +export function groupSleepingRecordsByWorktree( + session: WorkspaceSessionState +): SleepingRecordsByWorktree { + const grouped: SleepingRecordsByWorktree = new Map() + for (const [paneKey, record] of Object.entries(session.sleepingAgentSessionsByPaneKey ?? {})) { + const records = grouped.get(record.worktreeId) ?? {} + records[paneKey] = record + grouped.set(record.worktreeId, records) + } + return grouped +} + /** Pure; reads only the worktree's own rows from the partition that owns it. */ export function projectTerminalTopologySlice( session: WorkspaceSessionState, hostId: ExecutionHostId, - worktreeId: string + worktreeId: string, + sleepingByWorktree: SleepingRecordsByWorktree = groupSleepingRecordsByWorktree(session) ): UnsequencedTerminalTopologySlice { const rows = session.tabsByWorktree?.[worktreeId] ?? [] const tabs = rows.map(projectTabRow) @@ -51,12 +67,6 @@ export function projectTerminalTopologySlice( ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {}) } } - const sleeping: Record = {} - for (const [paneKey, record] of Object.entries(session.sleepingAgentSessionsByPaneKey ?? {})) { - if (record.worktreeId === worktreeId) { - sleeping[paneKey] = record - } - } return { hostId, worktreeId, @@ -64,7 +74,7 @@ export function projectTerminalTopologySlice( tabs, presentation, layouts, - sleeping + sleeping: sleepingByWorktree.get(worktreeId) ?? {} } } diff --git a/src/main/runtime/terminal-topology-publisher.test.ts b/src/main/runtime/terminal-topology-publisher.test.ts index d1bb470fc1ef..6d080d4fce4b 100644 --- a/src/main/runtime/terminal-topology-publisher.test.ts +++ b/src/main/runtime/terminal-topology-publisher.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -104,6 +105,44 @@ describe('TerminalTopologyPublisher', () => { ]) }) + it("reads a partition's sleeping records once per reconcile, however many worktrees it holds", () => { + const worktreeIds = Array.from({ length: 50 }, (_, index) => `repo-1::/tmp/wt-${index}`) + const session = sessionWith(worktreeIds) + let enumerations = 0 + const records: Record = {} + for (const worktreeId of worktreeIds) { + const paneKey = `tab-${worktreeId}:${TEST_LEAF_1}` + records[paneKey] = { + paneKey, + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: worktreeId }, + prompt: 'hi', + state: 'done', + capturedAt: 1, + updatedAt: 2 + } + } + session.sleepingAgentSessionsByPaneKey = new Proxy(records, { + ownKeys: (target) => { + enumerations += 1 + return Reflect.ownKeys(target) + } + }) + const pushes: TerminalTopologySlice[] = [] + const publisher = new TerminalTopologyPublisher( + () => new Map(worktreeIds.map((worktreeId) => [worktreeId, { hostId: 'local', session }])), + (slice) => pushes.push(slice) + ) + + publisher.markDirty() + publisher.flush() + + expect(enumerations).toBe(1) + expect(pushes).toHaveLength(worktreeIds.length) + expect(Object.keys(pushes[7]!.sleeping)).toEqual([`tab-${worktreeIds[7]}:${TEST_LEAF_1}`]) + }) + it('pushes only the worktree whose topology changed, once per burst', async () => { const h = harness(sessionWith([WT, WT_B])) const next = structuredClone(h.session) diff --git a/src/main/runtime/terminal-topology-publisher.ts b/src/main/runtime/terminal-topology-publisher.ts index e227abbeaa53..b3d1aae38a98 100644 --- a/src/main/runtime/terminal-topology-publisher.ts +++ b/src/main/runtime/terminal-topology-publisher.ts @@ -1,9 +1,12 @@ import { isDeepStrictEqual } from 'node:util' import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' import type { TerminalSessionPartition } from '../persistence/terminal-topology/terminal-topology-membership' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { emptyTerminalTopologySlice, + groupSleepingRecordsByWorktree, projectTerminalTopologySlice, + type SleepingRecordsByWorktree, type UnsequencedTerminalTopologySlice } from './terminal-topology-projection' @@ -75,11 +78,15 @@ export class TerminalTopologyPublisher { private reconcile(): void { const owners = this.readOwners() + const sleepingBySession = new Map() const next: UnsequencedTerminalTopologySlice[] = [] for (const [worktreeId, owner] of owners) { // The last slice stands until the owner resolves. if (owner) { - next.push(projectTerminalTopologySlice(owner.session, owner.hostId, worktreeId)) + const { session, hostId } = owner + const sleeping = sleepingBySession.get(session) ?? groupSleepingRecordsByWorktree(session) + sleepingBySession.set(session, sleeping) + next.push(projectTerminalTopologySlice(session, hostId, worktreeId, sleeping)) } } const withdrawn: UnsequencedTerminalTopologySlice[] = [] diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 0acc4f993d3e..8349b1568621 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -207,7 +207,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('follow-terminal-topology', () => followTerminalTopology( window.api.session, - useAppStore.getState().applyTerminalTopologySlice, + useAppStore.getState().applyTerminalTopologySlices, abortController.signal ) ) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts index 82294088975e..7a1aa148a46e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts @@ -156,7 +156,7 @@ describe('dragging a pane out to a new tab follows main’s move', () => { it('shows the new tab once when main’s push arrives before the reply', async () => { const { result, moveLeafToNewTab } = detach(async (request) => { // The reconciler detaches the source pane as soon as this push lands. - state().applyTerminalTopologySlice(movedSlice(2, request.targetTabId)) + state().applyTerminalTopologySlices([movedSlice(2, request.targetTabId)]) return { status: 'moved', ptyId: PTY, publishSeq: 2 } }) @@ -175,7 +175,7 @@ describe('dragging a pane out to a new tab follows main’s move', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(state().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - state().applyTerminalTopologySlice(movedSlice(2, targetTabIdOf(moveLeafToNewTab))) + state().applyTerminalTopologySlices([movedSlice(2, targetTabIdOf(moveLeafToNewTab))]) await expect(result).resolves.toMatchObject({ leafId: LEAF_2 }) expectTargetTabShownOnce(targetTabIdOf(moveLeafToNewTab)) @@ -201,7 +201,7 @@ describe('dragging a pane out to a new tab follows main’s move', () => { reason: 'cleanup' }) // Main's push with the new tab can't show it meanwhile. - state().applyTerminalTopologySlice(movedSlice(2, targetTabId)) + state().applyTerminalTopologySlices([movedSlice(2, targetTabId)]) expect(state().tabsByWorktree[WORKTREE_ID].map((tab) => tab.id)).toEqual([SOURCE_TAB_ID]) }) @@ -211,7 +211,7 @@ describe('dragging a pane out to a new tab follows main’s move', () => { const { result } = detach( async (request) => { paneIds = [2] - state().applyTerminalTopologySlice(movedSlice(2, request.targetTabId)) + state().applyTerminalTopologySlices([movedSlice(2, request.targetTabId)]) return { status: 'moved', ptyId: PTY, publishSeq: 2 } }, managerWithPanes(() => paneIds) @@ -248,7 +248,7 @@ describe('dragging a pane out to a new tab follows main’s move', () => { await expect(repeat.result).resolves.toBeNull() expect(repeat.moveLeafToNewTab).not.toHaveBeenCalled() - state().applyTerminalTopologySlice(movedSlice(2, targetTabIdOf(first.moveLeafToNewTab))) + state().applyTerminalTopologySlices([movedSlice(2, targetTabIdOf(first.moveLeafToNewTab))]) release({ status: 'moved', ptyId: PTY, publishSeq: 2 }) await expect(first.result).resolves.toMatchObject({ leafId: LEAF_2 }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index 2e4409681477..d0e01a5ae7b1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -24,6 +24,7 @@ export type TerminalPaneTabDetachStore = Pick< | 'moveUnifiedTabToGroup' | 'pendingTerminalPanes' | 'reorderUnifiedTabs' + | 'restoreTerminalTopologySlice' | 'setActiveTab' | 'setActiveTabType' | 'setTabLayout' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ce8dde6e8392..3b8c725e9761 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19328,6 +19328,9 @@ "paneMove": { "failed": "Couldn't move the pane to a new tab." }, + "topologyChange": { + "refused": "Couldn't save that terminal change, so it was undone." + }, "claudeOldTerminalBanner": { "title": "This terminal was opened before Orca's Claude account update, so claude here uses System default's login.", "dialogTitle": "Why claude here doesn't use the selected account", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fcc5d506162c..578605fd5c7b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19338,6 +19338,9 @@ "paneMove": { "failed": "No se pudo mover el panel a una pestaña nueva." }, + "topologyChange": { + "refused": "No se pudo guardar ese cambio en la terminal, así que se deshizo." + }, "paneTopBanner": { "openNewTerminal": "Abrir terminal nuevo", "learnMore": "Más información", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a566463c9b23..373d8a700ed8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19370,6 +19370,9 @@ "paneMove": { "failed": "Impossible de déplacer le volet vers un nouvel onglet." }, + "topologyChange": { + "refused": "Impossible d'enregistrer cette modification du terminal ; elle a été annulée." + }, "paneTopBanner": { "openNewTerminal": "Ouvrir un nouveau terminal", "learnMore": "Apprendre encore plus", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b05e33cc1c9a..9957e0711e6c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19370,6 +19370,9 @@ "paneMove": { "failed": "ペインを新規タブに移動できませんでした。" }, + "topologyChange": { + "refused": "このターミナルの変更を保存できなかったため、元に戻しました。" + }, "paneTopBanner": { "openNewTerminal": "新規ターミナルを開く", "learnMore": "詳しく見る", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 922180cbd59d..e908a860b165 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19370,6 +19370,9 @@ "paneMove": { "failed": "분할 창을 새 탭으로 이동할 수 없습니다." }, + "topologyChange": { + "refused": "터미널 변경 사항을 저장하지 못해 되돌렸습니다." + }, "paneTopBanner": { "openNewTerminal": "새 터미널 열기", "learnMore": "자세히 알아보기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d4f231058078..c7f207bb7537 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19370,6 +19370,9 @@ "paneMove": { "failed": "无法将窗格移动到新选项卡。" }, + "topologyChange": { + "refused": "无法保存该终端更改,已将其撤销。" + }, "paneTopBanner": { "openNewTerminal": "打开新终端", "learnMore": "了解更多", diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index d05ac17d5742..43366dc9b4c7 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -187,26 +187,28 @@ function pushSetupSplitSlice( setupPtyId: string, publishSeq: number ): void { - useAppStore.getState().applyTerminalTopologySlice({ - hostId: 'local', - worktreeId, - publishSeq, - revision: 1, - tabs: [{ id: TAB_B, ptyId: primaryPtyId, worktreeId, createdAt: 1 }], - presentation: {}, - layouts: { - [TAB_B]: { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: PRIMARY_LEAF }, - second: { type: 'leaf', leafId: SETUP_LEAF } - }, - ptyIdsByLeafId: { [PRIMARY_LEAF]: primaryPtyId, [SETUP_LEAF]: setupPtyId } - } - }, - sleeping: {} - }) + useAppStore.getState().applyTerminalTopologySlices([ + { + hostId: 'local', + worktreeId, + publishSeq, + revision: 1, + tabs: [{ id: TAB_B, ptyId: primaryPtyId, worktreeId, createdAt: 1 }], + presentation: {}, + layouts: { + [TAB_B]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: PRIMARY_LEAF }, + second: { type: 'leaf', leafId: SETUP_LEAF } + }, + ptyIdsByLeafId: { [PRIMARY_LEAF]: primaryPtyId, [SETUP_LEAF]: setupPtyId } + } + }, + sleeping: {} + } + ]) } function setupSplitReveal( diff --git a/src/renderer/src/lib/terminal-topology-follow.test.ts b/src/renderer/src/lib/terminal-topology-follow.test.ts index b525db93c672..62c765b9cc01 100644 --- a/src/renderer/src/lib/terminal-topology-follow.test.ts +++ b/src/renderer/src/lib/terminal-topology-follow.test.ts @@ -48,8 +48,8 @@ function fakeMain(pulled: () => TerminalTopologySlice[]) { } const tabIds = () => (useAppStore.getState().tabsByWorktree[WT] ?? []).map((tab) => tab.id) -const apply = (next: TerminalTopologySlice) => - useAppStore.getState().applyTerminalTopologySlice(next) +const apply = (slices: readonly TerminalTopologySlice[]) => + useAppStore.getState().applyTerminalTopologySlices(slices) afterEach(() => { useAppStore.setState(initial, true) diff --git a/src/renderer/src/lib/terminal-topology-follow.ts b/src/renderer/src/lib/terminal-topology-follow.ts index ed44cdaf9d78..a5a464f006e0 100644 --- a/src/renderer/src/lib/terminal-topology-follow.ts +++ b/src/renderer/src/lib/terminal-topology-follow.ts @@ -11,17 +11,17 @@ type TerminalTopologySource = { */ export async function followTerminalTopology( source: TerminalTopologySource, - apply: (slice: TerminalTopologySlice) => void, + apply: (slices: readonly TerminalTopologySlice[]) => void, signal: AbortSignal ): Promise { if (signal.aborted) { return } - signal.addEventListener('abort', source.onTerminalTopologyChanged(apply), { once: true }) + const unsubscribe = source.onTerminalTopologyChanged((slice) => apply([slice])) + signal.addEventListener('abort', unsubscribe, { once: true }) try { - for (const slice of await source.getTerminalTopologySlices()) { - apply(slice) - } + // One batch, so the startup pull mirrors every worktree in one store update. + apply(await source.getTerminalTopologySlices()) } catch (error) { // The hydrated session came from the same store, so the window stays usable; pushes still apply. console.warn('[terminal-topology] Startup pull failed:', error) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index d3aa3f71ec3b..77ba19803764 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -286,8 +286,10 @@ export type TerminalActions = { setDeferredSshReconnectTargets: (targetIds: string[]) => void removeDeferredSshReconnectTarget: (targetId: string) => void removeDeferredSshSessionId: (tabId: string) => void - /** Mirrors main's terminal topology for one worktree; a slice older than the last applied is ignored. */ - applyTerminalTopologySlice: (slice: TerminalTopologySlice) => void + /** Mirrors main's terminal topology per worktree; a slice older than the last applied is ignored. */ + applyTerminalTopologySlices: (slices: readonly TerminalTopologySlice[]) => void + /** Re-applies main's latest slice over a change of this window's that main refused. */ + restoreTerminalTopologySlice: (slice: TerminalTopologySlice) => void markPendingTerminalPane: (entry: PendingTerminalPane) => void /** Main answered `entry`'s commit; it ends once the mirror holds `publishSeq`. */ settlePendingTerminalPane: (entry: PendingTerminalPane, publishSeq?: number) => void diff --git a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts index 96198550e78a..693f4408dd7f 100644 --- a/src/renderer/src/store/terminals/terminal-pending-layout.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-layout.test.ts @@ -58,7 +58,7 @@ function slice(publishSeq: number, root: TerminalPaneLayoutNode): TerminalTopolo const state = () => useAppStore.getState() const rootInStore = () => state().terminalLayoutsByTabId[TAB]?.root const apply = (publishSeq: number, root: TerminalPaneLayoutNode) => - state().applyTerminalTopologySlice(slice(publishSeq, root)) + state().applyTerminalTopologySlices([slice(publishSeq, root)]) const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) /** What the drag end does: the window takes its own tree, then commits it to main. */ diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts index e799c64139e1..3fa4e1102a6e 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' import { useAppStore } from '@/store' import type { TerminalTopologyReply, @@ -12,12 +13,14 @@ import { } from './terminal-pending-panes' import { makeWorktree } from '../slices/store-test-helpers' +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + const WT = 'repo::/wt' const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' const initial = useAppStore.getState() const state = () => useAppStore.getState() -const apply = (next: TerminalTopologySlice) => state().applyTerminalTopologySlice(next) +const apply = (next: TerminalTopologySlice) => state().applyTerminalTopologySlices([next]) const tabIds = () => (state().tabsByWorktree[WT] ?? []).map((tab) => tab.id) const unifiedIds = () => (state().unifiedTabsByWorktree[WT] ?? []).map((tab) => tab.entityId) @@ -49,30 +52,37 @@ function slice(publishSeq: number, tabs: Record): TerminalTopo } } -/** Main's replies are released by the test, so either order can be driven. */ -function stubReplies(method: 'closeTerminalSurface' | 'createTerminalSurface'): { - reply: (answer: TerminalTopologyReply | Error) => void +type Reply = TerminalTopologyReply & { status?: string } + +/** Main's replies are released by the test, so either order can be driven; `held` is main's topology. */ +function stubReplies( + method: 'closeTerminalSurface' | 'createTerminalSurface', + held: TerminalTopologySlice[] = [] +): { + reply: (answer: Reply | Error) => void calls: ReturnType } { - const pending: ((answer: TerminalTopologyReply | Error) => void)[] = [] + const pending: ((answer: Reply | Error) => void)[] = [] const calls = vi.fn( () => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { pending.push((answer) => (answer instanceof Error ? reject(answer) : resolve(answer))) }) ) - vi.stubGlobal('window', { api: { session: { [method]: calls } } }) + const getTerminalTopologySlices = async () => held + vi.stubGlobal('window', { api: { session: { [method]: calls, getTerminalTopologySlices } } }) return { reply: (answer) => pending.shift()?.(answer), calls } } -const stubCloseReplies = () => { - const { reply, calls } = stubReplies('closeTerminalSurface') +const stubCloseReplies = (held?: TerminalTopologySlice[]) => { + const { reply, calls } = stubReplies('closeTerminalSurface', held) return { reply, closeTerminalSurface: calls } } const settled = () => new Promise((resolve) => setTimeout(resolve, 0)) beforeEach(() => { + vi.mocked(toast.error).mockClear() vi.spyOn(console, 'warn').mockImplementation(() => {}) apply(slice(1, { a: [LEAF_A] })) }) @@ -117,6 +127,18 @@ describe('a tab this window creates', () => { expect(state().pendingTerminalPanes).toEqual([]) }) + it('is dropped at once, and says so, when main refuses it', async () => { + const main = stubReplies('createTerminalSurface', [slice(1, { a: [LEAF_A] })]) + const tab = state().createTab(WT) + expect(tabIds()).toEqual(['a', tab.id]) + + main.reply({ status: 'refused', publishSeq: 1 }) + await settled() + expect(tabIds()).toEqual(['a']) + expect(state().pendingTerminalPanes).toEqual([]) + expect(toast.error).toHaveBeenCalledOnce() + }) + it('keeps a pane main has not named yet, and only that one', () => { state().markPendingTerminalPane({ worktreeId: WT, tabId: 'a', leafId: LEAF_B, change: 'add' }) expect(pendingTerminalLeafIds(state().pendingTerminalPanes, 'a', 'add')).toEqual( @@ -178,16 +200,17 @@ describe('a tab this window closes', () => { expect(state().pendingTerminalPanes).toEqual([]) }) - it('shows main’s copy again when main refuses the close', async () => { + it('shows main’s copy again at once, and says so, when main refuses the close', async () => { apply(slice(10, { a: [LEAF_A], b: [LEAF_B] })) - const main = stubCloseReplies() + const main = stubCloseReplies([slice(10, { a: [LEAF_A], b: [LEAF_B] })]) state().closeTab('b') + expect(tabIds()).toEqual(['a']) + main.reply(new Error('write failed')) await settled() expect(state().pendingTerminalPanes).toEqual([]) - - apply(slice(11, { a: [LEAF_A], b: [LEAF_B] })) expect(tabIds()).toEqual(['a', 'b']) + expect(toast.error).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.ts b/src/renderer/src/store/terminals/terminal-pending-panes.ts index d75c1cad98d9..377ad495fab2 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.ts @@ -1,3 +1,5 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' import { parseExecutionHostId } from '../../../../shared/execution-host' import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' @@ -12,6 +14,11 @@ import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runti import type { AppState } from '../types' import type { TerminalSlice, TerminalStoreSet } from './terminal-state' +export type PendingTerminalChangeStore = Pick< + TerminalSlice, + 'settlePendingTerminalPane' | 'restoreTerminalTopologySlice' +> + /** A whole tab when `leafId` is absent. */ export type PendingTerminalPaneKey = { worktreeId: string; tabId: string; leafId?: string } @@ -155,7 +162,7 @@ export function newTerminalTabCreate( /** Commits a creation this window shows as `entry` into main, unbound; main's reply settles it. */ export function sendTerminalSurfaceCreate( - store: Pick, + store: PendingTerminalChangeStore, entry: PendingTerminalPane, request: TerminalSurfaceCreateRequest ): void { @@ -251,7 +258,7 @@ export function terminalTabPanesNamed( * reply is applied. A later change to the same target replaces `entry`; this reply then settles nothing. */ export function commitPendingTerminalChange( - store: Pick, + store: PendingTerminalChangeStore & Pick, entry: PendingTerminalPane, send: () => Promise | undefined ): void { @@ -261,19 +268,48 @@ export function commitPendingTerminalChange( /** Settles `entry`, which this window already shows, once `send`'s reply names main's push. */ function settlePendingTerminalChangeOnReply( - store: Pick, + store: PendingTerminalChangeStore, entry: PendingTerminalPane, - send: () => Promise | undefined + send: () => Promise<(TerminalTopologyReply & { status?: string }) | undefined> | undefined ): void { void Promise.resolve(send()).then( - (reply) => store.settlePendingTerminalPane(entry, reply?.publishSeq), - (error: unknown) => { - console.warn(`[terminal-topology] main did not commit the ${entry.change}`, error) - store.settlePendingTerminalPane(entry) - } + (reply) => + reply?.status === 'refused' + ? restoreMainTerminalTopology(store, entry, reply) + : store.settlePendingTerminalPane(entry, reply?.publishSeq), + (error: unknown) => restoreMainTerminalTopology(store, entry, error) ) } +/** + * Main refused `entry` or rolled its write back, so no push will ever undo what this window shows; + * main's slice for the worktree replaces it now. + */ +async function restoreMainTerminalTopology( + store: PendingTerminalChangeStore, + entry: PendingTerminalPane, + cause: unknown +): Promise { + console.warn(`[terminal-topology] main did not commit the ${entry.change}`, cause) + store.settlePendingTerminalPane(entry) + toast.error( + translate( + 'terminal.topologyChange.refused', + "Couldn't save that terminal change, so it was undone." + ) + ) + try { + const slices = await globalThis.window?.api?.session?.getTerminalTopologySlices?.() + const slice = slices?.find((candidate) => candidate.worktreeId === entry.worktreeId) + if (slice) { + store.restoreTerminalTopologySlice(slice) + } + } catch (error) { + // The next push for the worktree still restores main's topology. + console.warn("[terminal-topology] could not pull main's topology", error) + } +} + export function createTerminalPendingPaneActions( set: TerminalStoreSet ): Pick { diff --git a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts index edaf307d413d..8e0d18d7ca28 100644 --- a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts +++ b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts @@ -1,11 +1,14 @@ import type { TerminalSurfaceCloseTarget } from '../../../../shared/terminal-surface-close-target' -import { commitPendingTerminalChange } from './terminal-pending-panes' +import { + commitPendingTerminalChange, + type PendingTerminalChangeStore +} from './terminal-pending-panes' import type { TerminalSlice } from './terminal-state' /** Commits a terminal tab or split-pane close in main, which owns membership. The surface stays * hidden here until main's topology drops it, so an older push cannot bring it back. */ export function commitTerminalSurfaceClose( - store: Pick, + store: PendingTerminalChangeStore & Pick, worktreeId: string, target: TerminalSurfaceCloseTarget, reason?: 'user' | 'cleanup' diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts index 33bd4a65274f..2bd56d8ea050 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -106,11 +106,11 @@ function seed(): void { groupsByWorktree: { [WT]: [{ ...group, tabOrder: ['a', 'b', '/wt/readme.md'] }] }, activeGroupIdByWorktree: { [WT]: 'group' } }) - useAppStore.getState().applyTerminalTopologySlice(slice(1)) + useAppStore.getState().applyTerminalTopologySlices([slice(1)]) } const apply = (next: TerminalTopologySlice) => - useAppStore.getState().applyTerminalTopologySlice(next) + useAppStore.getState().applyTerminalTopologySlices([next]) const state = () => useAppStore.getState() afterEach(() => { @@ -118,7 +118,7 @@ afterEach(() => { useAppStore.setState(initial, true) }) -describe('applyTerminalTopologySlice', () => { +describe('applyTerminalTopologySlices', () => { beforeEach(seed) it('keeps presentation, liveness and activation state while replacing topology', () => { @@ -381,6 +381,49 @@ describe('applyTerminalTopologySlice', () => { ]) }) + it("reads the window's sleeping records a fixed number of times per batch, however many worktrees", () => { + const enumerationsFor = (worktreeCount: number): number => { + let enumerations = 0 + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: new Proxy>( + {}, + { + ownKeys: (target) => { + enumerations += 1 + return Reflect.ownKeys(target) + } + } + ) + }) + const worktreeIds = Array.from( + { length: worktreeCount }, + (_, index) => `repo::/wt-${worktreeCount}-${index}` + ) + state().applyTerminalTopologySlices( + worktreeIds.map((worktreeId) => + slice(10, { worktreeId, tabs: [row(`t-${worktreeId}`, { worktreeId })], layouts: {} }) + ) + ) + return enumerations + } + + const single = enumerationsFor(1) + expect(single).toBeGreaterThan(0) + expect(enumerationsFor(50)).toBe(single) + }) + + it('restores main’s slice at the applied seq over this window’s refused change', () => { + apply(slice(3)) + useAppStore.setState({ + tabsByWorktree: { [WT]: [...state().tabsByWorktree[WT], windowTab('refused')] } + }) + + state().restoreTerminalTopologySlice(slice(2)) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b', 'refused']) + state().restoreTerminalTopologySlice(slice(3)) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b']) + }) + it('ignores a slice older than the last one applied', () => { apply(slice(5, { tabs: [row('a')], layouts: { a: slice(5).layouts.a } })) const before = state() diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index 6b9fd0c6e8c0..fb55dcfb221d 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -76,14 +76,18 @@ function mirrorLayout( return { ...presentation, ...layout, activeLeafId } } +/** One pass over the window's records for the whole batch, however many worktrees it holds. */ function mirrorSleepingRecords( current: Record, - slice: TerminalTopologySlice + slices: readonly TerminalTopologySlice[] ): Record { - const others = Object.entries(current).filter( - ([, record]) => record.worktreeId !== slice.worktreeId + const replaced = new Set(slices.map((slice) => slice.worktreeId)) + const next = Object.fromEntries( + Object.entries(current).filter(([, record]) => !replaced.has(record.worktreeId)) ) - const next = { ...Object.fromEntries(others), ...slice.sleeping } + for (const slice of slices) { + Object.assign(next, slice.sleeping) + } return structuralValuesEqual(next, current) ? current : next } @@ -92,7 +96,7 @@ function mirrorSleepingRecords( * before main names them, or hidden before main drops them); everything else is the window's. * Unchanged rows and layouts keep their identity, so an identical slice changes only the seq. */ -export function mirrorTerminalTopologySlice( +function mirrorTerminalTopologySlice( state: AppState, slice: TerminalTopologySlice ): Partial { @@ -143,13 +147,9 @@ export function mirrorTerminalTopologySlice( (tabId) => layouts[tabId] !== state.terminalLayoutsByTabId[tabId] ) - const sleeping = mirrorSleepingRecords(state.sleepingAgentSessionsByPaneKey, slice) return { ...(tabsChanged ? { tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: tabs } } : {}), ...(layoutsChanged ? { terminalLayoutsByTabId: layouts } : {}), - ...(sleeping !== state.sleepingAgentSessionsByPaneKey - ? { sleepingAgentSessionsByPaneKey: sleeping } - : {}), ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), ...(pending !== state.pendingTerminalPanes ? { pendingTerminalPanes: pending } : {}), terminalTopologySeqByWorktree: { @@ -159,16 +159,37 @@ export function mirrorTerminalTopologySlice( } } +function mirrorTerminalTopologySlices( + state: AppState, + slices: readonly TerminalTopologySlice[] +): Partial { + let next = state + for (const slice of slices) { + next = { ...next, ...mirrorTerminalTopologySlice(next, slice) } + } + const sleeping = mirrorSleepingRecords(state.sleepingAgentSessionsByPaneKey, slices) + return sleeping === state.sleepingAgentSessionsByPaneKey + ? next + : { ...next, sleepingAgentSessionsByPaneKey: sleeping } +} + +const appliedSeq = (state: AppState, slice: TerminalTopologySlice): number => + state.terminalTopologySeqByWorktree[slice.worktreeId] ?? 0 + export function createTerminalTopologyMirrorActions( set: TerminalStoreSet -): Pick { +): Pick { return { - applyTerminalTopologySlice: (slice) => { - set((s) => + applyTerminalTopologySlices: (slices) => { + set((s) => { // Pushes and the startup pull race; the higher publishSeq is the newer slice. - slice.publishSeq > (s.terminalTopologySeqByWorktree[slice.worktreeId] ?? 0) - ? mirrorTerminalTopologySlice(s, slice) - : s + const newer = slices.filter((slice) => slice.publishSeq > appliedSeq(s, slice)) + return newer.length > 0 ? mirrorTerminalTopologySlices(s, newer) : s + }) + }, + restoreTerminalTopologySlice: (slice) => { + set((s) => + slice.publishSeq >= appliedSeq(s, slice) ? mirrorTerminalTopologySlices(s, [slice]) : s ) } } From f71d7418d0b529a383a6e057248791f374930f33 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:08:33 -0400 Subject: [PATCH 60/75] test(terminal): pin pushes and saves of main's direct session writers Covers inventory rows P6, P7, P8, P10, P11, P12, R8, C-extra3. --- ...dless-editor-tab-close-persistence.test.ts | 127 +++++++++++ ...ess-terminal-tab-order-persistence.test.ts | 72 ++++++ ...rminal-topology-main-writer-pushes.test.ts | 206 ++++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 src/main/runtime/headless-editor-tab-close-persistence.test.ts create mode 100644 src/main/runtime/headless-terminal-tab-order-persistence.test.ts create mode 100644 src/main/terminal-topology-main-writer-pushes.test.ts diff --git a/src/main/runtime/headless-editor-tab-close-persistence.test.ts b/src/main/runtime/headless-editor-tab-close-persistence.test.ts new file mode 100644 index 000000000000..386e6f4221b2 --- /dev/null +++ b/src/main/runtime/headless-editor-tab-close-persistence.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { Tab } from '../../shared/tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { closeTestStores, createStore, makeRepo, testState } from '../persistence-test-harness' +import { HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR } from './mobile-session-editor-projection' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) + +const WT = 'r1::/repo' +const README = '/repo/README.md' +const MAIN = '/repo/main.ts' + +function openFile(filePath: string, overrides: Partial = {}): PersistedOpenFile { + return { + filePath, + relativePath: filePath.slice('/repo/'.length), + worktreeId: WT, + language: filePath.endsWith('.md') ? 'markdown' : 'typescript', + ...overrides + } +} + +function editorTab(id: string, filePath: string): Tab { + return { + id, + entityId: filePath, + groupId: 'group', + worktreeId: WT, + contentType: 'editor', + label: filePath, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +/** Two persisted editors, the README active and focused, as a migrated SSH host carries them. */ +function editorSession(readme: PersistedOpenFile = openFile(README)): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [WT]: [readme, openFile(MAIN)] }, + unifiedTabs: { [WT]: [editorTab('editor-readme', README), editorTab('editor-main', MAIN)] }, + tabGroups: { + [WT]: [ + { + id: 'group', + worktreeId: WT, + activeTabId: 'editor-readme', + tabOrder: ['editor-readme', 'editor-main'], + recentTabIds: ['editor-main', 'editor-readme'] + } + ] + }, + activeTabTypeByWorktree: { [WT]: 'editor' }, + activeFileIdByWorktree: { [WT]: README }, + activeTabIdByWorktree: { [WT]: 'editor-readme' } + } +} + +async function headlessHost(session: WorkspaceSessionState) { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorkspaceSession(session) + const runtime = new OrcaRuntimeService(store) + const listed = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(listed.tabs.map((tab) => tab.id)).toEqual(['editor-readme', 'editor-main']) + return { store, runtime } +} + +describe('closing a persisted editor tab on a windowless host', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-headless-editor-close-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('removes the file, its tab bar entry and every pointer to it from the saved session', async () => { + const { store, runtime } = await headlessHost(editorSession()) + + await runtime.closeMobileSessionTab(`id:${WT}`, 'editor-readme') + + const saved = store.getWorkspaceSession() + expect(saved.openFilesByWorktree?.[WT]?.map((file) => file.filePath)).toEqual([MAIN]) + expect(saved.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual(['editor-main']) + expect(saved.tabGroups?.[WT]).toEqual([ + { + id: 'group', + worktreeId: WT, + activeTabId: null, + tabOrder: ['editor-main'], + recentTabIds: ['editor-main'] + } + ]) + expect(saved.activeFileIdByWorktree?.[WT]).toBeNull() + expect(saved.activeTabIdByWorktree?.[WT]).toBeNull() + const relisted = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(relisted.tabs.map((tab) => tab.id)).toEqual(['editor-main']) + }) + + it('refuses to drop an unsaved draft unless the close is forced', async () => { + const dirty = openFile(README, { dirtyDraftContent: '# draft' }) + const { store, runtime } = await headlessHost(editorSession(dirty)) + + await expect(runtime.closeMobileSessionTab(`id:${WT}`, 'editor-readme')).rejects.toThrow( + HEADLESS_EDITOR_UNSAVED_DRAFT_ERROR + ) + expect(store.getWorkspaceSession().openFilesByWorktree?.[WT]).toHaveLength(2) + + await runtime.closeMobileSessionTab(`id:${WT}`, 'editor-readme', { force: true }) + expect(store.getWorkspaceSession().openFilesByWorktree?.[WT]).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/headless-terminal-tab-order-persistence.test.ts b/src/main/runtime/headless-terminal-tab-order-persistence.test.ts new file mode 100644 index 000000000000..0b61a9a9af41 --- /dev/null +++ b/src/main/runtime/headless-terminal-tab-order-persistence.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import { makeTerminalTab, TEST_LEAF_1, TEST_LEAF_2 } from '../persistence-session-fixtures' +import { closeTestStores, createStore, makeRepo, testState } from '../persistence-test-harness' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) + +const WT = 'r1::/repo' + +function leafLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +describe('a mobile reorder on a windowless host', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-headless-tab-order-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('persists the new terminal tab order, so a restart lists the tabs as moved', async () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WT]: [ + makeTerminalTab({ id: 'tab-a', worktreeId: WT, ptyId: 'pty-a', sortOrder: 0 }), + makeTerminalTab({ id: 'tab-b', worktreeId: WT, ptyId: 'pty-b', sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { + 'tab-a': leafLayout(TEST_LEAF_1, 'pty-a'), + 'tab-b': leafLayout(TEST_LEAF_2, 'pty-b') + } + }) + const runtime = new OrcaRuntimeService(store) + const listed = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(listed.tabGroups?.[0]?.tabOrder).toEqual(['tab-a', 'tab-b']) + + await runtime.moveMobileSessionTab(`id:${WT}`, { + kind: 'reorder', + tabId: 'tab-b', + targetGroupId: listed.tabGroups![0]!.id, + tabOrder: ['tab-b', 'tab-a'] + }) + store.flush() + + const sortOrders = (rows = store.getWorkspaceSession().tabsByWorktree[WT] ?? []) => + Object.fromEntries(rows.map((tab) => [tab.id, tab.sortOrder])) + expect(sortOrders()).toEqual({ 'tab-b': 0, 'tab-a': 1 }) + const restarted = new OrcaRuntimeService(createStore()) + const relisted = await restarted.listMobileSessionTabs(`id:${WT}`) + expect(relisted.tabGroups?.[0]?.tabOrder).toEqual(['tab-b', 'tab-a']) + }) +}) diff --git a/src/main/terminal-topology-main-writer-pushes.test.ts b/src/main/terminal-topology-main-writer-pushes.test.ts new file mode 100644 index 000000000000..b0e8bc2443ea --- /dev/null +++ b/src/main/terminal-topology-main-writer-pushes.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { ExecutionHostId } from '../shared/execution-host' +import type { TerminalTopologySlice } from '../shared/terminal-topology-slice' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import type { Store } from './persistence/loading-store/store' +import { makeTerminalTab, TEST_LEAF_1 } from './persistence-session-fixtures' +import { closeTestStores, createStore, makeRepo, testState } from './persistence-test-harness' +import { RuntimeWorkspaceSessionController } from './runtime/runtime-workspace-session-controller' +import { TerminalTopologyPublisher } from './runtime/terminal-topology-publisher' + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +/** One worktree holding one bound single-pane tab. */ +function sessionWithTab(worktreeId: string, ptyId: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab', worktreeId, ptyId })] }, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: ptyId } + } + } + } +} + +/** Main's publisher wired to a real store the way the runtime wires it; records what follows the first pull. */ +function watchPushes(store: Store) { + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: () => null, + hasRuntimeOwnedPtyCandidate: () => false + }) + const pushes: TerminalTopologySlice[] = [] + const publisher = new TerminalTopologyPublisher( + () => controller.getTerminalTopologyOwners(), + (slice) => pushes.push(slice) + ) + store.onWorkspaceSessionWritten(() => publisher.markDirty()) + const initial = publisher.snapshot() + pushes.length = 0 + return { + initial, + pushesAfter: (write: () => unknown): TerminalTopologySlice[] => { + write() + publisher.flush() + return pushes + } + } +} + +function withdrawn(hostId: ExecutionHostId, worktreeId: string) { + return expect.objectContaining({ hostId, worktreeId, tabs: [], layouts: {} }) +} + +describe('a window showing a worktree learns of main-side writes that remove or rekey it', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-topology-writer-pushes-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('withdraws a folder workspace deleted directly or with its project group', () => { + for (const viaGroup of [false, true]) { + const store = createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const folder = store.createFolderWorkspace({ + projectGroupId: group.id, + folderPath: '/workspace/platform' + }) + const key = folderWorkspaceKey(folder.id) + store.setWorkspaceSession(sessionWithTab(key, 'pty-folder')) + const { initial, pushesAfter } = watchPushes(store) + expect(initial.map((slice) => slice.worktreeId)).toEqual([key]) + + const pushes = pushesAfter(() => + viaGroup ? store.deleteProjectGroup(group.id) : store.removeFolderWorkspace(folder.id) + ) + + expect(pushes).toEqual([withdrawn('local', key)]) + } + }) + + it('withdraws the worktrees of a removed repo', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + store.setWorkspaceSession(sessionWithTab('r1::/repo', 'pty-repo')) + const { initial, pushesAfter } = watchPushes(store) + expect(initial).toHaveLength(1) + + expect(pushesAfter(() => store.removeProject('r1'))).toEqual([withdrawn('local', 'r1::/repo')]) + }) + + it("withdraws one host's worktree when a shared repo id is removed from that host only", () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-a', + executionHostId: 'ssh:ssh-a' + }) + ) + const worktreeId = 'shared::/local/repo/wt' + store.setWorktreeMeta(worktreeId, { displayName: 'local-wt', hostId: 'local' }) + store.setWorkspaceSession(sessionWithTab(worktreeId, 'pty-local')) + const { initial, pushesAfter } = watchPushes(store) + expect(initial.map((slice) => slice.worktreeId)).toEqual([worktreeId]) + + // The id survives on the SSH host, so only the per-host prune removes these rows. + const pushes = pushesAfter(() => store.removeProjectForHost('shared', 'local')) + + expect(pushes).toEqual([withdrawn('local', worktreeId)]) + }) + + it('withdraws the old identity and publishes the tab under the new one on a rename', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' })) + const oldId = 'repo-1::/workspace/feature' + const newId = 'repo-1::/workspace/renamed' + store.setWorkspaceSession(sessionWithTab(oldId, 'pty-feature')) + const { pushesAfter } = watchPushes(store) + + const pushes = pushesAfter(() => store.migrateWorktreeIdentity(oldId, newId)) + + expect(pushes).toEqual([ + withdrawn('local', oldId), + expect.objectContaining({ + hostId: 'local', + worktreeId: newId, + tabs: [expect.objectContaining({ id: 'tab', ptyId: 'pty-feature' })] + }) + ]) + }) + + it('republishes an SSH worktree under the re-added target, with its bindings re-pointed', () => { + const store = createStore() + store.addRepo( + makeRepo({ + id: 'remote', + path: '/remote/repo', + connectionId: 'old-target', + executionHostId: 'ssh:old-target' + }) + ) + const worktreeId = 'remote::/remote/repo' + store.setWorkspaceSession(sessionWithTab(worktreeId, 'ssh:old-target@@pty-1'), 'ssh:old-target') + const { initial, pushesAfter } = watchPushes(store) + expect(initial.map((slice) => slice.hostId)).toEqual(['ssh:old-target']) + + const pushes = pushesAfter(() => store.reassignSshTargetId('old-target', 'new-target')) + + expect(pushes).toHaveLength(1) + expect(pushes[0]).toMatchObject({ hostId: 'ssh:new-target', worktreeId }) + expect(pushes[0]!.tabs[0]!.ptyId).toBe('ssh:new-target@@pty-1') + expect(pushes[0]!.layouts.tab!.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'ssh:new-target@@pty-1' + }) + }) + + it('pushes the unbound pane when main terminates its SSH lease', () => { + const store = createStore() + const worktreeId = 'wt1' + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId, + tabId: 'tab', + leafId: TEST_LEAF_1, + state: 'attached' + }) + store.setWorkspaceSession(sessionWithTab(worktreeId, 'ssh:ssh-1@@remote-pty')) + const { pushesAfter } = watchPushes(store) + + // The cleanup edits the session in place; the push must still see it. + const pushes = pushesAfter(() => + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + ) + + expect(pushes).toHaveLength(1) + expect(pushes[0]!.tabs[0]!.ptyId).toBeNull() + expect(pushes[0]!.layouts.tab!.ptyIdsByLeafId).toEqual({}) + }) +}) From 0ec572398f03223f23db4d754170a07d9de5e703 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:09:06 -0400 Subject: [PATCH 61/75] fix(terminal): pass the mirror's active-leaf resolution its fields by name --- .../src/store/terminals/terminal-topology-mirror.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts index b4e478f2930a..666bc0f67e73 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -55,7 +55,11 @@ function mirrorLayout( layout: TerminalTopologyLayout ): TerminalLayoutSnapshot { if (!current) { - const activeLeafId = resolvePtyBoundActiveLeafId({ ...layout, activeLeafId: null }) + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: null, + ptyIdsByLeafId: layout.ptyIdsByLeafId + }) return { ...emptyLayoutSnapshot(), ...layout, activeLeafId } } if ( From d19658ab51828107d8ac7f692ce50b4604f87af4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:12:28 -0400 Subject: [PATCH 62/75] test(terminal): pin create-before-spawn persistence for launch, adopt, floating and web tabs Covers inventory rows B56, B57, B62, B79, B86, K2. --- ...erminal-surface-create-quit-resume.test.ts | 116 ++++++++++++++++++ ...nal-creation-and-readiness-part-04.spec.ts | 36 ++++++ .../lib/agent-launch-tab-publication.test.ts | 21 +++- .../terminals/terminal-pending-panes.test.ts | 39 ++++++ 4 files changed, 210 insertions(+), 2 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts index d59ef76670f0..f3c82ad6012e 100644 --- a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts +++ b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts @@ -2,6 +2,7 @@ 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 { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { ExecutionHostId } from '../../../shared/execution-host' import type { TerminalSurfaceCreateRequest } from '../../../shared/terminal-surface-create' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -201,6 +202,87 @@ describe('a tab or pane the window creates, quit before it binds', () => { } }) + it('keeps an agent launch tab, its agent and its laid-out pane, before the agent exists', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab: TerminalTab = { ...row('tab-launch', WORKTREE), launchAgent: 'claude' } + // The request a published launch tab sends: its host ids and the launch in the row. + await expect( + create({ + worktreeId: WORKTREE, + tabId: tab.id, + leafId: LEFT, + placement: { + kind: 'new-tab', + row: { title: tab.title, createdAt: tab.createdAt, launchAgent: 'claude' } + } + }) + ).resolves.toMatchObject({ status: 'committed' }) + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [WORKTREE]: [{ ...tab, agentLaunchPane: { leafId: LEFT } }] } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]).toEqual([ + expect.objectContaining({ + id: tab.id, + ptyId: null, + launchAgent: 'claude', + agentLaunchPane: { leafId: LEFT } + }) + ]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a tab adopted for a live PTY whose pane has not mounted', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab: TerminalTab = { ...row('tab-adopted', WORKTREE), ptyId: 'pty-live' } + await expect(create(newTab(WORKTREE, tab, LEFT))).resolves.toMatchObject({ + status: 'committed' + }) + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [WORKTREE]: [tab] } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]?.map((entry) => entry.id)).toEqual([tab.id]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a floating terminal tab, which no repo catalogs, in the local partition', async () => { + const { directory, store, create, stageQuit } = await windowOnStore() + const tab = row('tab-floating', FLOATING_TERMINAL_WORKTREE_ID, 'scratch') + await expect(create(newTab(FLOATING_TERMINAL_WORKTREE_ID, tab, LEFT))).resolves.toMatchObject({ + status: 'committed' + }) + const window = structuredClone(store.getWorkspaceSession()) + window.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab] } + expect(stageQuit(window)).toEqual({ ok: true }) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect( + session.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.map((entry) => [ + entry.id, + entry.customTitle + ]) + ).toEqual([[tab.id, 'scratch']]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + } finally { + await relaunched.freezeWritesAsync() + } + }) + it('refuses a tab main recorded as closed, and a split of a pane it does not hold', async () => { const { store, create } = await windowOnStore() const tab = row('tab-closed', WORKTREE) @@ -228,3 +310,37 @@ describe('a tab or pane the window creates, quit before it binds', () => { ]) }) }) + +describe('a tab a phone closes on the desktop', () => { + // The window commits the close, then saves its whole session before answering the phone. + it('stays closed through the window’s full save and a relaunch, keeping the rest', async () => { + const { directory, store, create } = await windowOnStore() + const kept = row('tab-kept', WORKTREE) + const closed = row('tab-closed', WORKTREE) + await create(newTab(WORKTREE, kept, LEFT)) + await create(newTab(WORKTREE, closed, RIGHT)) + const before = structuredClone(store.getWorkspaceSession()) + + const close = invokeHandlers.get('session:close-terminal-surface')!( + {}, + { worktreeId: WORKTREE, target: { kind: 'tab', tabId: closed.id } } + ) + // A debounced save taken before the close races it; the full save follows. + await invokeHandlers.get('session:set')!({}, before) + const window = structuredClone(before) + window.tabsByWorktree = { [WORKTREE]: [{ ...kept, customTitle: 'server' }] } + await invokeHandlers.get('session:set')!({}, window) + await close + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession() + expect(session.tabsByWorktree[WORKTREE]?.map((tab) => [tab.id, tab.customTitle])).toEqual([ + [kept.id, 'server'] + ]) + expect(session.terminalLayoutsByTabId[closed.id]).toBeUndefined() + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index 534891782482..3f394dbde905 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -273,6 +273,42 @@ describe('OrcaRuntimeService', () => { ]) }) + // As before the topology core: a background worker's title names its terminal, never a desktop tab. + it("keeps a background create's title off every tab, revealing none", async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-worker' }) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-worker' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker-1', + presentation: 'background' + }) + + expect(created).toMatchObject({ title: 'worker-1', surface: 'background' }) + expect(revealTerminalSession).not.toHaveBeenCalled() + expect(JSON.stringify(spawn.mock.calls[0]?.[0])).not.toContain('worker-1') + }) + // Why (flipped by the aug20 "windows 2" incident): #8646 scoped the persisted // binding to windowless promotion, which left a host-initiated terminal on a // host running the full app with neither a persisted tab nor runtime diff --git a/src/renderer/src/lib/agent-launch-tab-publication.test.ts b/src/renderer/src/lib/agent-launch-tab-publication.test.ts index 8d9ec4c3f0b8..c22b1512b9c0 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { AgentLaunchTabPublishRequest } from '../../../shared/agent-launch-tab-publication' import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' @@ -30,7 +30,7 @@ vi.mock('@/hooks/ipc-events/terminal-command-state', async (importOriginal) => ( focusTerminalInitiatedTab })) -createTabsSliceMockApi() +const mockApi = createTabsSliceMockApi() const { publishAgentLaunchTab } = await import('./agent-launch-tab-publication') const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') @@ -88,6 +88,23 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchTab()?.viewMode).toBe('chat') }) + it('records the tab, its launch and its pane in main before the agent exists', () => { + const createTerminalSurface = vi.fn() + vi.stubGlobal('window', { api: { ...mockApi, session: { createTerminalSurface } } }) + onTestFinished(() => { + vi.unstubAllGlobals() + }) + + publishAgentLaunchTab(request()) + + expect(createTerminalSurface).toHaveBeenCalledWith({ + worktreeId: WT, + tabId: TAB_ID, + leafId: LEAF_ID, + placement: { kind: 'new-tab', row: expect.objectContaining({ launchAgent: 'claude' }) } + }) + }) + it('keeps the prompt for the pane to offer if its agent does not start', () => { publishAgentLaunchTab(request({ prompt: 'fix the build' })) expect(agentLaunchPanePrompt(TAB_ID)).toBe('fix the build') diff --git a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts index e799c64139e1..36ed7317ac8e 100644 --- a/src/renderer/src/store/terminals/terminal-pending-panes.test.ts +++ b/src/renderer/src/store/terminals/terminal-pending-panes.test.ts @@ -5,8 +5,10 @@ import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' +import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' import { commitTerminalPaneIfAheadOfMain, + isPendingTerminalTab, pendingTerminalLeafIds, terminalTabPanesNamed } from './terminal-pending-panes' @@ -127,6 +129,43 @@ describe('a tab this window creates', () => { }) }) +describe('a tab adopted for a live PTY no pane has mounted', () => { + it('is committed to main with its pane at once, not on its first attach', () => { + const main = stubReplies('createTerminalSurface') + const tab = state().createTab(WT, undefined, undefined, { + initialPtyId: 'pty-live', + activate: false, + recordInteraction: false + }) + + const [leafId] = collectLeafIds(state().terminalLayoutsByTabId[tab.id]?.root) + expect(leafId).toBeDefined() + expect(main.calls).toHaveBeenCalledWith({ + worktreeId: WT, + tabId: tab.id, + leafId, + placement: { kind: 'new-tab', row: expect.objectContaining({ title: tab.title }) } + }) + expect(isPendingTerminalTab(state().pendingTerminalPanes, WT, tab.id, 'add')).toBe(true) + }) +}) + +describe('a web client', () => { + // Its tabs reach the host through session-tab RPCs and no push from main settles them. + it('commits nothing to main and holds no pending entry for a tab it makes', () => { + const createTerminalSurface = vi.fn() + vi.stubGlobal('window', { + __ORCA_WEB_CLIENT__: true, + api: { session: { createTerminalSurface } } + }) + const tab = state().createTab(WT) + + expect(createTerminalSurface).not.toHaveBeenCalled() + expect(state().pendingTerminalPanes).toEqual([]) + expect(tabIds()).toEqual(['a', tab.id]) + }) +}) + describe('a worktree a paired Orca server hosts', () => { // Main publishes no slice for it, so nothing would ever settle an entry held here. it('holds no pending entry for a tab or pane made here', () => { From b654e918aa436ba92932e69b26d40bfa7deccdf9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:12:48 -0400 Subject: [PATCH 63/75] fix(terminal): a pane main's normalizer drops takes its PTY incarnation with it --- .../normalized-pane-incarnations.ts | 29 +++++++++++++++++++ .../restoring-sessions/pane-key-remapping.ts | 13 +++++++++ ...workspace-pane-normalization-index.test.ts | 16 ++++++++-- .../workspace-pane-normalization.ts | 17 ++--------- 4 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts diff --git a/src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts b/src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts new file mode 100644 index 000000000000..58cba8352c15 --- /dev/null +++ b/src/main/persistence/restoring-sessions/normalized-pane-incarnations.ts @@ -0,0 +1,29 @@ +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { layoutContainsLeafId } from './terminal-layout-normalization' + +/** The session with its normalized layouts; a pane the normalizer dropped takes its PTY incarnation. */ +export function withKeptPaneIncarnations( + session: WorkspaceSessionState, + terminalLayoutsByTabId: Record +): WorkspaceSessionState { + const incarnations = session.terminalPtyIncarnationsByPaneKey + if (!incarnations) { + return { ...session, terminalLayoutsByTabId } + } + const kept = Object.entries(incarnations).filter(([paneKey]) => { + const pane = parsePaneKey(paneKey) + const layout = pane && terminalLayoutsByTabId[pane.tabId] + return ( + !layout || + layout === session.terminalLayoutsByTabId[pane.tabId] || + layoutContainsLeafId(layout.root, pane.leafId) + ) + }) + return { + ...session, + terminalLayoutsByTabId, + terminalPtyIncarnationsByPaneKey: Object.fromEntries(kept) + } +} diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.ts index 8e43184b0c5f..6b022575939c 100644 --- a/src/main/persistence/restoring-sessions/pane-key-remapping.ts +++ b/src/main/persistence/restoring-sessions/pane-key-remapping.ts @@ -72,3 +72,16 @@ export function remapActivityClearedAtPaneKeys( const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId) return { cutoffs: result.values, changed: result.changed } } + +/** Acknowledgement keys lack host metadata, so an already-mapped tab keeps its mapping. */ +export function mergeAcknowledgementLeafIdMapsByTabId( + target: Map>, + source: Map> +): Map> { + const merged = new Map(target) + for (const [tabId, leafIds] of source) { + const existing = merged.get(tabId) + merged.set(tabId, existing ? new Map([...leafIds, ...existing]) : new Map(leafIds)) + } + return merged +} diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts index ceb1fde7d1a8..4e2b19ea95aa 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts @@ -196,9 +196,14 @@ describe('workspace pane normalization tab index', () => { ptyIdsByLeafId: { [STABLE_LEAF_ID]: 'pty-1', [second]: 'pty-1' } } - const result = normalizeWorkspaceSessionPaneIdentities( - workspaceSession({ wt: [terminalTab('tab', 'wt', 'pty-1')] }, { tab: layout }) - ) + const result = normalizeWorkspaceSessionPaneIdentities({ + ...workspaceSession({ wt: [terminalTab('tab', 'wt', 'pty-1')] }, { tab: layout }), + terminalPtyIncarnationsByPaneKey: { + [`tab:${STABLE_LEAF_ID}`]: 'incarnation-dropped', + [`tab:${second}`]: 'incarnation-kept', + 'unlaid:leaf': 'incarnation-of-a-tab-without-layout' + } + }) expect(result.changed).toBe(true) expect(result.session.terminalLayoutsByTabId.tab).toEqual({ @@ -207,5 +212,10 @@ describe('workspace pane normalization tab index', () => { expandedLeafId: null, ptyIdsByLeafId: { [second]: 'pty-1' } }) + // The dropped pane's incarnation goes with it, as a save used to prune it. + expect(result.session.terminalPtyIncarnationsByPaneKey).toEqual({ + [`tab:${second}`]: 'incarnation-kept', + 'unlaid:leaf': 'incarnation-of-a-tab-without-layout' + }) }) }) diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index 38ec014c7c96..1d0fde7445e1 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -17,6 +17,7 @@ import { type PaneAliasNormalizationOptions } from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' +import { withKeptPaneIncarnations } from './normalized-pane-incarnations' import { legacyMigrationUnsupportedRowsToAliasEntries, legacyPaneKeyAliasEntriesEqual, @@ -25,6 +26,7 @@ import { normalizeLegacyPaneKeyAliasEntries } from './pane-alias-normalization' import { + mergeAcknowledgementLeafIdMapsByTabId, remapAcknowledgedAgentPaneKeys, remapActivityClearedAtPaneKeys, remapManuallyUnreadTurnPaneKeys @@ -103,7 +105,7 @@ export function normalizeWorkspaceSessionPaneIdentities( changed ||= normalized.changed } return { - session: changed ? { ...session, terminalLayoutsByTabId } : session, + session: changed ? withKeptPaneIncarnations(session, terminalLayoutsByTabId) : session, changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, @@ -161,19 +163,6 @@ export function remapSshRemotePtyLeaseLeafIds( return { leases: nextLeases, changed } } -/** Acknowledgement keys lack host metadata, so an already-mapped tab keeps its mapping. */ -function mergeAcknowledgementLeafIdMapsByTabId( - target: Map>, - source: Map> -): Map> { - const merged = new Map(target) - for (const [tabId, leafIds] of source) { - const existing = merged.get(tabId) - merged.set(tabId, existing ? new Map([...leafIds, ...existing]) : new Map(leafIds)) - } - return merged -} - export function normalizePersistedPaneIdentityState( state: PersistedState, options: PaneAliasNormalizationOptions = {} From 13d0ca18f5dabab09930b49057e4193f63797267 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:15:06 -0400 Subject: [PATCH 64/75] test(terminal): a worktree rename reaches the window before either id's topology push --- .../first-work-folder-rename.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index d8a8a54161e0..cd2d9b7f89ec 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + TerminalTopologyPublisher, + type TerminalTopologyOwners +} from '../runtime/terminal-topology-publisher' import { renameWorktreeFolderOnFirstWork, type FirstWorkFolderRenameDeps @@ -97,4 +102,52 @@ describe('renameWorktreeFolderOnFirstWork', () => { expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false) expect(deps.moveWorktree).not.toHaveBeenCalled() }) + + it("tells the window about the rename before main pushes either id's topology", async () => { + const NEW_ID = 'repo1::/ws/worktree-creation-spinner' + const tab = (worktreeId: string) => ({ + id: 'tab', + ptyId: 'pty', + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }) + const ownedBy = (worktreeId: string): TerminalTopologyOwners => + new Map([ + [ + worktreeId, + { + hostId: 'local', + session: { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [tab(worktreeId)] } + } + } + ] + ]) + let owners = ownedBy(OLD_ID) + const order: string[] = [] + const publisher = new TerminalTopologyPublisher( + () => owners, + (slice) => order.push(`${slice.worktreeId}: ${slice.tabs.length} tab(s)`) + ) + publisher.snapshot() + order.length = 0 + + const deps = makeDeps({ + migrateWorktreeIdentity: vi.fn(() => { + owners = ownedBy(NEW_ID) + publisher.markDirty() + }), + notifyWorktreeRenamed: vi.fn(() => order.push('renamed')) + }) + await renameWorktreeFolderOnFirstWork(OLD_ID, 'worktree-creation-spinner', deps) + await Promise.resolve() + + // The window re-keys its tabs first, so the old id's empty slice finds none to drop. + expect(order).toEqual(['renamed', `${OLD_ID}: 0 tab(s)`, `${NEW_ID}: 1 tab(s)`]) + }) }) From 78971dbbef90305c36b6e7fc93021c5a9dbf81c2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:18:48 -0400 Subject: [PATCH 65/75] fix(terminal): a tab minted for a live agent PTY is bound in main at once --- ...erminal-surface-create-quit-resume.test.ts | 22 ++++++--- .../worktree-agent-activation-gate.test.ts | 25 ++++++++--- .../worktree-agent-live-surface-adoption.ts | 45 ++++++++++++------- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts index f3c82ad6012e..aa73e7bae39d 100644 --- a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts +++ b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts @@ -238,12 +238,17 @@ describe('a tab or pane the window creates, quit before it binds', () => { } }) - it('keeps a tab adopted for a live PTY whose pane has not mounted', async () => { + it('keeps a tab adopted for a live PTY whose pane has not mounted, bound to it', async () => { const { directory, store, create, stageQuit } = await windowOnStore() const tab: TerminalTab = { ...row('tab-adopted', WORKTREE), ptyId: 'pty-live' } - await expect(create(newTab(WORKTREE, tab, LEFT))).resolves.toMatchObject({ - status: 'committed' - }) + // The adoption sends its create and its binding back to back, without awaiting the create. + const created = create(newTab(WORKTREE, tab, LEFT)) + const bound = invokeHandlers.get('session:terminal-bind-leaf')!( + {}, + { worktreeId: WORKTREE, tabId: tab.id, leafId: LEFT, ptyId: 'pty-live' } + ) + await expect(created).resolves.toMatchObject({ status: 'committed' }) + await expect(bound).resolves.toMatchObject({ status: 'bound' }) const window = structuredClone(store.getWorkspaceSession()) window.tabsByWorktree = { [WORKTREE]: [tab] } expect(stageQuit(window)).toEqual({ ok: true }) @@ -251,8 +256,13 @@ describe('a tab or pane the window creates, quit before it binds', () => { const relaunched = await reopenTopologyStore(store, directory) try { const session = relaunched.getWorkspaceSession() - expect(session.tabsByWorktree[WORKTREE]?.map((entry) => entry.id)).toEqual([tab.id]) - expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual({ type: 'leaf', leafId: LEFT }) + expect(session.tabsByWorktree[WORKTREE]?.map((entry) => [entry.id, entry.ptyId])).toEqual([ + [tab.id, 'pty-live'] + ]) + expect(session.terminalLayoutsByTabId[tab.id]).toMatchObject({ + root: { type: 'leaf', leafId: LEFT }, + ptyIdsByLeafId: { [LEFT]: 'pty-live' } + }) } finally { await relaunched.freezeWritesAsync() } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index dbf30002de48..268bd60dffc8 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -281,13 +281,24 @@ describe('worktree agent activation gate', () => { sessions: [listed(ptyId)], surfaceOwners: new Map([[ptyId, UNOWNED]]) }) + const bindTerminalLeaf = vi.fn(async () => ({ status: 'bound' as const })) + vi.stubGlobal('window', { api: { session: { bindTerminalLeaf } } }) - await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + try { + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + } finally { + vi.unstubAllGlobals() + } - expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { - initialPtyId: ptyId, - activate: false, - recordInteraction: false + expect(createTab).toHaveBeenCalledOnce() + // Main binds the minted pane at once, so a restart before it mounts reattaches the agent. + const [minted] = deps.getState().tabsByWorktree[WORKTREE_ID] + const leafId = deps.getState().terminalLayoutsByTabId[minted.id]?.activeLeafId + expect(bindTerminalLeaf).toHaveBeenCalledExactlyOnceWith({ + worktreeId: WORKTREE_ID, + tabId: minted.id, + leafId, + ptyId }) expect(resume).not.toHaveBeenCalled() }) @@ -311,6 +322,7 @@ describe('worktree agent activation gate', () => { expect(createTab).toHaveBeenCalledOnce() expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: livePtyId, activate: false, recordInteraction: false @@ -433,6 +445,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: ptyId, activate: false, recordInteraction: false @@ -453,6 +466,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') expect(createTab).toHaveBeenCalledWith(folderWorkspaceId, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: ptyId, activate: false, recordInteraction: false @@ -901,6 +915,7 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialLeafId: expect.any(String), initialPtyId: livePtyId, activate: false, recordInteraction: false diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 85b58dae1148..a32eae97774f 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -1,6 +1,7 @@ import { parsePaneKey } from '../../../shared/stable-pane-id' import { worktreeIdsEqual } from '../../../shared/worktree/id' import type { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id' import type { LiveTerminalSurfaceOwner, @@ -57,23 +58,39 @@ export function bindLivePtyToExactSurface( return false } store.updateTabPtyId(terminal.tabId, terminal.ptyId) - // The pane's binding is main's: it records the adopted PTY, and its push brings it to the pane. - void globalThis.window?.api?.session?.bindTerminalLeaf?.({ - worktreeId: existing.ownerWorktreeId, - tabId: terminal.tabId, - leafId: pane.leafId, - ptyId: terminal.ptyId - }) + bindLeafInMain(existing.ownerWorktreeId, terminal.tabId, pane.leafId, terminal.ptyId) return true } + return mintTabForLivePty(store, worktreeId, terminal.ptyId, { + tabId: terminal.tabId, + leafId: pane.leafId + }) +} + +/** The pane's binding is main's: it records the adopted PTY, and its push brings it to the pane. */ +function bindLeafInMain(worktreeId: string, tabId: string, leafId: string, ptyId: string): void { + void globalThis.window?.api?.session?.bindTerminalLeaf?.({ worktreeId, tabId, leafId, ptyId }) +} + +/** A new tab for a live PTY; bound in main at once, so a restart before its pane mounts reattaches. */ +function mintTabForLivePty( + store: LiveSurfaceAdoptionStore, + worktreeId: string, + ptyId: string, + { tabId, leafId }: { tabId?: string; leafId: string } +): boolean { const created = store.createTab(worktreeId, undefined, undefined, { - id: terminal.tabId, - initialLeafId: pane.leafId, - initialPtyId: terminal.ptyId, + ...(tabId ? { id: tabId } : {}), + initialLeafId: leafId, + initialPtyId: ptyId, activate: false, recordInteraction: false }) - return created.id === terminal.tabId + if (tabId && created.id !== tabId) { + return false + } + bindLeafInMain(worktreeId, created.id, leafId, ptyId) + return true } function tabExists(store: LiveSurfaceAdoptionStore, tabId: string): boolean { @@ -205,11 +222,7 @@ export async function adoptLiveWorkspacePtySurfaces( ) { continue } - getState().createTab(worktreeId, undefined, undefined, { - initialPtyId: ptyId, - activate: false, - recordInteraction: false - }) + mintTabForLivePty(getState(), worktreeId, ptyId, { leafId: createBrowserUuid() }) } return { surfaced, declinedPtyIds } } From 913f6841234ec4e458f99ecb191268eaffb211df Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:24:23 -0400 Subject: [PATCH 66/75] fix(persistence): apply old close records on the first write, as main does Load keeps them, as on main; the first write applies and clears them, so main's identity-migration assertion holds again unchanged. --- ...-terminal-surface-tombstones-write.test.ts | 95 +++++++++++++++++++ ...ence-cohort-and-identity-migration.test.ts | 5 +- ...y-terminal-surface-tombstones-load.test.ts | 75 --------------- .../normalize-loaded-state-collections.ts | 3 +- .../loading-store/session-host-partitions.ts | 2 + .../workspace-session-partitions.ts | 5 +- .../workspace-session-snapshot-publication.ts | 6 +- 7 files changed, 107 insertions(+), 84 deletions(-) create mode 100644 src/main/legacy-terminal-surface-tombstones-write.test.ts delete mode 100644 src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts diff --git a/src/main/legacy-terminal-surface-tombstones-write.test.ts b/src/main/legacy-terminal-surface-tombstones-write.test.ts new file mode 100644 index 000000000000..c0d14357017f --- /dev/null +++ b/src/main/legacy-terminal-surface-tombstones-write.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultPersistedState } from '../shared/constants' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { hasHostAuthoritativeTerminalMembership } from './persistence/terminal-topology/terminal-topology-membership' +import { + createStore, + makeRepo, + makeTerminalTab, + testState, + writeDataFile +} from './persistence-test-harness' +import { TEST_LEAF_1 } from './persistence-session-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const WORKTREE = 'repo1::/worktree' +const RUNTIME_HOST = 'runtime:env-1' + +/** A session an older build saved: its close record was left for the next write to apply. */ +function sessionWithUnappliedClose(incarnationId: string): Partial { + return { + tabsByWorktree: { + [WORKTREE]: [makeTerminalTab({ id: 'closed-tab', ptyId: 'pty-1', worktreeId: WORKTREE })] + }, + terminalLayoutsByTabId: { + 'closed-tab': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [`closed-tab:${TEST_LEAF_1}`]: 'incarnation-a' }, + terminalSurfaceTombstonesByPaneKey: { + [`closed-tab:${TEST_LEAF_1}`]: { + worktreeId: WORKTREE, + parentTabId: 'closed-tab', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId, + retiredAt: 42 + } + } + } +} + +async function storeLoadedWith(hostId: string | null, incarnationId: string) { + const persisted = getDefaultPersistedState(testState.dir) + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] + const session = { ...persisted.workspaceSession, ...sessionWithUnappliedClose(incarnationId) } + if (hostId) { + persisted.workspaceSessionsByHostId = { [hostId]: session } + } else { + persisted.workspaceSession = session + } + writeDataFile(persisted) + return createStore() +} + +describe('close records an older build left unapplied', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('are applied by the first write, so the closed tab stays closed, then dropped', async () => { + const store = await storeLoadedWith(null, 'incarnation-a') + + store.setWorkspaceSession(store.getWorkspaceSession()) + + const written = store.getWorkspaceSession() + expect(written.tabsByWorktree[WORKTREE]).toEqual([]) + expect(written.terminalLayoutsByTabId['closed-tab']).toBeUndefined() + expect(written.terminalSurfaceTombstonesByPaneKey).toEqual({}) + }) + + it('are dropped when they cannot apply, so a runtime partition is not host-authoritative', async () => { + const store = await storeLoadedWith(RUNTIME_HOST, 'incarnation-b') + + store.setWorkspaceSession(store.getWorkspaceSession(RUNTIME_HOST), RUNTIME_HOST) + + const written = store.getWorkspaceSession(RUNTIME_HOST) + expect(written.tabsByWorktree[WORKTREE]).toHaveLength(1) + expect(written.terminalSurfaceTombstonesByPaneKey).toEqual({}) + expect(hasHostAuthoritativeTerminalMembership(written, WORKTREE)).toBe(false) + }) +}) diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index a7ac6f13dee4..02afa7e3da57 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -378,9 +378,8 @@ describe('Store.migrateWorktreeIdentity', () => { expect(hostSession.tabsByWorktree[OLD]).toBeUndefined() expect(hostSession.tabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(hostSession.activeWorkspaceKey).toBe(NEW_WORKSPACE_KEY) - expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(8) - // A legacy close record stays a fence, now naming the renamed worktree. - expect(hostSession.terminalSurfaceTombstonesByPaneKey?.['host-tab:leaf']?.worktreeId).toBe(NEW) + expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(9) + expect(hostSession.terminalSurfaceTombstonesByPaneKey).toEqual({}) }) it('rewrites parentWorktreeId back-references in other lineage entries', async () => { diff --git a/src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts b/src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts deleted file mode 100644 index f3ec10209f1b..000000000000 --- a/src/main/persistence/loading-store/legacy-terminal-surface-tombstones-load.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { hasHostAuthoritativeTerminalMembership } from '../terminal-topology/terminal-topology-membership' -import { normalizeLoadedLocalSession } from './normalize-loaded-state-collections' -import { parseWorkspaceSessionsByHostId } from './workspace-session-partitions' - -const WORKTREE_ID = 'repo::/worktree' - -/** A profile an older build saved: its close record was left for the next write to apply. */ -function sessionWithUnappliedClose(incarnationId: string): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: 'closed-tab', - ptyId: 'pty-1', - worktreeId: WORKTREE_ID, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'closed-tab': { - root: { type: 'leaf', leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null, - ptyIdsByLeafId: { 'leaf-1': 'pty-1' } - } - }, - terminalPtyIncarnationsByPaneKey: { 'closed-tab:leaf-1': 'incarnation-a' }, - terminalSurfaceTombstonesByPaneKey: { - 'closed-tab:leaf-1': { - worktreeId: WORKTREE_ID, - parentTabId: 'closed-tab', - leafId: 'leaf-1', - ptyId: 'pty-1', - incarnationId, - retiredAt: 42 - } - } - } -} - -describe('loading a session an older build left close records in', () => { - it('applies them, so the closed tab stays closed, then drops them', () => { - const defaults = getDefaultPersistedState('/home/user') - const loaded = normalizeLoadedLocalSession( - { ...defaults, workspaceSession: sessionWithUnappliedClose('incarnation-a') }, - defaults, - () => {} - ) - - expect(loaded.tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(loaded.terminalLayoutsByTabId['closed-tab']).toBeUndefined() - expect(loaded.terminalSurfaceTombstonesByPaneKey).toEqual({}) - }) - - it('drops one it cannot apply, so a runtime partition does not read as host-authoritative', () => { - const { partitions } = parseWorkspaceSessionsByHostId( - { 'runtime:env-1': sessionWithUnappliedClose('incarnation-b') }, - getDefaultWorkspaceSession() - ) - const loaded = partitions['runtime:env-1'] - - expect(loaded?.tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - expect(loaded?.terminalSurfaceTombstonesByPaneKey).toEqual({}) - expect(hasHostAuthoritativeTerminalMembership(loaded, WORKTREE_ID)).toBe(false) - }) -}) diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 19d522223f7e..16c5b424def8 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -5,7 +5,6 @@ import { } from '../../../shared/project-catalog-row-normalization' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' -import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' import { backfillAutomationRunNumbers, pruneAutomationRuns @@ -36,7 +35,7 @@ export function normalizeLoadedLocalSession( // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. markNeedsSave() } - return applyLegacyTerminalSurfaceTombstones({ ...defaults.workspaceSession, ...result.value }) + return { ...defaults.workspaceSession, ...result.value } } export function normalizeLoadedHostSessions( diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 5e39b60cf914..93a59e00227d 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -1,5 +1,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, @@ -184,6 +185,7 @@ export function setHostWorkspaceSession( hostId: ExecutionHostId, session: WorkspaceSessionState ): void { + session = applyLegacyTerminalSurfaceTombstones(session) // Why here too: the load-side drop only survives until the next full snapshot write. A renderer // or runtime payload that still carries local's globals would re-inject them into this partition. const pruned = withoutRedundantGlobalFields( diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts index a0b1f1a9de70..f2b5bd291740 100644 --- a/src/main/persistence/loading-store/workspace-session-partitions.ts +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -6,7 +6,6 @@ import { } from '../../../shared/execution-host' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership' -import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' export function workspaceSessionSalvageLogDetails(result: { droppedCount: number @@ -55,10 +54,10 @@ export function parseWorkspaceSessionsByHostId( } // Runs before the defaults spread, so a field the type requires comes back at its default // rather than going missing. - partitions[hostId] = applyLegacyTerminalSurfaceTombstones({ + partitions[hostId] = { ...defaults, ...withoutRedundantGlobalFields(result.value, localSession) - }) + } } return { partitions, repaired } } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index ff2e2ed06ded..1716cb687478 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -1,5 +1,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { applyLegacyTerminalSurfaceTombstones } from '../../runtime/mobile-session-terminal-persistence-retirement' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' @@ -33,7 +34,10 @@ export function setLocalWorkspaceSession( const context = getSessionSnapshotOperationsContext(owner) const prior = context.runtime.state.workspaceSession session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + pruneLocalTerminalScrollbackBuffers( + applyLegacyTerminalSurfaceTombstones(session), + context.runtime.state.repos + ) ) const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) From 6a95d6e2a8736b182932cfe221467c6f90483e3b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:29:36 -0400 Subject: [PATCH 67/75] fix(ssh): in a repo main fenced, a host pull keeps main's tabs and panes, as its rebase did --- ...rminal-peer-import-partition-write.test.ts | 66 ++++++++++++------- .../terminal-peer-import-rows.ts | 51 +++++++++++--- .../terminal-topology-commit.test.ts | 16 ++++- 3 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts index 4015c73ab4ee..92bf7b3d11b4 100644 --- a/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts +++ b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts @@ -49,38 +49,56 @@ function leafLayout(tabId: string): WorkspaceSessionState['terminalLayoutsByTabI } } +function mainSession(revision: number): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE_ID]: [tab('main-tab')] }, + terminalLayoutsByTabId: { 'main-tab': leafLayout('main-tab') }, + terminalPtyIncarnationsByPaneKey: { [`main-tab:${LEAF}`]: 'main-incarnation' }, + terminalTopologyRevisionByRepoId: { 'ssh-repo': revision } + } +} + +function importHostPull(store: Store): WorkspaceSessionState { + importPeerTopology( + store, + 'target-1', + { + tabsByWorktree: { [WORKTREE_ID]: [tab('host-tab')] }, + terminalLayoutsByTabId: { 'host-tab': leafLayout('host-tab') }, + terminalPtyIncarnationsByPaneKey: { [`host-tab:${LEAF}`]: 'host-incarnation' } + }, + () => false + ) + return store.getWorkspaceSession(HOST_ID) +} + describe('a host pull into an SSH partition main has published nothing since', () => { - // Cross-check: the import is a plain patch, so rows and every record the pull names are the host's. - it("replaces main's rows and the pane records the pull names", () => { + // As main's membership rebase did: once main fenced the repo, its tabs and panes stand. + it("keeps main's tabs and pane records in a repo main has fenced, dropping host-only tabs", () => { const store = openStore() - store.setWorkspaceSession( - { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [tab('main-tab')] }, - terminalLayoutsByTabId: { 'main-tab': leafLayout('main-tab') }, - terminalPtyIncarnationsByPaneKey: { [`main-tab:${LEAF}`]: 'main-incarnation' }, - terminalTopologyRevisionByRepoId: { 'ssh-repo': 1 } - }, - HOST_ID - ) + store.setWorkspaceSession(mainSession(1), HOST_ID) - importPeerTopology( - store, - 'target-1', - { - tabsByWorktree: { [WORKTREE_ID]: [tab('host-tab')] }, - terminalLayoutsByTabId: { 'host-tab': leafLayout('host-tab') }, - terminalPtyIncarnationsByPaneKey: { [`host-tab:${LEAF}`]: 'host-incarnation' } - }, - () => false - ) + const saved = importHostPull(store) + + expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual(['main-tab']) + expect(Object.keys(saved.terminalLayoutsByTabId)).toEqual(['main-tab']) + expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ + [`main-tab:${LEAF}`]: 'main-incarnation' + }) + expect(saved.terminalTopologyRevisionByRepoId).toEqual({ 'ssh-repo': 1 }) + }) + + it("replaces main's rows and the pane records the pull names in a repo main never fenced", () => { + const store = openStore() + store.setWorkspaceSession(mainSession(0), HOST_ID) + + const saved = importHostPull(store) - const saved = store.getWorkspaceSession(HOST_ID) expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual(['host-tab']) expect(Object.keys(saved.terminalLayoutsByTabId)).toEqual(['host-tab']) expect(saved.terminalPtyIncarnationsByPaneKey).toEqual({ [`host-tab:${LEAF}`]: 'host-incarnation' }) - expect(saved.terminalTopologyRevisionByRepoId).toEqual({ 'ssh-repo': 1 }) }) }) diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts b/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts index e7fccbc14628..f3aa8addb1b7 100644 --- a/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts +++ b/src/main/persistence/terminal-topology/terminal-peer-import-rows.ts @@ -1,12 +1,16 @@ import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' +import { parsePaneKey } from '../../../shared/stable-pane-id' import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { hasHostAuthoritativeTerminalMembership } from './terminal-topology-membership' /** - * A pull's rows over main's: on a worktree main has published past what the window merged against, - * main's tabs and their layouts stand, and the pull adds only tabs main neither holds nor closed. + * A pull's rows over main's. Main's tabs, their layouts and pane incarnations stand on a worktree + * main has published past what the window merged against, where the pull adds only tabs main + * neither holds nor closed, and in a repo main has fenced, where it adds none (as main's + * membership rebase did). */ export function overNewerMainRows( pull: WorkspaceSessionPatch, @@ -18,19 +22,35 @@ export function overNewerMainRows( } const tabsByWorktree = { ...pull.tabsByWorktree } const layouts = { ...pull.terminalLayoutsByTabId } + // Tabs whose layout and incarnations are main's, and pulled tabs main's membership drops. + const mainTabIds = new Set() + const droppedTabIds = new Set() const worktreeIds = new Set([ ...Object.keys(main.tabsByWorktree), ...Object.keys(pull.tabsByWorktree) ]) - for (const worktreeId of [...worktreeIds].filter(publishedSince)) { + for (const worktreeId of worktreeIds) { + const addsHostTabs = publishedSince(worktreeId) + if (!addsHostTabs && !hasHostAuthoritativeTerminalMembership(main, worktreeId)) { + continue + } const mainTabs = main.tabsByWorktree[worktreeId] ?? [] const held = new Set(mainTabs.map((tab) => tab.id)) - const hostOnly = (pull.tabsByWorktree[worktreeId] ?? []).filter( - (tab) => - !held.has(tab.id) && - !hasClosedTerminalTabRecord(main.closedTerminalTabTombstonesByTabId, tab.id, worktreeId) - ) + const hostOnly = (pull.tabsByWorktree[worktreeId] ?? []).filter((tab) => !held.has(tab.id)) + const added = addsHostTabs + ? hostOnly.filter( + (tab) => + !hasClosedTerminalTabRecord(main.closedTerminalTabTombstonesByTabId, tab.id, worktreeId) + ) + : [] + for (const tab of hostOnly) { + if (!added.includes(tab)) { + droppedTabIds.add(tab.id) + delete layouts[tab.id] + } + } for (const tab of mainTabs) { + mainTabIds.add(tab.id) const layout = main.terminalLayoutsByTabId[tab.id] if (layout) { layouts[tab.id] = layout @@ -38,11 +58,22 @@ export function overNewerMainRows( delete layouts[tab.id] } } - tabsByWorktree[worktreeId] = [...mainTabs, ...hostOnly] + tabsByWorktree[worktreeId] = [...mainTabs, ...added] } + const tabOf = ([paneKey]: [string, string]): string => parsePaneKey(paneKey)?.tabId ?? '' return { ...pull, tabsByWorktree, - ...(pull.terminalLayoutsByTabId && { terminalLayoutsByTabId: layouts }) + ...(pull.terminalLayoutsByTabId && { terminalLayoutsByTabId: layouts }), + ...(pull.terminalPtyIncarnationsByPaneKey && { + terminalPtyIncarnationsByPaneKey: Object.fromEntries([ + ...Object.entries(pull.terminalPtyIncarnationsByPaneKey).filter( + (entry) => !mainTabIds.has(tabOf(entry)) && !droppedTabIds.has(tabOf(entry)) + ), + ...Object.entries(main.terminalPtyIncarnationsByPaneKey ?? {}).filter((entry) => + mainTabIds.has(tabOf(entry)) + ) + ]) + }) } } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts index 42539af8aa88..df2e6c8cf91b 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -152,7 +152,8 @@ describe('persistence.terminal-topology span', () => { it("commits an SSH import into that target's partition alone", () => { const patchWorkspaceSession = vi.fn() - const store = { getWorkspaceSession: () => session(), patchWorkspaceSession } + const unfenced = { ...session(), terminalTopologyRevisionByRepoId: {} } + const store = { getWorkspaceSession: () => unfenced, patchWorkspaceSession } importPeerTopology(store, 'target-1', { tabsByWorktree: {} }, () => false) importPeerTopology(store, 'target-1', {}, () => false) @@ -250,9 +251,20 @@ describe('an SSH import against a mirror main has published past', () => { }) it('applies the pull as the window merged it when main has published nothing since', () => { - const { kept, written } = importInto(mainAfterWindowRead(), false) + const unfenced = { ...mainAfterWindowRead(), terminalTopologyRevisionByRepoId: {} } + const { kept, written } = importInto(unfenced, false) expect(written).toEqual(windowPull()) expect(kept).toBe(false) }) + + it("keeps main's rows and adds no host tab in a repo main fenced, as its rebase did", () => { + const { written } = importInto(mainAfterWindowRead(), false) + + expect(written.tabsByWorktree?.[WORKTREE_ID]?.map((row) => row.id)).toEqual([ + SPLIT_TAB, + PINNED_TAB, + NEW_TAB + ]) + }) }) From 9a5c925e93eb32af7935bdba386acd8e459828d8 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:40:26 -0400 Subject: [PATCH 68/75] test(terminal): a push never puts back an SSH attachment the window cleared --- .../store/terminals/terminal-topology-mirror.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts index b775e7b23def..d1aa168ad306 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { createSessionWriteSubscriber } from '@/lib/session-write-subscriber' +import { clearDirectSshTerminalBindings } from '../slices/direct-ssh-terminal-recovery' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' @@ -201,6 +202,16 @@ describe('applyTerminalTopologySlices', () => { dispose() }) + it('never puts back an SSH attachment the window cleared for a lost connection', () => { + const cleared = clearDirectSshTerminalBindings(state(), new Set([WT])).patch + useAppStore.setState(cleared ?? {}) + // Main keeps the persisted binding; reattaching it is main's verdict, not the push's. + apply(slice(2, { tabs: [row('a', { ptyId: 'pty-a' }), row('b')] })) + + expect(state().tabsByWorktree[WT].map((tab) => tab.ptyId)).toEqual([null, null]) + expect(state().ptyIdsByTabId).toEqual({ a: [], b: [] }) + }) + it('clears an optional row field main dropped', () => { apply(slice(2, { tabs: [row('a', { launchAgent: 'codex' }), row('b')] })) apply(slice(3)) From fc74b9a8010c047d6328523eddd03ff87a80c117 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:43:51 -0400 Subject: [PATCH 69/75] fix(terminal): the window saves the tab-bar entry it gives a tab main created --- .../src/lib/session-write-subscriber.ts | 15 ++++++-- .../terminal-topology-mirror.test.ts | 36 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 1b91196d3881..f645e489d3c7 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -6,6 +6,12 @@ import { buildWorkspaceSessionPatch } from './workspace-session-patch' import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection' type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number] + +const MAIN_AUTHORED_SESSION_FIELDS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'sleepingAgentSessionsByPaneKey' +]) type TabsByWorktree = AppState['tabsByWorktree'] type TerminalTab = TabsByWorktree[string][number] type UnifiedTabsByWorktree = AppState['unifiedTabsByWorktree'] @@ -285,11 +291,14 @@ export function createSessionWriteSubscriber({ return } prev = next - // Why: main already holds what a mirror apply wrote; saving it back would only echo it. - if (mirrored) { + // Main already holds the topology a mirror apply wrote; the tab-bar placement it made is ours. + const owedFields = mirrored + ? changedFields.filter((field) => !MAIN_AUTHORED_SESSION_FIELDS.has(field)) + : changedFields + if (mirrored && owedFields.length === 0) { return } - for (const field of changedFields) { + for (const field of owedFields) { pendingChangedFields.add(field) } if (shouldSchedulePersist && !shouldSchedulePersist()) { diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts index d1aa168ad306..bbe253d3915c 100644 --- a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -252,7 +252,15 @@ describe('applyTerminalTopologySlices', () => { vi.advanceTimersByTime(1_000) persist.mockClear() - apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + apply( + slice(2, { + tabs: [row('a', { launchAgent: 'codex' }), row('b')], + layouts: { + ...slice(2).layouts, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b2' } } + } + }) + ) vi.advanceTimersByTime(1_000) expect(persist).not.toHaveBeenCalled() @@ -263,6 +271,32 @@ describe('applyTerminalTopologySlices', () => { dispose() }) + // A hard kill must not rebuild a tab main created somewhere else in the tab bar. + it('saves the tab-bar entry it gives a tab main created, and nothing main holds', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + apply(slice(2, { tabs: [...slice(2).tabs, row('c')] })) + vi.advanceTimersByTime(1_000) + + expect(persist).toHaveBeenCalledTimes(1) + const { patch } = persist.mock.calls[0][0] + expect(patch.unifiedTabs[WT].map((tab: Tab) => tab.id)).toEqual([ + 'a', + 'b', + '/wt/readme.md', + 'c' + ]) + expect(patch.tabGroups[WT][0].tabOrder).toEqual(['a', 'b', '/wt/readme.md', 'c']) + expect(patch).not.toHaveProperty('tabsByWorktree') + expect(patch).not.toHaveProperty('terminalLayoutsByTabId') + dispose() + }) + it('keeps every other tab and layout reference when one tab changes', () => { const before = state() apply( From 8a02a45f369172fb46cbb76529e086ae036d9f9f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 04:51:15 -0400 Subject: [PATCH 70/75] fix(ssh): main moves an SSH workspace's rows an older build left in local to its own partition before a window save drops them --- .../terminal-local-residue-homing.ts | 53 +++++++++++++++++++ .../terminal-renderer-presentation-save.ts | 5 +- ...erminal-surface-create-quit-resume.test.ts | 40 ++++++++++++++ ...ace-session-stranded-partition-adoption.ts | 2 +- 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-local-residue-homing.ts diff --git a/src/main/persistence/terminal-topology/terminal-local-residue-homing.ts b/src/main/persistence/terminal-topology/terminal-local-residue-homing.ts new file mode 100644 index 000000000000..fab88bbf4ec3 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-local-residue-homing.ts @@ -0,0 +1,53 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + adoptStrandedHostPartitionSession, + withoutWorkspaces, + workspaceIdsNamedByPartition +} from '../../../shared/workspace-session-stranded-partition-adoption' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope' +import type { Store } from '../loading-store/store' +import { isTerminalOwnerPartition } from './terminal-topology-membership' + +type ResidueStore = Pick + +/** + * Before a window save drops the rows of a workspace another partition homes, rows an older build + * left here, main moves them to that home when it holds none, as its save of the window's routed + * session used to (#19572, #26098). + */ +export function homeStrandedRowsBeforeWindowSave( + store: ResidueStore, + hostId: ExecutionHostId, + homeHostIdOf: (worktreeId: string) => ExecutionHostId | null +): void { + if (!isTerminalOwnerPartition(hostId)) { + return + } + const strandedByHome = new Map>() + for (const [worktreeId, tabs] of Object.entries( + store.getWorkspaceSession(hostId).tabsByWorktree + )) { + const home = homeHostIdOf(worktreeId) + if ( + tabs.length > 0 && + home && + home !== hostId && + isTerminalOwnerPartition(home) && + (store.getWorkspaceSession(home).tabsByWorktree[worktreeId]?.length ?? 0) === 0 + ) { + const stranded = strandedByHome.get(home) ?? new Set() + stranded.add(normalizeWorkspaceSessionKeyToWorkspaceId(worktreeId)) + strandedByHome.set(home, stranded) + } + } + for (const [home, workspaceIds] of strandedByHome) { + const residue = store.getWorkspaceSession(hostId) + const others = workspaceIdsNamedByPartition(residue) + workspaceIds.forEach((workspaceId) => others.delete(workspaceId)) + const homed = adoptStrandedHostPartitionSession(store.getWorkspaceSession(home), residue, { + foreignSessionKeys: others + }) + store.setWorkspaceSession(homed.session, home) + store.setWorkspaceSession(withoutWorkspaces(residue, workspaceIds), hostId) + } +} diff --git a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts index 1d865b4a2e71..dc83760a8b87 100644 --- a/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts +++ b/src/main/persistence/terminal-topology/terminal-renderer-presentation-save.ts @@ -14,6 +14,7 @@ import { projectTabRow } from '../../runtime/terminal-topology-projection' import type { Store } from '../loading-store/store' import { resolveHostId } from '../loading-store/session-host-partitions' import { sameTerminalLeafSet } from './terminal-layout-set' +import { homeStrandedRowsBeforeWindowSave } from './terminal-local-residue-homing' import { hasHostAuthoritativeTerminalMembership, isTerminalOwnerPartition @@ -63,11 +64,13 @@ function overMainTopology( incoming: T, hostId?: string | null ): T { + const homeHostIdOf = (worktreeId: string) => catalogHomeHostId(store, worktreeId) + homeStrandedRowsBeforeWindowSave(store, resolveHostId(hostId), homeHostIdOf) return mergeRendererPresentationSave( incoming, store.getWorkspaceSession(hostId), resolveHostId(hostId), - (worktreeId) => catalogHomeHostId(store, worktreeId) + homeHostIdOf ) } diff --git a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts index aa73e7bae39d..6e90ab08dc1a 100644 --- a/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts +++ b/src/main/persistence/terminal-topology/terminal-surface-create-quit-resume.test.ts @@ -354,3 +354,43 @@ describe('a tab a phone closes on the desktop', () => { } }) }) + +describe('an SSH workspace whose tabs an older build left in the local partition', () => { + it('keeps them through the first save and a relaunch, in the SSH partition', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stranded-ssh-rows-')) + directories.push(directory) + const tab = row('tab-stranded', SSH_WORKTREE, 'server') + const layout = { + root: { type: 'leaf' as const, leafId: LEFT }, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-stranded' } + } + const profile = JSON.parse(emptyTerminalSessionProfile()) + profile.workspaceSession.tabsByWorktree = { [SSH_WORKTREE]: [tab] } + profile.workspaceSession.terminalLayoutsByTabId = { [tab.id]: layout } + const store = await openTopologyStore(directory, JSON.stringify(profile)) + registerSessionHandlers(store, new OrcaRuntimeService(store)) + + // The window shows the reunited rows and routes them to the workspace's own partition. + const local = structuredClone(store.getWorkspaceSession()) + local.tabsByWorktree = {} + local.terminalLayoutsByTabId = {} + await invokeHandlers.get('session:set')!({}, local) + const ssh = structuredClone(store.getWorkspaceSession(SSH_HOST)) + ssh.tabsByWorktree = { [SSH_WORKTREE]: [tab] } + ssh.terminalLayoutsByTabId = { [tab.id]: layout } + await invokeHandlers.get('session:set')!({}, ssh, SSH_HOST) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const session = relaunched.getWorkspaceSession(SSH_HOST) + expect(session.tabsByWorktree[SSH_WORKTREE]?.map((entry) => entry.customTitle)).toEqual([ + 'server' + ]) + expect(session.terminalLayoutsByTabId[tab.id]?.root).toEqual(layout.root) + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) diff --git a/src/shared/workspace-session-stranded-partition-adoption.ts b/src/shared/workspace-session-stranded-partition-adoption.ts index c36d35b4bef4..e154145203f3 100644 --- a/src/shared/workspace-session-stranded-partition-adoption.ts +++ b/src/shared/workspace-session-stranded-partition-adoption.ts @@ -263,7 +263,7 @@ function entryWorkspaceResolver( } /** The base without any rows for these workspaces, routed by the same indexes the split uses. */ -function withoutWorkspaces( +export function withoutWorkspaces( base: WorkspaceSessionState, workspaceIds: ReadonlySet ): WorkspaceSessionState { From 0eed5ef402326bbac1760833ea56a072d7e0eaa5 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 05:29:08 -0400 Subject: [PATCH 71/75] test: run the new real-SQLite restart tests in the Node runtime project --- config/scripts/vitest-sqlite-runtime-files.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index f1820fd89065..6d86967b1d47 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -165,6 +165,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-store-transactions.test.ts', 'src/main/runtime/agent-session-unsaved-creation-supersession.test.ts', 'src/main/runtime/automation-change-publication.test.ts', + 'src/main/runtime/headless-editor-tab-close-persistence.test.ts', + 'src/main/runtime/headless-terminal-tab-order-persistence.test.ts', 'src/main/runtime/host-terminal-close-persistence-durability.test.ts', 'src/main/runtime/orca-runtime-terminal-close-records.test.ts', 'src/main/runtime/orca-runtime-terminal-surface-close.test.ts', @@ -196,6 +198,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', + 'src/main/legacy-terminal-surface-tombstones-write.test.ts', 'src/main/ssh-expired-lease-pane-readoption.test.ts', 'src/main/ssh-reattach-pane-cardinality.test.ts', 'src/main/ssh/orcad-migration-cutover-coordinator.test.ts', @@ -206,6 +209,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ssh/orcad-runtime-conversion.test.ts', 'src/main/ssh/orcad-unreachable-setup-release.test.ts', 'src/main/ssh/ssh-target-orcad-preflight.test.ts', + 'src/main/terminal-topology-main-writer-pushes.test.ts', 'src/main/worktree-identity-persistence.test.ts', 'src/main/worktree-removal-close-records.test.ts', 'src/main/worktree-removal-session-partition-fencing.test.ts', From fe56085b95626bfc7301d8cf081423353cb3f604 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 05:47:33 -0400 Subject: [PATCH 72/75] fix(terminal): a window's own tabs advance an existing fence but never start one, as on main --- .../pty-binding-session-update.ts | 12 ++++++- ...rminal-peer-import-partition-write.test.ts | 36 +++++++++++++++++++ .../terminal-surface-create.ts | 9 +++-- .../terminal-topology-membership.ts | 16 +++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 655ba1fb94c8..7ab0cd567a3b 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -3,6 +3,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' import { placeTerminalPane } from '../terminal-topology/terminal-pane-placement-apply' +import { startsOrAdvancesTerminalFence } from '../terminal-topology/terminal-topology-membership' import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' import type { PersistPtyBindingArgs } from './pty-binding-persistence' @@ -37,6 +38,7 @@ export function applyPtyBinding( delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } } + const tabExisted = session.tabsByWorktree[bindingWorktreeId]?.some((t) => t.id === args.tabId) // A spawn can beat the window's creation commit, so the binding can create its pane too. const terminalMembershipChanged = placeTerminalPane( session, @@ -57,7 +59,15 @@ export function applyPtyBinding( args.ptyId ) } - if (reconciledIncarnation || terminalMembershipChanged) { + if ( + reconciledIncarnation || + (terminalMembershipChanged && + startsOrAdvancesTerminalFence( + session, + bindingWorktreeId, + args.expectedSourceBinding !== undefined || !tabExisted + )) + ) { const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) session.terminalTopologyRevisionByRepoId = { ...session.terminalTopologyRevisionByRepoId, diff --git a/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts index 92bf7b3d11b4..839061e226ef 100644 --- a/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts +++ b/src/main/persistence/terminal-topology/terminal-peer-import-partition-write.test.ts @@ -101,4 +101,40 @@ describe('a host pull into an SSH partition main has published nothing since', ( [`host-tab:${LEAF}`]: 'host-incarnation' }) }) + + // Two desktops on one SSH host: a tab this window created starts no fence, so the other's imports. + it("imports another desktop's new tab after this window created its own", async () => { + const store = openStore() + await store.createTerminalSurface( + { + worktreeId: WORKTREE_ID, + tabId: 'main-tab', + leafId: LEAF, + placement: { kind: 'new-tab', row: { title: 'Terminal', createdAt: 1 } } + }, + HOST_ID + ) + expect( + store.getWorkspaceSession(HOST_ID).tabsByWorktree[WORKTREE_ID]?.map((row) => row.id) + ).toEqual(['main-tab']) + + importPeerTopology( + store, + 'target-1', + { + tabsByWorktree: { [WORKTREE_ID]: [tab('main-tab'), tab('host-tab')] }, + terminalLayoutsByTabId: { + 'main-tab': leafLayout('main-tab'), + 'host-tab': leafLayout('host-tab') + } + }, + () => false + ) + + const saved = store.getWorkspaceSession(HOST_ID) + expect(saved.tabsByWorktree[WORKTREE_ID]?.map((row) => row.id)).toEqual([ + 'main-tab', + 'host-tab' + ]) + }) }) diff --git a/src/main/persistence/terminal-topology/terminal-surface-create.ts b/src/main/persistence/terminal-topology/terminal-surface-create.ts index 499c1cb9d05f..35b1f38d80cf 100644 --- a/src/main/persistence/terminal-topology/terminal-surface-create.ts +++ b/src/main/persistence/terminal-topology/terminal-surface-create.ts @@ -7,7 +7,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import { placeTerminalPane } from './terminal-pane-placement-apply' -import { advanceTerminalTopologyRevision } from './terminal-topology-membership' +import { + advanceTerminalTopologyRevision, + startsOrAdvancesTerminalFence +} from './terminal-topology-membership' /** * Records a tab or pane the window created in the worktree's home partition, unbound; its spawn @@ -46,6 +49,8 @@ export function planTerminalSurfaceCreate( placeTerminalPane(session, { worktreeId, tabId, leafId }, placement) return { result: { status: 'committed' }, - session: advanceTerminalTopologyRevision(session, worktreeId) + session: startsOrAdvancesTerminalFence(home, worktreeId, false) + ? advanceTerminalTopologyRevision(session, worktreeId) + : session } } diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts index 358a1a34736a..f1c7252ee408 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-membership.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -22,6 +22,22 @@ export function advanceTerminalTopologyRevision( } } +/** + * Whether a membership change moves the repo's fence, as main's did: once a fence exists, any; + * before, only a split or a tab main itself admitted, so a window's own tabs keep a peer's pull + * importable. + */ +export function startsOrAdvancesTerminalFence( + session: WorkspaceSessionState, + worktreeId: string, + hostAdmitted: boolean +): boolean { + return ( + hostAdmitted || + (session.terminalTopologyRevisionByRepoId?.[getRepoIdFromWorktreeId(worktreeId)] ?? 0) > 0 + ) +} + /** * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the From 5dcb46e354d13a6af48ec5b27f1a910ff7a80b0f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 05:52:19 -0400 Subject: [PATCH 73/75] test(e2e): close and move scenarios now save main's fence and incarnations exactly --- tests/e2e/terminal-layout-parity-declared-differences.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/e2e/terminal-layout-parity-declared-differences.ts b/tests/e2e/terminal-layout-parity-declared-differences.ts index 9e9fe2baf34b..c4d1c91925f2 100644 --- a/tests/e2e/terminal-layout-parity-declared-differences.ts +++ b/tests/e2e/terminal-layout-parity-declared-differences.ts @@ -9,16 +9,13 @@ import type { */ export const TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES: readonly DeclaredParityDifference[] = [ // The core's mechanism: main alone authors terminal topology, so a window save no longer erases - // main's per-pane incarnations, and the binding write counts every membership change it makes. - // Saved-profile fields only; nothing on screen differs. + // main's per-pane incarnations or the fence that guards them. Saved-profile fields only; nothing + // on screen differs. Close and move scenarios save exactly what main does. ...[ 'create-tab', 'split-right-down', - 'close-pane', - 'close-tab', 'reorder-panes', 'cli-split', - 'drag-out-to-tab', 'restart-restore', 'folder-workspace', 'setup-split-first-activation', From 4a850cf8abbc7841bd33df0b7c8e9dc2f9081fc1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 06:05:28 -0400 Subject: [PATCH 74/75] fix(terminal): fence a window's topology commits, follow main's topology in a degraded boot, and report a failed SSH import Co-Authored-By: Claude --- src/main/ipc/session.test.ts | 45 +++++++++++++++++++ src/main/ipc/session.ts | 19 +++++--- .../terminal-topology-commit.ts | 6 +-- .../terminal-topology-model.test.ts | 7 +-- .../app-shell/use-app-startup-hydration.ts | 3 ++ .../hooks/remote-workspace-snapshot-apply.ts | 11 ++++- ...e-workspace-snapshot-import-commit.test.ts | 27 +++++++++++ .../startup/startup-degraded-recovery.test.ts | 31 ++++++++++++- .../src/startup/startup-degraded-recovery.ts | 12 +++++ 9 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index f01eada9a4c9..9534e9b85725 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -85,6 +85,51 @@ describe('registerSessionHandlers', () => { expect(store.setWorkspaceSession).toHaveBeenCalledTimes(2) }) + it("refuses a window's topology commits to a fenced home and writes nothing", async () => { + const store = { + getSshTarget: vi.fn(() => ({ orcadFence: { environmentId: 'env-1' } })), + getWorkspaceSessionHostIds: vi.fn(() => ['ssh:fenced']), + getWorkspaceSession: vi.fn(() => sessionWithTab('tab')), + patchWorkspaceSession: vi.fn(), + createTerminalSurface: vi.fn(), + setTerminalTabLayout: vi.fn(), + persistPtyBinding: vi.fn() + } + const runtime = { + getTerminalTopologyHomeHostId: vi.fn(() => 'ssh:fenced'), + settleTerminalTopology: vi.fn(() => 0) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test touch only the store and runtime methods stubbed above. + registerSessionHandlers(store as never, runtime as never) + const leafId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + const pane = { worktreeId: WORKTREE, tabId: 'tab', leafId } + const refused = { status: 'refused', reason: 'home_unresolved', publishSeq: 0 } + + await expect( + handlers.get('session:terminal-create-surface')?.( + {}, + { ...pane, placement: { kind: 'root' } } + ) + ).resolves.toEqual(refused) + await expect( + handlers.get('session:terminal-set-layout')?.( + {}, + { worktreeId: WORKTREE, tabId: 'tab', root: { type: 'leaf', leafId } } + ) + ).resolves.toEqual(refused) + await expect( + handlers.get('session:terminal-bind-leaf')?.({}, { ...pane, ptyId: 'pty-1' }) + ).resolves.toEqual(refused) + handlers.get('session:terminal-clear-launch-agent')?.( + {}, + { worktreeId: WORKTREE, tabId: 'tab' } + ) + expect(store.createTerminalSurface).not.toHaveBeenCalled() + expect(store.setTerminalTabLayout).not.toHaveBeenCalled() + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(store.patchWorkspaceSession).not.toHaveBeenCalled() + }) + it('set, set-sync, patch and the quit stage all keep the tabs main holds', async () => { const written: WorkspaceSessionState['tabsByWorktree'][] = [] const record = (session: Partial): void => { diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 973d4e4ad258..eacbdf4b6cea 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -14,6 +14,7 @@ import { parseTerminalLeafBindRequest, type TerminalLeafBindResult } from '../../shared/terminal-leaf-bind' +import type { ExecutionHostId } from '../../shared/execution-host' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { @@ -35,6 +36,11 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic // Why: renderer saves would change a fenced host's frozen source partition. const isFenced = (hostId?: string | null): boolean => isFrozenOrcadSourceSessionPartition(store, hostId) + // A window's topology commits replace its saves, so a fenced home is no home for them either. + const windowWritableHome = (worktreeId: string): ExecutionHostId | null => { + const hostId = runtime.getTerminalTopologyHomeHostId(worktreeId) + return hostId && !isFenced(hostId) ? hostId : null + } // Why: hostId is an optional second arg so an older renderer that invokes // these channels without it keeps reading/writing the 'local' partition @@ -100,8 +106,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic ? changes.wake.filter((paneKey) => typeof paneKey === 'string') : [] }, - (worktreeId) => runtime.getTerminalTopologyHomeHostId(worktreeId), - isFenced + windowWritableHome ) } ) @@ -113,8 +118,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic if (typeof args?.worktreeId !== 'string' || typeof args.tabId !== 'string') { return } - const hostId = runtime.getTerminalTopologyHomeHostId(args.worktreeId) - if (hostId && !isFenced(hostId)) { + const hostId = windowWritableHome(args.worktreeId) + if (hostId) { clearLaunchAgent(store, { worktreeId: args.worktreeId, tabId: args.tabId }, hostId) } } @@ -126,7 +131,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic if (!request) { return { status: 'refused', reason: 'invalid_request' } satisfies TerminalSurfaceCreateResult } - const hostId = runtime.getTerminalTopologyHomeHostId(request.worktreeId) + const hostId = windowWritableHome(request.worktreeId) const result: TerminalSurfaceCreateResult = hostId ? await store.createTerminalSurface(request, hostId) : { status: 'refused', reason: 'home_unresolved' } @@ -140,7 +145,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLayoutSetResult } // One home per worktree; an unresolved one is unverifiable, so nothing is written. - const hostId = runtime.getTerminalTopologyHomeHostId(request.worktreeId) + const hostId = windowWritableHome(request.worktreeId) const result: TerminalLayoutSetResult = hostId ? await store.setTerminalTabLayout(request, hostId) : { status: 'refused', reason: 'home_unresolved' } @@ -153,7 +158,7 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic if (!request) { return { status: 'refused', reason: 'invalid_request' } satisfies TerminalLeafBindResult } - const hostId = runtime.getTerminalTopologyHomeHostId(request.worktreeId) + const hostId = windowWritableHome(request.worktreeId) const bound = hostId !== null && (await bindLeaf(store, request, hostId)) const result: TerminalLeafBindResult = bound ? { status: 'bound' } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index e3a935664318..2298de7bcb6f 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -135,13 +135,9 @@ export function commitSleepingRecords( 'getWorkspaceSessionHostIds' | 'getWorkspaceSession' | 'patchWorkspaceSession' >, changes: TerminalSleepingRecordChanges, - homeHostId: (worktreeId: string) => ExecutionHostId | null, - isFenced: (hostId: ExecutionHostId) => boolean + homeHostId: (worktreeId: string) => ExecutionHostId | null ): void { for (const hostId of store.getWorkspaceSessionHostIds()) { - if (isFenced(hostId)) { - continue - } const isHome = (worktreeId: string): boolean => homeHostId(worktreeId) === hostId const session = store.getWorkspaceSession(hostId) const next = sleepLeaf(wakeLeaf(session, changes.wake, isHome), changes.sleep, isHome) diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index c815f385669f..7f091287069d 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -280,12 +280,7 @@ async function runSeed(seed: number): Promise { const save = (): void => setRendererSession(store, window.snapshot()) // The window's own sleeping-record changes commit to main; its saves no longer carry them. const commitSleeping = (changes: TerminalSleepingRecordChanges): void => - commitSleepingRecords( - store, - changes, - () => LOCAL_EXECUTION_HOST_ID, - () => false - ) + commitSleepingRecords(store, changes, () => LOCAL_EXECUTION_HOST_ID) const panes = () => [...model].flatMap(([tabId, tab]) => [...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane })) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 8349b1568621..4d0de1a78f35 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -72,6 +72,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta let uiHydrated = false // Why (issue #1158): track whether success-path reconnect started so the catch doesn't re-run it — re-entering on partially-mutated state would double-set ptyIds and drain pending* twice. let reconnectStarted = false + let terminalTopologyFollowed = false void (async () => { const startupStartedAt = performance.now() logRendererStartupDiagnostic('startup-chain-start') @@ -211,6 +212,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta abortController.signal ) ) + terminalTopologyFollowed = true await timeRendererStartupStep('prepare-terminal-startup-restoration', () => window.api.app.prepareTerminalStartupRestoration() ) @@ -348,6 +350,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta error, uiHydrated, reconnectStarted, + terminalTopologyFollowed, isCancelled: () => cancelled, hydratePersistedUI: actions.hydratePersistedUI, reconnectPersistedTerminals: actions.reconnectPersistedTerminals, diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 1fae134fe98f..03198abeb1e2 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -267,7 +267,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ buildWorkspaceSessionHostPatches(mergedFields(merged, current), currentStore).find( ({ hostId }) => hostId === toSshExecutionHostId(authority.targetId) )?.patch ?? {} - }).catch((error: unknown) => console.warn('[remote-workspace] import commit failed:', error)) + }).catch((error: unknown) => { + // Main holds none of the import, so the target is not synced, whatever this apply showed. + if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) { + store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + direction: 'pull', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + }) currentStore.hydrateWorkspaceSession(merged, { directSshAuthority: authority, replaceWorkspaceKeys diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts index fae38c13587c..a088623afc1d 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-import-commit.test.ts @@ -227,4 +227,31 @@ describe('direct SSH snapshot import commit', () => { vi.useRealTimers() } }) + + it('reports an error, not synced, when main fails to commit the import', async () => { + const store = createTestStore() + seedCatalog(store) + const setRemoteWorkspaceSyncStatus = vi.fn() + store.setState({ setRemoteWorkspaceSyncStatus }) + + await applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot: snapshot(6, ['host-tab']), + token: token(6), + arrival: 1, + isArrivalCurrent: () => true, + isPreparationTokenCurrent: () => true, + waitForWorkspaceSessionReady: async () => true, + finalizeHydratedTerminals: () => 0, + importPeerTopology: async () => { + throw new Error('import failed') + } + }) + + expect(setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith(TARGET_ID, { + phase: 'error', + direction: 'pull', + message: 'import failed' + }) + }) }) diff --git a/src/renderer/src/startup/startup-degraded-recovery.test.ts b/src/renderer/src/startup/startup-degraded-recovery.test.ts index a3f0fb44f261..7886abfe23e7 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.test.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.test.ts @@ -11,7 +11,8 @@ vi.mock('../store', async () => { const useAppStore = create(() => ({ startupWorktreeRefreshCompleted: false, terminalStartupRestorationReady: false, - workspaceSessionReady: false + workspaceSessionReady: false, + applyTerminalTopologySlices: vi.fn() })) return { useAppStore } }) @@ -22,17 +23,21 @@ import { recoverFromDegradedStartup } from './startup-degraded-recovery' function recover(args: { isCancelled: () => boolean reconnectPersistedTerminals: () => Promise + terminalTopologyFollowed?: boolean }): Promise { return recoverFromDegradedStartup({ error: new Error('hydration failed'), uiHydrated: true, reconnectStarted: false, + terminalTopologyFollowed: true, hydratePersistedUI: vi.fn(), abortSignal: new AbortController().signal, ...args }) } +const SLICE = { worktreeId: 'repo::/wt', publishSeq: 3 } + describe('recoverFromDegradedStartup', () => { beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -44,6 +49,10 @@ describe('recoverFromDegradedStartup', () => { awaitFirstWindowStartupServices: async () => undefined, recoverLegacyWorkerTerminalsForRendererStartup: async () => undefined, relaunch: vi.fn() + }, + session: { + onTerminalTopologyChanged: vi.fn(() => () => undefined), + getTerminalTopologySlices: async () => [SLICE] } } }) @@ -71,4 +80,24 @@ describe('recoverFromDegradedStartup', () => { expect(useAppStore.getState().terminalStartupRestorationReady).toBe(false) }) + + it("follows main's terminal topology when startup failed before the follow began", async () => { + await recover({ + isCancelled: () => false, + reconnectPersistedTerminals: async () => undefined, + terminalTopologyFollowed: false + }) + + expect(window.api.session.onTerminalTopologyChanged).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().applyTerminalTopologySlices).toHaveBeenCalledWith([SLICE]) + }) + + it('does not follow twice when the success path already follows', async () => { + await recover({ + isCancelled: () => false, + reconnectPersistedTerminals: async () => undefined + }) + + expect(window.api.session.onTerminalTopologyChanged).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index c5570af27173..0f0275240bd1 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' import { getStartupErrorFallbackUI } from '../lib/startup-ui-hydration' +import { followTerminalTopology } from '../lib/terminal-topology-follow' import { collectTerminalProviderSnapshotPtyIds, refreshTerminalProviderSnapshotCapabilities @@ -13,6 +14,8 @@ type DegradedStartupRecoveryArgs = { uiHydrated: boolean /** Whether the success path already began terminal reconnect over now-partial state. */ reconnectStarted: boolean + /** Whether the success path already follows main's terminal topology. */ + terminalTopologyFollowed: boolean isCancelled: () => boolean hydratePersistedUI: ReturnType['hydratePersistedUI'] reconnectPersistedTerminals: (signal: AbortSignal) => Promise @@ -44,6 +47,7 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr error, uiHydrated, reconnectStarted, + terminalTopologyFollowed, isCancelled, hydratePersistedUI, reconnectPersistedTerminals, @@ -85,6 +89,14 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr forceWorkspaceSessionReady() return } + // Why: without main's pushes no pending terminal change settles, so the session stays stuck. + if (!terminalTopologyFollowed) { + await followTerminalTopology( + window.api.session, + useAppStore.getState().applyTerminalTopologySlices, + abortSignal + ) + } try { await window.api.app.awaitFirstWindowStartupServices() await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() From 2a508561e6d712156451acde25a6e4ec0b9625e3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 14:10:36 -0400 Subject: [PATCH 75/75] test(terminal): remove a repo through main's host-scoped removeProjectForHost Co-Authored-By: Claude --- src/main/terminal-topology-main-writer-pushes.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/terminal-topology-main-writer-pushes.test.ts b/src/main/terminal-topology-main-writer-pushes.test.ts index b0e8bc2443ea..5dbbb6ad9aac 100644 --- a/src/main/terminal-topology-main-writer-pushes.test.ts +++ b/src/main/terminal-topology-main-writer-pushes.test.ts @@ -109,7 +109,9 @@ describe('a window showing a worktree learns of main-side writes that remove or const { initial, pushesAfter } = watchPushes(store) expect(initial).toHaveLength(1) - expect(pushesAfter(() => store.removeProject('r1'))).toEqual([withdrawn('local', 'r1::/repo')]) + expect(pushesAfter(() => store.removeProjectForHost('r1', 'local'))).toEqual([ + withdrawn('local', 'r1::/repo') + ]) }) it("withdraws one host's worktree when a shared repo id is removed from that host only", () => {