diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index ad4423f844c..51431de4d74 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -1,4 +1,4 @@ -import type { OrcadOptions } from './orcad-entry' +import type { OrcadOptions } from './orcad-start-contract' import { DEFAULT_WS_PORT } from '../runtime/runtime-rpc/runtime-rpc-pairing-types' import { ORCAD_LOOPBACK_BIND_HOST } from './orcad-bind-address' import { getServeOptionValidationError } from '../../shared/serve-option-validation' diff --git a/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts b/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts new file mode 100644 index 00000000000..74eed4fd84e --- /dev/null +++ b/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts @@ -0,0 +1,306 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { PersistedState } from '../../shared/persisted-state-types' +import { agentHookServer } from '../agent-hooks/server' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore +} from '../persistence/terminal-topology/terminal-topology-profile-fixture' +import { DelayedAuthority } from '../persistence/loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../persistence/loading-store/store' +import { + leafIds, + WindowSession +} from '../persistence/terminal-topology/terminal-topology-window-session-fixture' + +const state = vi.hoisted(() => { + const value: { + root: string + store: Store | null + layoutOwnerIsMain: boolean + rekeyWorkerTerminalResourcePaneKey: Mock<() => number> + /** What a startup step saw on disk; set by a test that watches the order. */ + onStartupStep: ((step: string) => void) | null + } = { + root: '', + store: null, + layoutOwnerIsMain: true, + rekeyWorkerTerminalResourcePaneKey: vi.fn(() => 1), + onStartupStep: null + } + return value +}) + +vi.mock('../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return state.layoutOwnerIsMain + } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +// Why: startup reconciles the user-global agent configs; keep it off this machine's real home. +vi.mock('../codex/codex-hook-reconcile', async (importOriginal) => ({ + ...(await importOriginal>()), + startCodexHooks: vi.fn() +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', async (importOriginal) => ({ + ...(await importOriginal>()), + installManagedAgentHooks: vi.fn(async () => []) +})) +vi.mock('../agent-hooks/local-agent-cli-presence', async (importOriginal) => ({ + ...(await importOriginal>()), + hydrateAgentCliShellPath: async () => {} +})) +vi.mock('../agent-hooks/agent-home-login-shell', () => ({ + adoptLoginShellAgentHomes: async () => {} +})) +vi.mock('../opencode/opencode-status-plugin-startup-refresh', () => ({ + refreshInstalledOpenCodeStatusPlugins: vi.fn() +})) +vi.mock('./orcad-app-paths', () => ({ + resolveOrcadInstallRoot: () => state.root, + resolveOrcadPath: () => state.root, + resolveUserDataPath: () => state.root +})) +vi.mock('./orcad-plaintext-credential-migration', () => ({ + orcadCredentialRoot: () => state.root, + migratePlaintextCredentials: () => ({ sealed: 0, failed: 0 }) +})) +vi.mock('./orcad-account-services', () => ({ createOrcadAccountServices: () => null })) +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: async () => null })) +vi.mock('./orcad-instance-lock', () => ({ + acquireOrcadInstanceLock: () => ({ + path: join(state.root, 'orcad.lock'), + record: { pid: process.pid, startedAtMs: null, nonce: 'repair-instance' }, + release() {} + }) +})) +vi.mock('./orcad-daemon-supervision', () => ({ + startOrcadDaemon: async () => {}, + stopOrcadDaemon: async () => {} +})) +vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) +vi.mock('./orcad-automations', () => ({ + startOrcadAutomations: () => {}, + stopOrcadAutomationScheduler: () => {}, + orcadAutomationsKeepHostBusy: () => false +})) +vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({ + startAgentStateRulesLiveUpdates: () => {} +})) +vi.mock('../daemon/daemon-provider-state', () => ({ daemonOwnsFreshPersistentPtys: () => false })) +vi.mock('../ipc/pty/register-headless-runtime', () => ({ + registerHeadlessPtyRuntime: async () => {} +})) +vi.mock('../ipc/pty/provider/registry', () => ({ getLocalPtyProvider: () => null })) +// The real Store orcad loads, on a profile holding one terminal drawn in two panes. +vi.mock('./orcad-profile-state-startup', () => ({ + createOrcadProfileStateStartup: async () => ({ + store: state.store, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false + } + }) +})) +vi.mock('../orca-profiles/profile-index-store', () => ({ + initOrcaProfilePaths() {}, + ensureActiveOrcaProfile: () => ({ + dataFile: join(state.root, 'profile.json'), + stateDatabaseFile: join(state.root, 'profile-state.db'), + profile: { id: 'repair-profile' } + }) +})) +vi.mock('../ssh/ssh-host-key-store', () => ({ initSshHostKeyStoreFile() {} })) +vi.mock('../server/serve-readiness', () => ({ + ServeReadinessPublisher: class { + async publish() {} + } +})) +vi.mock('../runtime/push/desktop-push-service', () => ({ + DesktopPushService: { create: () => null } +})) +vi.mock('../runtime/orca-runtime', () => ({ + OrcaRuntimeService: class { + readonly sidebarFacts = null + getRuntimeId() { + return 'repair-runtime' + } + getExistingOrchestrationDb() { + return { rekeyWorkerTerminalResourcePaneKey: state.rekeyWorkerTerminalResourcePaneKey } + } + rehydrateClientHostedBrowserPages() {} + restoreHostAccountLaunchEnv() {} + async refreshRestoredOrchestrationAuthority() {} + async reconcileLegacyWorkerTerminals() { + state.onStartupStep?.('reconcile') + } + async installPendingOrcadMigrationShellHistory() {} + async stopLegacyWorkerTerminalRecovery() {} + async closeNotificationDismissals() {} + syncWindowGraph() {} + } +})) +vi.mock('../runtime/runtime-rpc', () => ({ + OrcaRuntimeRpcServer: class { + async start() { + state.onStartupStep?.('rpc.start') + } + async stop() {} + getWebSocketEndpoint() { + return null + } + } +})) + +const TAB = 'tab-1' +const KEPT = '11111111-1111-4111-8111-111111111111' +const DUPLICATE = '22222222-2222-4222-8222-222222222222' +const directories: string[] = [] + +beforeEach(() => { + // startOrcad canonicalizes this; stubbing restores the prior value after each test. + vi.stubEnv('ORCA_USER_DATA_PATH', '') +}) + +afterEach(() => { + state.layoutOwnerIsMain = true + state.onStartupStep = null + state.rekeyWorkerTerminalResourcePaneKey.mockClear() + vi.unstubAllEnvs() + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function temporaryDirectory(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)) + directories.push(directory) + return directory +} + +/** The stored shape a window save leaves behind: one terminal drawn in two panes of one tab. */ +function duplicatePtyProfile(): string { + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(FIXTURE_GIT_WORKTREE_ID, TAB, KEPT) + window.setLayout( + TAB, + { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: KEPT }, + second: { type: 'leaf', leafId: DUPLICATE } + }, + { [KEPT]: 'pty-1', [DUPLICATE]: 'pty-1' } + ) + return JSON.stringify({ + ...JSON.parse(emptyTerminalSessionProfile()), + workspaceSession: window.session + }) +} + +function storedLeafIds(directory: string): string[] { + const reader = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test wrote this profile as PersistedState. + const stored = JSON.parse(reader.readSerializedState() ?? '{}') as PersistedState + return leafIds(stored.workspaceSession?.terminalLayoutsByTabId?.[TAB]?.root) + } finally { + reader.close() + } +} + +async function seedDuplicatePtyProfile(): Promise { + state.root = temporaryDirectory('orcad-duplicate-pty-repair-') + const profileDirectory = temporaryDirectory('orcad-duplicate-pty-profile-') + state.store = await openTopologyStore(profileDirectory, duplicatePtyProfile()) + return profileDirectory +} + +it('repairs a stored duplicate-terminal pane at orcad startup, moving owners before the write', async () => { + const profileDirectory = await seedDuplicatePtyProfile() + const diskAtTransfer: string[][] = [] + const transfer = vi + .spyOn(agentHookServer, 'transferPaneAuthority') + .mockImplementation(() => diskAtTransfer.push(storedLeafIds(profileDirectory))) + + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + await vi.waitFor(() => expect(storedLeafIds(profileDirectory)).toEqual([KEPT])) + expect(transfer).toHaveBeenCalledExactlyOnceWith( + `${TAB}:${DUPLICATE}`, + `${TAB}:${KEPT}`, + 'pty-1', + expect.any(Number), + { authorityVerified: true } + ) + expect(state.rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledExactlyOnceWith({ + fromPaneKey: `${TAB}:${DUPLICATE}`, + toPaneKey: `${TAB}:${KEPT}` + }) + // The status moved while disk still held both panes. + expect(diskAtTransfer).toEqual([[KEPT, DUPLICATE]]) + } finally { + await host.stop() + } +}) + +it('leaves the stored panes alone when the build keeps the window as layout owner', async () => { + state.layoutOwnerIsMain = false + const profileDirectory = await seedDuplicatePtyProfile() + const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority') + + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + await host.stop() + + expect(transfer).not.toHaveBeenCalled() + expect(state.rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled() + expect(storedLeafIds(profileDirectory)).toEqual([KEPT, DUPLICATE]) +}) + +it('writes the repair before reconcile and RPC even when a profile write is in flight', async () => { + state.root = temporaryDirectory('orcad-duplicate-pty-repair-') + const profileDirectory = temporaryDirectory('orcad-duplicate-pty-profile-') + const inner = new ProfileStateSqliteAuthority( + join(profileDirectory, 'profile-state.db'), + 'topology' + ) + inner.writeSerializedState(Buffer.from(duplicatePtyProfile())) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(profileDirectory, 'orca-data.json'), + profileStateAuthority: authority + }) + await store.flushPendingOrThrowAsync() + state.store = store + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + const diskAtStep: Record = {} + state.onStartupStep = (step) => (diskAtStep[step] = storedLeafIds(profileDirectory)) + + // An earlier profile write is still in flight; the repair queues behind it. + const held = authority.pause() + store.updateSettings({ agentStatusHooksEnabled: false }) + const heldWrite = store.flushPendingOrThrowAsync() + await held.started.promise + const { startOrcad } = await import('./orcad-entry') + const startup = startOrcad({ noPairing: true, json: true }) + await new Promise((resolve) => setTimeout(resolve, 200)) + held.finish.resolve() + await heldWrite + const host = await startup + try { + expect(diskAtStep).toEqual({ reconcile: [KEPT], 'rpc.start': [KEPT] }) + } finally { + await host.stop() + } +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 8e46f80140e..4f449e5b252 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -13,7 +13,6 @@ import { join } from 'node:path' import process from 'node:process' import { getAppEnvironment } from '../../shared/app-environment' -import type { ServeReadiness } from '../server/serve-readiness' import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' import { prepareOrcadCliLauncher } from './orcad-cli-launcher' import { @@ -32,7 +31,7 @@ import { ORCAD_USAGE, parseArgs, resolveOrcadWebClientRoot } from './orcad-comma import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' +import type { OrcadHandle, OrcadOptions } from './orcad-start-contract' import { beginOrcadIdleExit, bindOrcadIdleShutdown } from './orcad-managed-idle-exit-host' import { orcadAutomationsKeepHostBusy, startOrcadAutomations } from './orcad-automations' import { @@ -43,36 +42,12 @@ import { stampRuntimeSourceEnv } from '../../shared/runtime-source-env' import { adoptWslHostLoginEnvironment } from './orcad-wsl-host-login-environment' import { installOrcadProcessErrorGuards } from './orcad-process-error-guards' import { orcadWsPortOptions } from './orcad-ws-port-policy' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' export { parseArgs } let closeOrcadObservability = (): void => {} -export type OrcadOptions = { - port?: number - json?: boolean - noPairing?: boolean - pairingAddress?: string - /** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */ - mobilePairing?: boolean - /** Lets the paired runtime client drive this machine's desktop (computer.*). */ - grantDesktopControl?: boolean - /** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */ - recipeJson?: boolean - projectRoot?: string - /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ - bind?: string - /** The browser client's directory; the slot carries none, so `orca serve` passes the app's. */ - webClientRoot?: string -} - -export type OrcadHandle = { - readiness: ServeReadiness - /** What an instance-bound stop request must name to stop this process. */ - managedStop: OrcadManagedStopContext - stop(): Promise -} - /** * Boot the runtime and serve RPC. Resolves once the transport is listening and the * readiness payload has been published, mirroring the desktop `--serve` contract byte @@ -289,6 +264,12 @@ async function startOrcadRuntime( startAgentStateRulesLiveUpdates(profileStore, (rules) => console.info(`[orcad] agent state rules ${rules.version} (${rules.source})`) ) + // Switch point: main's load repair, here too, now that this runtime can move pane owners. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { repairDuplicatePtyPanesOnStart } = await import('../runtime/moved-pane-owner-rekey') + // Awaited: it queues behind any in-flight write, and must land before reconcile and RPC. + await repairDuplicatePtyPanesOnStart(profileStore, runtime) + } // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a // pane's status row changes, and orcad's whole job is serving paired clients. diff --git a/src/main/orcad/orcad-serve-readiness.ts b/src/main/orcad/orcad-serve-readiness.ts index 533a7b830b2..59a24465537 100644 --- a/src/main/orcad/orcad-serve-readiness.ts +++ b/src/main/orcad/orcad-serve-readiness.ts @@ -2,7 +2,7 @@ import type { ServeReadiness } from '../server/serve-readiness' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { buildServePairingReadiness } from '../server/serve-pairing-readiness' import type { OrcadHealth } from './orcad-health' -import type { OrcadOptions } from './orcad-entry' +import type { OrcadOptions } from './orcad-start-contract' /** The readiness payload orcad publishes once its RPC transport is listening. */ export async function buildOrcadServeReadiness(input: { diff --git a/src/main/orcad/orcad-start-contract.ts b/src/main/orcad/orcad-start-contract.ts new file mode 100644 index 00000000000..e199f5c7cab --- /dev/null +++ b/src/main/orcad/orcad-start-contract.ts @@ -0,0 +1,27 @@ +import type { ServeReadiness } from '../server/serve-readiness' +import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' + +export type OrcadOptions = { + port?: number + json?: boolean + noPairing?: boolean + pairingAddress?: string + /** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */ + mobilePairing?: boolean + /** Lets the paired runtime client drive this machine's desktop (computer.*). */ + grantDesktopControl?: boolean + /** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */ + recipeJson?: boolean + projectRoot?: string + /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ + bind?: string + /** The browser client's directory; the slot carries none, so `orca serve` passes the app's. */ + webClientRoot?: string +} + +export type OrcadHandle = { + readiness: ServeReadiness + /** What an instance-bound stop request must name to stop this process. */ + managedStop: OrcadManagedStopContext + stop(): Promise +} diff --git a/src/main/startup/main-process-ready-duplicate-pty-repair.test.ts b/src/main/startup/main-process-ready-duplicate-pty-repair.test.ts new file mode 100644 index 00000000000..fb19aa455dd --- /dev/null +++ b/src/main/startup/main-process-ready-duplicate-pty-repair.test.ts @@ -0,0 +1,202 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { PersistedState } from '../../shared/persisted-state-types' +import { agentHookServer } from '../agent-hooks/server' +import { DelayedAuthority } from '../persistence/loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../persistence/loading-store/store' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID +} from '../persistence/terminal-topology/terminal-topology-profile-fixture' +import { + leafIds, + WindowSession +} from '../persistence/terminal-topology/terminal-topology-window-session-fixture' + +const fixture = vi.hoisted(() => { + const value: { state: Record; userData: string } = { + // Account services are stubs: this file pins only the repair's place in readiness. + state: { + store: null, + stats: {}, + runtime: null, + claudeAccounts: {}, + codexAccounts: {}, + rateLimits: {} + }, + userData: '' + } + return value +}) + +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('electron', () => ({ + app: { isPackaged: false, on: vi.fn(), once: vi.fn(), getPath: () => fixture.userData }, + nativeTheme: {} +})) +vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } })) +vi.mock('./main-process-state', () => ({ mainProcessState: fixture.state })) +vi.mock('../runtime/orca-runtime', () => ({ + OrcaRuntimeService: class { + readonly sidebarFacts = null + getExistingOrchestrationDb() { + return null + } + rehydrateClientHostedBrowserPages() {} + setAgentBrowserBridge() {} + setEmulatorBridge() {} + setArtifactService() {} + setSkillCloudService() {} + setAccountServices() {} + setCommitMessageAgentEnvironmentResolvers() {} + } +})) +vi.mock('../runtime/agent-session-claim-identity', () => ({ + loadAgentSessionClaimSigner: () => null +})) +vi.mock('../ai-vault-search/session-search-enablement', () => ({ + applySessionSearchSettingsChange: vi.fn(), + installChildSessionSearchService: () => null +})) +vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({ + startAgentStateRulesLiveUpdates: () => {} +})) +vi.mock('../artifacts/artifact-cloud-service', () => ({ ArtifactCloudService: class {} })) +vi.mock('../skills/skill-cloud-service', () => ({ SkillCloudService: class {} })) +vi.mock('./wsl-orcad-account-env-wiring', () => ({ installWslOrcadAccountShim: vi.fn() })) +vi.mock('../codex/codex-hook-reconcile', () => ({ startCodexHooks: vi.fn() })) +vi.mock('../star-nag/service', () => ({ + StarNagService: class { + start = vi.fn() + registerIpcHandlers = vi.fn() + } +})) +vi.mock('../browser/agent-browser-bridge', () => ({ + AgentBrowserBridge: class { + sweepOrphanedSessions = vi.fn() + } +})) +vi.mock('../emulator/emulator-bridge', () => ({ EmulatorBridge: class {} })) +vi.mock('../runtime/rpc/dispatcher', () => ({ RpcDispatcher: class {} })) +vi.mock('../browser/browser-manager', () => ({ + browserManager: { setBrowserGuestStateChangedListener: vi.fn() } +})) +vi.mock('../browser/browser-client-page-automation-runtime', () => ({ + configureBrowserClientPageAutomationRuntime: vi.fn() +})) +vi.mock('../crash-reporting/process-gone-diagnostics', () => ({ + startPreGoneCrashSampling: vi.fn() +})) +vi.mock('./main-window-lifecycle-flags', () => ({ recordProcessGoneCrash: vi.fn() })) +vi.mock('./gpu-lifecycle', () => ({ handleGpuChildCrash: vi.fn() })) +vi.mock('./configure-process', () => ({ shouldInstallManagedHooks: () => false })) +vi.mock('./main-process-observers', () => ({ initializeMainProcessObservers: vi.fn() })) +vi.mock('./branch-rename-hook', () => ({ installFirstWorkRenameHookSubscription: vi.fn() })) +vi.mock('./main-process-account-services', () => ({ + initializeMainProcessAccountServices: vi.fn() +})) +vi.mock('./main-process-automations', () => ({ initializeMainProcessAutomations: vi.fn() })) +vi.mock('./main-process-plugins', () => ({ initializeMainProcessPlugins: vi.fn() })) +vi.mock('../worktree-trash', () => ({ + collectWorktreeTrashSweepRoots: vi.fn(), + sweepStaleWorktreeTrash: vi.fn() +})) +vi.mock('./worktree-removal-records-load', () => ({ loadWorktreeRemovalRecordsForStore: vi.fn() })) +vi.mock('./first-window-deferral', () => ({ runAfterFirstWindowShown: vi.fn() })) +vi.mock('./startup-diagnostics', () => ({ logStartupMilestone: vi.fn() })) +vi.mock('../opencode/opencode-status-plugin-startup-refresh', () => ({ + refreshInstalledOpenCodeStatusPlugins: vi.fn() +})) +vi.mock('../orcad/orcad-artifact-cache-retention', () => ({ + pruneDesktopOrcadArtifactCache: vi.fn() +})) + +import { initializeReadyRuntimeServices } from './main-process-ready-runtime' + +const TAB = 'tab-1' +const KEPT = '11111111-1111-4111-8111-111111111111' +const DUPLICATE = '22222222-2222-4222-8222-222222222222' +const directories: string[] = [] +const stores: Store[] = [] + +afterEach(async () => { + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function duplicatePtyProfile(): string { + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(FIXTURE_GIT_WORKTREE_ID, TAB, KEPT) + window.setLayout( + TAB, + { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: KEPT }, + second: { type: 'leaf', leafId: DUPLICATE } + }, + { [KEPT]: 'pty-1', [DUPLICATE]: 'pty-1' } + ) + return JSON.stringify({ + ...JSON.parse(emptyTerminalSessionProfile()), + workspaceSession: window.session + }) +} + +function storedLeafIds(directory: string): string[] { + const reader = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test wrote this profile as PersistedState. + const stored = JSON.parse(reader.readSerializedState() ?? '{}') as PersistedState + return leafIds(stored.workspaceSession?.terminalLayoutsByTabId?.[TAB]?.root) + } finally { + reader.close() + } +} + +// Ready services finish before the window opens and RPC starts (main-process-ready.ts:24-31). +it('finishes the duplicate-terminal repair before ready services resolve, even behind a held write', async () => { + const directory = mkdtempSync(join(tmpdir(), 'desktop-duplicate-pty-repair-')) + directories.push(directory) + fixture.userData = directory + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + inner.writeSerializedState(Buffer.from(duplicatePtyProfile())) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + stores.push(store) + await store.flushPendingOrThrowAsync() + fixture.state.store = store + vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {}) + + // An earlier profile write is still in flight; the repair queues behind it. + const held = authority.pause() + store.updateSettings({ agentStatusHooksEnabled: false }) + const heldWrite = store.flushPendingOrThrowAsync() + await held.started.promise + let atReady: { memory: string[]; disk: string[] } | null = null + const ready = initializeReadyRuntimeServices().then(() => { + atReady = { + memory: leafIds(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root), + disk: storedLeafIds(directory) + } + }) + await new Promise((resolve) => setTimeout(resolve, 200)) + held.finish.resolve() + await heldWrite + await ready + + expect(atReady).toEqual({ memory: [KEPT], disk: [KEPT] }) +}) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 55455c3169e..fd07a68723a 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -34,6 +34,8 @@ import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' import { pruneDesktopOrcadArtifactCache } from '../orcad/orcad-artifact-cache-retention' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { repairDuplicatePtyPanesOnStart } from '../runtime/moved-pane-owner-rekey' // Headless serve never opens a window, so the sweep still has to run off a timer there. const WORKTREE_TRASH_SWEEP_FALLBACK_MS = 15_000 @@ -143,5 +145,9 @@ export async function initializeReadyRuntimeServices(): Promise { void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) } }) + // Switch point: awaited, as it queues behind in-flight writes and every later phase must read its tree. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await repairDuplicatePtyPanesOnStart(store, runtime) + } logStartupMilestone('services-initialized') } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 63837024eb6..ef56b6ef3ef 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -42,8 +42,6 @@ import { } from '../runtime/agent-status-observed-pane-identity' import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' -import { repairDuplicatePtyPanesOnStart } from '../runtime/moved-pane-owner-rekey' export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const activation = state.desktopActivationGate @@ -148,10 +146,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { recordDurableCrashBreadcrumb('agent_state_rules_active', rules) ) state.runtime = runtime - // Switch point: main's load repair is written only now that a runtime can move pane owners. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - void repairDuplicatePtyPanesOnStart(store, runtime) - } runtime.sidebarFacts?.attachSidebarAgentStatusStore(agentHookServer) agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)