fix(runtime): preserve PTY identity during desktop promotion

This commit is contained in:
OrcaWin
2026-07-30 13:52:10 -07:00
parent 89a9d4fbda
commit cefb1e9d80
7 changed files with 433 additions and 23 deletions
+33 -5
View File
@@ -533,18 +533,20 @@
"providers": ["local", "daemon", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh"],
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.",
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, repo-catalog settlement before session hydration, local daemon identity, SSH identity transfer, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8457",
"https://github.com/stablyai/orca/issues/9563"
],
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.",
"oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.",
"oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and force a superseding repo refresh between the startup catalog read and session hydration. They assert hydration waits for the latest local catalog without waiting for remote hosts, then retains the exact daemon PTY reattach hint. The update harness separately uses a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. The Electron journey independently verifies persisted tab/leaf/PTY identity, owner/runtime/daemon/PTY continuity, and terminal I/O.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/repos-stale-fetch.test.ts src/renderer/src/store/slices/repos-all-hosts-generation.test.ts src/renderer/src/app-startup-routing.test.ts",
"pnpm exec electron-vite build --mode e2e",
"pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1"
"pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-serve-desktop-activation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10"
],
"testFiles": [
"src/main/updater.headless-serve-install.test.ts",
@@ -557,6 +559,9 @@
"src/cli/runtime-client.test.ts",
"src/cli/runtime/websocket-transport.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/renderer/src/store/slices/repos-stale-fetch.test.ts",
"src/renderer/src/store/slices/repos-all-hosts-generation.test.ts",
"src/renderer/src/app-startup-routing.test.ts",
"tests/e2e/headless-serve-desktop-activation.spec.ts"
],
"assertionRefs": [
@@ -638,16 +643,39 @@
"desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready"
]
},
{
"file": "src/renderer/src/store/slices/repos-stale-fetch.test.ts",
"assertions": [
"a superseding repo refresh settles before session hydration and preserves the original daemon PTY reattach hint"
]
},
{
"file": "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts",
"assertions": [
"the hydration barrier settles after the newest local catalog without waiting for remote runtime catalogs",
"Connect-flow and all-host catalogs preserve the newest request for each runtime host regardless of response order"
]
},
{
"file": "tests/e2e/headless-serve-desktop-activation.spec.ts",
"assertions": [
"desktop activation keeps the same main owner PID, runtime id, daemon PID, and PTY id",
"promotion persists the exact original tab id, terminal leaf id, and PTY id before the test proceeds to renderer assertions",
"terminal output written before promotion remains visible and post-promotion input/output still works",
"the activating second process exits instead of becoming another owner"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-30",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-serve-desktop-activation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10",
"result": "passed",
"durationSeconds": 58,
"summary": "Ten consecutive isolated Electron promotions retained the persisted tab, leaf, daemon PTY, main owner, runtime, and daemon identities plus pre/post-promotion I/O. The affected baseline failed 3/5; disabling only the catalog-settlement wait failed 3/5 with valid session input discarded because the superseding repo catalog had not settled."
},
{
"date": "2026-07-21",
"runner": "local",
@@ -686,11 +714,11 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The original updater regression was observed red with one native install call, one paired-client disconnect, one cleanup start, no replacement owner, and a stranded staged installer. The root-cause harness was then observed red because the Electron child received no handoff path and the CLI parent exited, allowing launchd to spawn the old version while ShipIt still required zero running target apps. A live canary then exposed MacUpdater ignoring quitAndInstall relaunch arguments and starting a second desktop owner; disabling its independent relaunch for supervised mode produced one stable LaunchAgent parent, one verified replacement, and a surviving session across the real ShipIt swap. The final deterministic harness keeps that parent, observes the atomic bundle swap, and verifies the new serving version before clearing state. Earlier activation evidence also fixed second-owner and replacement-PTY failures."
"evidence": "The promotion oracle failed 3/5 on the affected behavior and 3/5 with only the catalog-settlement wait disabled: session:get still contained the original binding, but hydration saw no settled repo owner, discarded its restore metadata, and fresh-spawned. The candidate passed 10/10, while the deterministic unit race is red without settlement and green with it. The original updater regression was separately observed red with one native install call, one paired-client disconnect, one cleanup start, no replacement owner, and a stranded staged installer. The final update harness keeps the CLI parent, observes the atomic bundle swap, and verifies the new serving version before clearing state."
},
"performanceBudget": {
"required": true,
"evidence": "Normal serve and desktop paths add only constant-time mode checks plus one IPC listener on the macOS CLI child. During an actual install handoff only, the CLI watches the stable app parent directory and performs a bounded 250ms version-file poll for at most 120 seconds; there are no subprocesses, network calls, provider scans, or startup waits. Activation performance is unchanged."
"evidence": "Startup adds one local-catalog settlement barrier that follows newer local requests but excludes remote-host RPCs. Normal serve and desktop paths otherwise add only constant-time mode checks plus one IPC listener on the macOS CLI child. During an actual install handoff only, the CLI watches the stable app parent directory and performs a bounded 250ms version-file poll for at most 120 seconds; there are no extra subprocesses, network calls, or provider scans."
},
"promotionCriteria": [
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
+4
View File
@@ -444,6 +444,7 @@ function App(): React.JSX.Element {
toggleSidebar: s.toggleSidebar,
fetchRepos: s.fetchRepos,
fetchReposForAllHosts: s.fetchReposForAllHosts,
awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement,
fetchProjectGroups: s.fetchProjectGroups,
fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts,
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
@@ -898,6 +899,9 @@ function App(): React.JSX.Element {
await timeRendererStartupStep('fetch-repos-local', () =>
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('repo-catalog-settlement', () =>
actions.awaitLocalRepoCatalogSettlement()
)
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
// so keep this chain ordered while overlapping it with session-scoped hydration.
const localCatalogChain = (async () => {
@@ -20,6 +20,9 @@ describe('renderer startup runtime routing', () => {
const localReposIndex = indexInStartupBlock(
"actions.fetchReposForAllHosts({ remoteHosts: 'skip' })"
)
const repoCatalogSettlementIndex = indexInStartupBlock(
"timeRendererStartupStep('repo-catalog-settlement'"
)
const localGroupsIndex = indexInStartupBlock(
"actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })"
)
@@ -39,6 +42,8 @@ describe('renderer startup runtime routing', () => {
expect(settingsIndex).toBeLessThan(uiGetIndex)
expect(uiGetIndex).toBeLessThan(hydrateUiIndex)
expect(hydrateUiIndex).toBeLessThan(localReposIndex)
expect(localReposIndex).toBeLessThan(repoCatalogSettlementIndex)
expect(repoCatalogSettlementIndex).toBeLessThan(sessionIndex)
// The local catalog chain stays internally ordered (folders merge against project groups).
expect(localReposIndex).toBeLessThan(localGroupsIndex)
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
@@ -23,14 +23,29 @@ const remoteRepo: Repo = {
addedAt: 1
}
const staleRemoteRepo: Repo = {
...remoteRepo,
path: '/remote-stale',
displayName: 'Remote stale'
}
const freshRemoteRepo: Repo = {
...remoteRepo,
path: '/remote-fresh',
displayName: 'Remote fresh'
}
const runtimeEnvironmentCall = vi.fn()
const reposList = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
runtimeEnvironmentCall.mockReset()
reposList.mockReset()
reposList.mockResolvedValue([localRepo])
vi.stubGlobal('window', {
api: {
repos: { list: vi.fn().mockResolvedValue([localRepo]) },
repos: { list: reposList },
projects: {
list: vi.fn().mockResolvedValue([]),
listHostSetups: vi.fn().mockResolvedValue([])
@@ -46,6 +61,185 @@ beforeEach(() => {
})
describe('fetchReposForAllHosts generation', () => {
it('settles startup hydration after the local catalog without waiting for remotes', async () => {
let resolveRemote!: (value: unknown) => void
let markRemoteStarted!: () => void
const remote = new Promise((resolve) => {
resolveRemote = resolve
})
const remoteStarted = new Promise<void>((resolve) => {
markRemoteStarted = resolve
})
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'repo.list') {
markRemoteStarted()
return remote
}
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
const load = store.getState().fetchReposForAllHosts()
await remoteStarted
await store.getState().awaitLocalRepoCatalogSettlement()
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
resolveRemote({
id: 'rpc-repo-list',
ok: true,
result: { repos: [remoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
})
await load
})
it('does not let a remote refresh supersede or delay local startup settlement', async () => {
let resolveLocal!: (repos: Repo[]) => void
let resolveRemote!: (value: unknown) => void
const local = new Promise<Repo[]>((resolve) => {
resolveLocal = resolve
})
const remote = new Promise((resolve) => {
resolveRemote = resolve
})
reposList.mockReturnValueOnce(local)
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method === 'repo.list') {
return remote
}
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
const remoteRefresh = store.getState().fetchRepos()
let remoteSettled = false
void remoteRefresh.then(() => {
remoteSettled = true
})
resolveLocal([localRepo])
await startup
await store.getState().awaitLocalRepoCatalogSettlement()
expect(remoteSettled).toBe(false)
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
resolveRemote({
id: 'rpc-repo-list',
ok: true,
result: { repos: [remoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
})
await remoteRefresh
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo'])
})
it('keeps a newer Connect-flow catalog when an older all-host response resolves last', async () => {
let resolveOlder!: (value: unknown) => void
let markOlderStarted!: () => void
const older = new Promise((resolve) => {
resolveOlder = resolve
})
const olderStarted = new Promise<void>((resolve) => {
markOlderStarted = resolve
})
let repoListCalls = 0
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method !== 'repo.list') {
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
}
repoListCalls++
if (repoListCalls === 1) {
markOlderStarted()
return older
}
return {
id: 'rpc-fresh',
ok: true,
result: { repos: [freshRemoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
const allHosts = store.getState().fetchReposForAllHosts()
await olderStarted
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
resolveOlder({
id: 'rpc-stale',
ok: true,
result: { repos: [staleRemoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
})
await allHosts
expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe(
freshRemoteRepo.path
)
})
it('keeps a newer all-host catalog when an older Connect-flow response resolves last', async () => {
let resolveOlder!: (value: unknown) => void
const older = new Promise((resolve) => {
resolveOlder = resolve
})
let repoListCalls = 0
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
if (args.method !== 'repo.list') {
return {
id: 'rpc-other',
ok: true,
result: { projects: [], setups: [] },
_meta: { runtimeId: 'runtime-remote' }
}
}
repoListCalls++
return repoListCalls === 1
? older
: {
id: 'rpc-fresh',
ok: true,
result: { repos: [freshRemoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
}
})
const store = createTestStore()
const connect = store.getState().fetchRuntimeEnvironmentRepos('env-1')
await store.getState().fetchReposForAllHosts()
resolveOlder({
id: 'rpc-stale',
ok: true,
result: { repos: [staleRemoteRepo] },
_meta: { runtimeId: 'runtime-remote' }
})
await connect
expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe(
freshRemoteRepo.path
)
})
it('does not validate repo UI from a superseded refresh', async () => {
let resolveOlderRemote!: (value: unknown) => void
let resolveNewerRemote!: (value: unknown) => void
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { Repo } from '../../../../shared/types'
import { createTestStore, makeTab } from './store-test-helpers'
import type { Repo, WorkspaceSessionState } from '../../../../shared/types'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
const localRepo: Repo = {
@@ -74,4 +75,54 @@ describe('repos slice stale-fetch race (#7020)', () => {
await stale
expect(store.getState().repos).toEqual([])
})
it('waits for the superseding catalog fetch before startup hydration', async () => {
let resolveStartup!: (repos: Repo[]) => void
let resolveRefresh!: (repos: Repo[]) => void
const startupRepos = new Promise<Repo[]>((resolve) => {
resolveStartup = resolve
})
const refreshedRepos = new Promise<Repo[]>((resolve) => {
resolveRefresh = resolve
})
reposList.mockReturnValueOnce(startupRepos).mockReturnValueOnce(refreshedRepos)
const store = createTestStore()
const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
const refresh = store.getState().fetchRepos()
resolveStartup([localRepo])
await startup
let settled = false
const settlement = store
.getState()
.awaitLocalRepoCatalogSettlement()
.then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
resolveRefresh([localRepo])
await Promise.all([refresh, settlement])
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
const worktreeId = 'local-repo::/local'
const session: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
activeRepoId: localRepo.id,
activeWorktreeId: worktreeId,
activeTabId: 'restored-tab',
activeWorktreeIdsOnShutdown: [worktreeId],
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'restored-tab', worktreeId, ptyId: 'original-daemon-pty' })]
}
}
store.getState().hydrateWorkspaceSession(session)
expect(store.getState().activeWorktreeId).toBe(worktreeId)
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({
'restored-tab': 'original-daemon-pty'
})
})
})
+87 -13
View File
@@ -1530,12 +1530,13 @@ export type RepoSlice = {
folderWorkspaces: FolderWorkspace[]
folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry>
activeRepoId: string | null
// Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020).
// Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020).
reposFetchGeneration: number
pendingSshRepoReadoptions: SshRepoReadoption[]
recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void
fetchRepos: () => Promise<void>
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
awaitLocalRepoCatalogSettlement: () => Promise<void>
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
fetchProjectGroups: () => Promise<void>
fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
@@ -1633,6 +1634,51 @@ export type RepoSlice = {
reorderRepos: (orderedIds: string[]) => Promise<void>
}
const latestLocalRepoCatalogFetchByStore = new WeakMap<() => AppState, Promise<void>>()
const latestRepoCatalogGenerationByHostByStore = new WeakMap<() => AppState, Map<string, number>>()
const latestAllHostRepoCatalogGenerationByStore = new WeakMap<() => AppState, number>()
function startLocalRepoCatalogFetch(get: () => AppState): () => void {
let settle: () => void = () => undefined
const settlement = new Promise<void>((resolve) => {
settle = resolve
})
latestLocalRepoCatalogFetchByStore.set(get, settlement)
return settle
}
async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise<void> {
while (true) {
const pending = latestLocalRepoCatalogFetchByStore.get(get)
if (!pending) {
return
}
await pending
if (latestLocalRepoCatalogFetchByStore.get(get) === pending) {
return
}
}
}
function claimRepoCatalogGeneration(get: () => AppState, hostId: string, generation: number): void {
let generations = latestRepoCatalogGenerationByHostByStore.get(get)
if (!generations) {
generations = new Map()
latestRepoCatalogGenerationByHostByStore.set(get, generations)
}
if ((generations.get(hostId) ?? 0) < generation) {
generations.set(hostId, generation)
}
}
function isLatestRepoCatalogGeneration(
get: () => AppState,
hostId: string,
generation: number
): boolean {
return latestRepoCatalogGenerationByHostByStore.get(get)?.get(hostId) === generation
}
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
repos: [],
projects: [],
@@ -1670,17 +1716,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}),
fetchRepos: async () => {
const target = getActiveRuntimeTarget(get().settings)
const settleLocalCatalog =
target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined
// Why: overlapping repos:changed fetches can resolve out of order; a stale one must not overwrite a newer result and resurrect deleted projects (#7020).
let generation = 0
set((s) => {
generation = s.reposFetchGeneration + 1
return { reposFetchGeneration: generation }
})
const targetHostId = getRuntimeTargetHostId(target)
claimRepoCatalogGeneration(get, targetHostId, generation)
try {
const target = getActiveRuntimeTarget(get().settings)
const catalog = await fetchRepoCatalogForTarget(target)
// A newer fetchRepos superseded us while we awaited — drop this stale result.
if (get().reposFetchGeneration !== generation) {
// A newer same-host fetch superseded us while we awaited — drop this stale result.
if (!isLatestRepoCatalogGeneration(get, targetHostId, generation)) {
return
}
let finalizedHostRepos: Repo[] = []
@@ -1732,6 +1782,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
scheduleSafeAutoForkSync(get, finalizedHostRepos)
} catch (err) {
console.error('Failed to fetch repos:', err)
} finally {
settleLocalCatalog()
}
},
@@ -1740,11 +1792,19 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration)
const connectionGeneration = getEnvironmentSshStateGeneration(environmentId)
const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
let catalogGeneration = 0
set((s) => {
catalogGeneration = s.reposFetchGeneration + 1
return { reposFetchGeneration: catalogGeneration }
})
const target = { kind: 'environment' as const, environmentId }
const targetHostId = getRuntimeTargetHostId(target)
claimRepoCatalogGeneration(get, targetHostId, catalogGeneration)
try {
const target = { kind: 'environment' as const, environmentId }
const catalog = await fetchRepoCatalogForTarget(target)
if (
runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration ||
!isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) ||
getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration ||
getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration
) {
@@ -1754,6 +1814,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
set((s) => {
if (
runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration ||
!isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) ||
getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration ||
getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration
) {
@@ -1810,15 +1871,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
},
fetchReposForAllHosts: async (options) => {
const settleLocalCatalog = startLocalRepoCatalogFetch(get)
let generation = 0
set((s) => {
generation = s.reposFetchGeneration + 1
return { reposFetchGeneration: generation }
})
latestAllHostRepoCatalogGenerationByStore.set(get, generation)
claimRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation)
// Why: fetching only the active host hides every other host's repos ("my projects vanished"); load local + all runtime envs, each failing soft.
const applyCatalog = (catalog: FetchedRepoCatalog): void => {
// Why: a concurrent all-host refresh must not let the older catalog resurrect a migrated SSH owner.
if (get().reposFetchGeneration !== generation) {
if (
latestAllHostRepoCatalogGenerationByStore.get(get) !== generation ||
!isLatestRepoCatalogGeneration(get, catalog.hostId, generation)
) {
return
}
let hostRepos: Repo[] = []
@@ -1886,7 +1953,12 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
failed = true
console.error('Failed to fetch local repos for all-host load:', err)
}
if (get().reposFetchGeneration !== generation) {
// Why: startup hydration needs the newest local catalog, not unreachable remote hosts.
settleLocalCatalog()
if (
get().reposFetchGeneration !== generation &&
!isLatestRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation)
) {
return
}
if (options?.remoteHosts === 'skip') {
@@ -1897,13 +1969,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber.
await Promise.all(
environments.map(async (environment) => {
const target = {
kind: 'environment' as const,
environmentId: environment.id
}
claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation)
try {
applyCatalog(
await fetchRepoCatalogForTarget({
kind: 'environment',
environmentId: environment.id
})
)
applyCatalog(await fetchRepoCatalogForTarget(target))
} catch (err) {
failed = true
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
@@ -1916,6 +1988,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
awaitLocalRepoCatalogSettlement: () => awaitLatestLocalRepoCatalogFetch(get),
fetchProjectGroups: async () => {
try {
const target = getActiveRuntimeTarget(get().settings)
@@ -29,6 +29,8 @@ import type {
RuntimeTerminalRead
} from '../../src/shared/runtime-types'
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
import { parsePaneKey } from '../../src/shared/stable-pane-id'
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron')
const electronPath = path.join(
@@ -67,6 +69,35 @@ function readDaemonPid(userDataDir: string): number {
return parsed.pid
}
function readPersistedPromotionBinding(
userDataDir: string,
worktreeId: string,
tabId: string,
leafId: string
): { tabId: string; leafId: string; ptyId: string } | null {
try {
const persisted = JSON.parse(
readFileSync(
path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'),
'utf8'
)
) as {
workspaceSession?: {
tabsByWorktree?: Record<string, { id?: string; ptyId?: string | null }[]>
terminalLayoutsByTabId?: Record<string, { ptyIdsByLeafId?: Record<string, string | null> }>
}
}
const tab = persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.find(
(candidate) => candidate.id === tabId
)
const ptyId =
persisted.workspaceSession?.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId]
return tab && ptyId ? { tabId, leafId, ptyId } : null
} catch {
return null
}
}
async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) {
return true
@@ -133,8 +164,11 @@ test('promotes the headless owner without replacing its daemon terminal', async
title: 'Serve promotion continuity'
})
const terminal = created.result.terminal
if (!terminal.ptyId) {
throw new Error('Headless terminal did not expose its daemon PTY id')
const originalPtyId = terminal.ptyId
const originalTabId = terminal.tabId
const paneIdentity = terminal.paneKey ? parsePaneKey(terminal.paneKey) : null
if (!originalPtyId || !originalTabId || !paneIdentity) {
throw new Error('Headless terminal did not expose its durable pane and daemon PTY identity')
}
const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}`
@@ -177,6 +211,26 @@ test('promotes the headless owner without replacing its daemon terminal', async
})
}
await expect
.poll(
() =>
readPersistedPromotionBinding(
userDataDir,
terminal.worktreeId,
originalTabId,
paneIdentity.leafId
),
{
timeout: 15_000,
message: 'headless terminal binding was not persisted during desktop promotion'
}
)
.toEqual({
tabId: originalTabId,
leafId: paneIdentity.leafId,
ptyId: originalPtyId
})
const page = await serveApp.firstWindow({ timeout: 60_000 })
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })