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 00000000000..297a99a3eb0 --- /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 00000000000..04a48908298 --- /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 00000000000..d6bb5fdda26 --- /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 00000000000..0ac2a9d5f64 --- /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 00000000000..141d3141b64 --- /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' + }) + }) +})