From 0ff240a48b9f5364474ef2fa2fbaa7e2f84205aa Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 00:51:22 -0400 Subject: [PATCH 1/5] 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 c502ff819d54f3685b83862243606369c5a0033d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 14:05:21 -0400 Subject: [PATCH 2/5] 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 3/5] 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 fba1399330723aafa5139a9afaee15a11c38446c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 21:36:02 -0400 Subject: [PATCH 4/5] 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 fed93954cf0a112b62b905a7bc9694385eb8c9a9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 23:32:11 -0400 Subject: [PATCH 5/5] 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,