mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
The terminal server and desktop finish the two-pane terminal repair before startup continues (dark, layout-owner build only) (#30429)
* refactor(orcad): move startOrcad's option and handle types into their own module Move-only: frees room in orcad-entry.ts for the layout-owner load repair. * fix(orcad): run main's duplicate-terminal pane repair at orcad startup (layout-owner build only) orcad builds its own runtime and never called the load-time repair, so an orcad host kept a terminal drawn in two panes. * test(orcad): type the hoisted test state without an assertion * fix(orcad): await the startup pane repair so it lands before reconcile and RPC * fix(startup): desktop awaits the duplicate-terminal repair before the window and RPC start (layout-owner build only)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<Record<string, unknown>>()),
|
||||
startCodexHooks: vi.fn()
|
||||
}))
|
||||
vi.mock('../agent-hooks/managed-agent-hook-controls', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
installManagedAgentHooks: vi.fn(async () => [])
|
||||
}))
|
||||
vi.mock('../agent-hooks/local-agent-cli-presence', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
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<string> {
|
||||
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<string, string[]> = {}
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
@@ -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<string, unknown>; 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] })
|
||||
})
|
||||
@@ -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> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user