From 866bcda4655431a21296ab9f9fbfc4f2c49448e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:47:41 -0700 Subject: [PATCH] fix(terminal): recover degraded daemon spawn routing (#12277) * fix(terminal): recover degraded daemon spawn routing * fix(terminal): preserve fresh-session recovery semantics * fix(terminal): avoid retaining exited recovery sessions --- src/main/daemon/daemon-init.test.ts | 23 +++++ src/main/daemon/daemon-init.ts | 4 +- .../degraded-daemon-fresh-spawn-routing.ts | 77 +++++++++++++++++ .../degraded-daemon-pty-provider.test.ts | 86 +++++++++++++++++++ .../daemon/degraded-daemon-pty-provider.ts | 56 ++++++------ .../daemon/degraded-daemon-session-routing.ts | 15 ++++ src/main/ipc/pty-management.test.ts | 22 ++++- src/main/ipc/pty-management.ts | 6 +- src/main/ipc/pty.test.ts | 80 +++++++++++++++++ src/main/ipc/pty.ts | 58 ++++++++++--- src/main/providers/pty-provider-contract.ts | 4 + src/main/runtime/orca-runtime.test.ts | 2 + src/main/runtime/orca-runtime.ts | 5 ++ 13 files changed, 395 insertions(+), 43 deletions(-) create mode 100644 src/main/daemon/degraded-daemon-fresh-spawn-routing.ts diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 444627f59..6f7de545d 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -689,6 +689,29 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) + it('rechecks the preserved daemon endpoint before recovering fresh-spawn routing', async () => { + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/degraded-socket', + tokenPath: '/fake/degraded-token', + mode: 'degraded-new-pty-fallback' + })) + await mod.initDaemonPtyProvider() + checkDaemonHealthMock.mockClear() + + const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + const provider = mod.getDaemonProvider() + expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) + const degradedProvider = provider as InstanceType + + await expect(degradedProvider.recoverFreshSpawnRouting()).resolves.toBe(true) + expect(checkDaemonHealthMock).toHaveBeenCalledWith( + '/fake/degraded-socket', + '/fake/degraded-token' + ) + expect(degradedProvider.routesFreshSpawnsToLocalProvider).toBeUndefined() + }) + it('fans pty:exit for every active session *before* unbinding listeners, and killedCount is captured pre-fanout', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 6c793a316..de040bb8e 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -763,7 +763,9 @@ export async function initDaemonPtyProvider( ? new DegradedDaemonPtyProvider({ current: newAdapter, legacy: legacyAdapters, - fallback: getLocalPtyProvider() + fallback: getLocalPtyProvider(), + probeCurrentDaemonSpawn: async () => + (await checkDaemonHealth(info.socketPath, info.tokenPath)) === 'healthy' }) : legacyAdapters.length > 0 ? new DaemonPtyRouter({ diff --git a/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts b/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts new file mode 100644 index 000000000..b1d4661dc --- /dev/null +++ b/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts @@ -0,0 +1,77 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' + +export const DEGRADED_DAEMON_RECOVERY_RETRY_MS = 30_000 + +export class DegradedDaemonFreshSpawnRouter { + private target: IPtyProvider + private recovery: Promise | null = null + private retryAfterMs = 0 + + constructor( + private readonly current: IPtyProvider, + private readonly fallback: IPtyProvider, + private readonly sessionProviders: Map, + private readonly probeCurrent: (() => Promise) | null + ) { + this.target = fallback + } + + get routesToFallback(): true | undefined { + return this.target === this.fallback ? true : undefined + } + + supportsGitGuardHost(sessionId?: string): boolean { + const provider = (sessionId ? this.sessionProviders.get(sessionId) : undefined) ?? this.target + return provider.supportsGitCredentialGuardHost?.(sessionId) === true + } + + canProvideSnapshot(sessionId: string): boolean { + return ( + this.sessionProviders.get(sessionId)?.canProvideAuthoritativeBufferSnapshot?.(sessionId) === + true + ) + } + + async recover(): Promise { + if (this.target === this.current) { + return true + } + if (!this.probeCurrent) { + return false + } + if (Date.now() < this.retryAfterMs) { + return false + } + if (this.recovery) { + return this.recovery + } + const recovery = this.probeCurrent() + .catch(() => false) + .then((healthy) => { + if (healthy) { + this.target = this.current + console.info('[daemon] PTY spawn health recovered; fresh terminals are daemon-backed') + } else { + this.retryAfterMs = Date.now() + DEGRADED_DAEMON_RECOVERY_RETRY_MS + } + return healthy + }) + .finally(() => { + if (this.recovery === recovery) { + this.recovery = null + } + }) + this.recovery = recovery + return recovery + } + + async spawn(opts: PtySpawnOptions): Promise { + const mapped = opts.sessionId ? this.sessionProviders.get(opts.sessionId) : undefined + const target = mapped ?? this.target + const result = await target.spawn(opts) + if (!result.exitedBeforeSpawnReply) { + this.sessionProviders.set(result.id, target) + } + return result + } +} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index fbbfdfe44..1f1bac452 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn-routing' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' @@ -124,6 +125,8 @@ function createDaemonAdapter( return { ...createProvider(label, sessions, true), protocolVersion: 13, + supportsGitCredentialGuardHost: vi.fn(() => true), + canProvideAuthoritativeBufferSnapshot: vi.fn(() => true), listSessions: vi.fn(async () => []), ackColdRestore: vi.fn(), clearTombstone: vi.fn(), @@ -228,6 +231,89 @@ describe('DegradedDaemonPtyProvider', () => { expect(fallback.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) + it('routes later fresh PTYs to the daemon after spawn health recovers', async () => { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + const probeCurrentDaemonSpawn = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const provider = new DegradedDaemonPtyProvider({ + current, + legacy: [], + fallback, + probeCurrentDaemonSpawn + }) + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + + try { + await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(false) + await provider.spawn({ cols: 80, rows: 24 }) + await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(false) + expect(probeCurrentDaemonSpawn).toHaveBeenCalledOnce() + now.mockReturnValue(1_000 + DEGRADED_DAEMON_RECOVERY_RETRY_MS) + await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(true) + const recovered = await provider.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) + + expect(provider.routesFreshSpawnsToLocalProvider).toBeUndefined() + expect(provider.isDegraded).toBe(true) + expect(provider.supportsGitCredentialGuardHost()).toBe(true) + expect(fallback.spawn).toHaveBeenCalledOnce() + expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24, worktreeId: 'wt-1' }) + expect(recovered.id).toBe('daemon-new') + expect(provider.canProvideAuthoritativeBufferSnapshot(recovered.id)).toBe(true) + } finally { + now.mockRestore() + } + }) + + it('coalesces concurrent fresh-spawn recovery probes', async () => { + let resolveProbe: ((healthy: boolean) => void) | undefined + const probeCurrentDaemonSpawn = vi.fn( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + const provider = new DegradedDaemonPtyProvider({ + current: createDaemonAdapter('daemon'), + legacy: [], + fallback: createProvider('fallback'), + probeCurrentDaemonSpawn + }) + + const first = provider.recoverFreshSpawnRouting() + const second = provider.recoverFreshSpawnRouting() + expect(probeCurrentDaemonSpawn).toHaveBeenCalledOnce() + resolveProbe?.(true) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + await expect(provider.recoverFreshSpawnRouting()).resolves.toBe(true) + expect(probeCurrentDaemonSpawn).toHaveBeenCalledOnce() + }) + + it('does not retain recovered daemon ownership after exit beats the spawn reply', async () => { + const current = createDaemonAdapter('daemon') + const provider = new DegradedDaemonPtyProvider({ + current, + legacy: [], + fallback: createProvider('fallback'), + probeCurrentDaemonSpawn: vi.fn(async () => true) + }) + vi.mocked(current.spawn).mockImplementation(async () => { + current.emitExit('daemon-fast-exit', 0) + return { id: 'daemon-fast-exit', exitedBeforeSpawnReply: true } + }) + + await provider.recoverFreshSpawnRouting() + await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({ + id: 'daemon-fast-exit', + exitedBeforeSpawnReply: true + }) + + expect(provider.getCurrentDaemonSessionIds()).toEqual([]) + }) + it('routes a previously daemon-backed id to fallback after daemon exit removes the mapping', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index a607495d3..e8a584f8c 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -11,18 +11,22 @@ import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { findDaemonAdapter, listProviderSessionIds } from './degraded-daemon-session-routing' +import { + discoverDegradedDaemonSessions, + findDaemonAdapter, + listProviderSessionIds +} from './degraded-daemon-session-routing' import { probePtyOwners } from './daemon-pty-liveness-probe' +import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing' export class DegradedDaemonPtyProvider implements IPtyProvider { - readonly routesFreshSpawnsToLocalProvider = true - // Why: surface that fresh PTYs lack daemon persistence until restart. readonly isDegraded = true private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private fallback: IPtyProvider private sessionProviders = new Map() + private freshSpawns: DegradedDaemonFreshSpawnRouter private unsubscribers: (() => void)[] = [] private dataListeners: ((payload: PtyDataEvent) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] @@ -31,10 +35,17 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { current: DaemonPtyAdapter legacy: DaemonPtyAdapter[] fallback: IPtyProvider + probeCurrentDaemonSpawn?: () => Promise }) { this.current = opts.current this.legacy = opts.legacy this.fallback = opts.fallback + this.freshSpawns = new DegradedDaemonFreshSpawnRouter( + opts.current, + opts.fallback, + this.sessionProviders, + opts.probeCurrentDaemonSpawn ?? null + ) for (const provider of this.allProviders()) { this.unsubscribers.push( @@ -53,30 +64,25 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } } - async discoverDaemonSessions(): Promise { - for (const adapter of this.allDaemonAdapters()) { - try { - const sessions = await adapter.listProcesses() - for (const session of sessions) { - this.sessionProviders.set(session.id, adapter) - } - } catch (error) { - console.warn('[daemon] Failed to discover degraded daemon sessions', error) - } - } + discoverDaemonSessions(): Promise { + return discoverDegradedDaemonSessions(this.allDaemonAdapters(), this.sessionProviders) } - async spawn(opts: PtySpawnOptions): Promise { - const mapped = opts.sessionId ? this.sessionProviders.get(opts.sessionId) : undefined - const target = mapped ?? this.fallback - const result = await target.spawn(opts) - this.sessionProviders.set(result.id, target) - return result + get routesFreshSpawnsToLocalProvider(): true | undefined { + return this.freshSpawns.routesToFallback } - async attach(id: string): Promise { - await this.providerFor(id).attach(id) - } + recoverFreshSpawnRouting = (): Promise => this.freshSpawns.recover() + + supportsGitCredentialGuardHost = (id?: string): boolean => + this.freshSpawns.supportsGitGuardHost(id) + + canProvideAuthoritativeBufferSnapshot = (id: string): boolean => + this.freshSpawns.canProvideSnapshot(id) + + spawn = (opts: PtySpawnOptions): Promise => this.freshSpawns.spawn(opts) + + attach = (id: string): Promise => this.providerFor(id).attach(id) hasPty(id: string): boolean { const mapped = this.sessionProviders.get(id) @@ -147,9 +153,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null } - async clearBuffer(id: string): Promise { - await this.providerFor(id).clearBuffer(id) - } + clearBuffer = (id: string): Promise => this.providerFor(id).clearBuffer(id) async closeStartupQueryAuthority(id: string): Promise { return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 diff --git a/src/main/daemon/degraded-daemon-session-routing.ts b/src/main/daemon/degraded-daemon-session-routing.ts index 2ff91cf12..30d934f38 100644 --- a/src/main/daemon/degraded-daemon-session-routing.ts +++ b/src/main/daemon/degraded-daemon-session-routing.ts @@ -1,6 +1,21 @@ import type { IPtyProvider } from '../providers/types' import type { DaemonPtyAdapter } from './daemon-pty-adapter' +export async function discoverDegradedDaemonSessions( + adapters: readonly DaemonPtyAdapter[], + sessionProviders: Map +): Promise { + for (const adapter of adapters) { + try { + for (const session of await adapter.listProcesses()) { + sessionProviders.set(session.id, adapter) + } + } catch (error) { + console.warn('[daemon] Failed to discover degraded daemon sessions', error) + } + } +} + export function listProviderSessionIds( sessionProviders: ReadonlyMap, provider: IPtyProvider diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 4e30669fb..1586c566f 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -44,11 +44,18 @@ vi.mock('../daemon/daemon-pty-router', () => { // subscribes to adapter events, so keep only the accessors pty-management uses. vi.mock('../daemon/degraded-daemon-pty-provider', () => { class DegradedDaemonPtyProvider { - readonly isDegraded = true private allAdapters: unknown[] + private routesFreshToFallback = true constructor(opts: { current: unknown; legacy: unknown[] }) { this.allAdapters = [opts.current, ...opts.legacy] } + get routesFreshSpawnsToLocalProvider(): true | undefined { + return this.routesFreshToFallback ? true : undefined + } + async recoverFreshSpawnRouting(): Promise { + this.routesFreshToFallback = false + return true + } getAllAdapters() { return this.allAdapters } @@ -177,6 +184,19 @@ describe('pty:management IPC handlers', () => { expect(result.sessions.map((s) => s.sessionId)).toEqual(['preserved-1']) }) + it('clears degraded mode after durable fresh-spawn routing recovers', async () => { + const current = makeAdapter(5, [makeSession('preserved-1')]) + const provider = await makeDegradedProvider(current) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(provider) + registerDaemonManagementHandlers() + const handler = buildHandlerMap()['pty:management:listSessions'] + + await expect(handler({})).resolves.toMatchObject({ degraded: true }) + await provider.recoverFreshSpawnRouting() + await expect(handler({})).resolves.toMatchObject({ degraded: false }) + }) + it('returns empty list when no daemon provider is installed', async () => { getDaemonProviderMock.mockReturnValue(null) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index f188fb3aa..6a3f455ae 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -26,7 +26,11 @@ function getDaemonAdapters(): DaemonPtyAdapter[] { // Why: surface degraded mode (daemon alive but cannot spawn fresh PTYs) so the UI can warn new terminals lack persistence. function isDaemonDegraded(): boolean { - return getDaemonProvider() instanceof DegradedDaemonPtyProvider + const provider = getDaemonProvider() + return ( + provider instanceof DegradedDaemonPtyProvider && + provider.routesFreshSpawnsToLocalProvider === true + ) } async function collectSessions(adapters: DaemonPtyAdapter[]): Promise { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 04b6e7b80..16d471704 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -762,6 +762,7 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn((next) => { controller = next }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_recovered'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn() } @@ -1031,6 +1032,85 @@ describe('registerPtyHandlers', () => { clearProviderPtyState('pty-local-claim') }) + it.each(['runtime controller', 'renderer IPC'] as const)( + 'recovers degraded fresh-spawn routing before %s chooses daemon host semantics', + async (entryPoint) => { + let degraded = true + const daemonSpawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'unexpected-fallback-id' + })) + const provider = createAgentClaimProvider({ spawn: daemonSpawn }) + const recoverFreshSpawnRouting = vi.fn(async () => { + degraded = false + return true + }) + Object.defineProperties(provider, { + routesFreshSpawnsToLocalProvider: { + configurable: true, + get: () => (degraded ? true : undefined) + }, + recoverFreshSpawnRouting: { value: recoverFreshSpawnRouting } + }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const worktreeId = 'repo::/tmp/recovered-daemon-routing' + const spawnArgs = { + cols: 80, + rows: 24, + cwd: '/tmp/recovered-daemon-routing', + worktreeId + } + + await (entryPoint === 'runtime controller' + ? controller.spawn(spawnArgs) + : handlers.get('pty:spawn')!(null, spawnArgs)) + + expect(recoverFreshSpawnRouting).toHaveBeenCalledOnce() + expect(daemonSpawn).toHaveBeenCalledOnce() + expect(daemonSpawn.mock.calls[0]?.[0].sessionId).toMatch( + new RegExp(`^${worktreeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@@`) + ) + expect(recoverFreshSpawnRouting.mock.invocationCallOrder[0]).toBeLessThan( + daemonSpawn.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + } + ) + + it('recovers degraded routing for a fresh runtime session with a stable id', async () => { + let degraded = true + const daemonSpawn = vi.fn(async (options: { sessionId?: string; isNewSession?: boolean }) => ({ + id: options.sessionId ?? 'unexpected-fallback-id' + })) + const provider = createAgentClaimProvider({ spawn: daemonSpawn }) + const recoverFreshSpawnRouting = vi.fn(async () => { + degraded = false + return true + }) + Object.defineProperties(provider, { + routesFreshSpawnsToLocalProvider: { + configurable: true, + get: () => (degraded ? true : undefined) + }, + recoverFreshSpawnRouting: { value: recoverFreshSpawnRouting } + }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + + await controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/recovered-stable-session', + worktreeId: 'repo::/tmp/recovered-stable-session', + sessionId: 'serve-stable-session', + isNewSession: true + }) + + expect(recoverFreshSpawnRouting).toHaveBeenCalledOnce() + expect(daemonSpawn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'serve-stable-session', isNewSession: true }) + ) + }) + it('adopts a daemon owner recovered from provider listing before claimed ensure', async () => { const owner: AgentSessionOwnerBinding = { claim: recoveredAgentClaim, diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c109a2973..2608e54f3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -228,9 +228,6 @@ import type { PtyListedSession } from '../../shared/pty-listed-session' // Routes PTY operations by connectionId (null = local provider). let localProvider: IPtyProvider = new LocalPtyProvider() -type FreshLocalFallbackProvider = IPtyProvider & { - routesFreshSpawnsToLocalProvider?: true -} const sshProviders = new Map() const sshProvidersByGeneration = new Map() @@ -1719,10 +1716,20 @@ function isClaudeLaunchCommand(command: string | undefined): boolean { ) } -function routesFreshSpawnsToLocalProvider( - provider: IPtyProvider -): provider is FreshLocalFallbackProvider { - return (provider as FreshLocalFallbackProvider).routesFreshSpawnsToLocalProvider === true +function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { + return provider.routesFreshSpawnsToLocalProvider === true +} + +function recoverFreshSpawnProviderRouting( + provider: IPtyProvider, + connectionId: string | null | undefined, + sessionId: string | undefined, + isNewSession = sessionId === undefined +): Promise | undefined { + if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { + return + } + return provider.recoverFreshSpawnRouting?.() } function beginPtySpawnForWorktree( @@ -4212,6 +4219,17 @@ export function registerPtyHandlers( } const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) const provider = getProvider(args.connectionId) + const freshSpawnRecovery = preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting( + provider, + args.connectionId, + args.sessionId, + args.isNewSession + ) + if (freshSpawnRecovery) { + await freshSpawnRecovery + } const isClaudeLaunch = !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -4244,7 +4262,10 @@ export function registerPtyHandlers( sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined const effectiveSessionAppId = sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined - const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn + const isNewDaemonSession = + !preAdoptedStablePane && + isDaemonHostSpawn && + (callerRequestedSessionId === undefined || args.isNewSession === true) const expectedWslDistro = !args.connectionId ? (resolveWslSessionContext({ cwd, @@ -4415,7 +4436,7 @@ export function registerPtyHandlers( rows: args.rows, cwd, env, - ...(isMintedSessionId ? { isNewSession: true } : {}) + ...(isNewDaemonSession ? { isNewSession: true } : {}) } if (!args.connectionId && !isDaemonHostSpawn) { spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } @@ -4618,8 +4639,8 @@ export function registerPtyHandlers( if (isDaemonHostSpawn && expectedPtyId) { preparedProvisionalExecutionContext = runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isMintedSessionId && !stablePaneOwnerCandidate, - preserveExisting: !isMintedSessionId || Boolean(stablePaneOwnerCandidate) + resetIncarnation: isNewDaemonSession && !stablePaneOwnerCandidate, + preserveExisting: !isNewDaemonSession || Boolean(stablePaneOwnerCandidate) }) ?? false } const sequenceBeforeProviderSpawn = expectedPtyId @@ -4720,7 +4741,7 @@ export function registerPtyHandlers( stablePaneOwner = stablePaneSpawn.owner if ( stablePaneOwner && - isMintedSessionId && + isNewDaemonSession && effectiveSessionAppId && effectiveSessionAppId !== result.id ) { @@ -4755,7 +4776,10 @@ export function registerPtyHandlers( : result.wslDistro ) } catch (err) { - if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { + if ( + (isNewDaemonSession || preparedProvisionalExecutionContext) && + effectiveSessionAppId + ) { runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { resetIncarnation: true }) @@ -4798,7 +4822,7 @@ export function registerPtyHandlers( store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') } } - if (isMintedSessionId && sessionId !== undefined) { + if (isNewDaemonSession && sessionId !== undefined) { clearProviderPtyState(sessionId) } throw spawnError @@ -5501,6 +5525,12 @@ export function registerPtyHandlers( if (earlyStablePaneOwner && !preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) } + const freshSpawnRecovery = preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting(provider, args.connectionId, args.sessionId) + if (freshSpawnRecovery) { + await freshSpawnRecovery + } const isClaudeLaunch = !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 665d8e15c..b766b1149 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -97,6 +97,10 @@ export type { PtyProcessInfo, PtySpawnResult } type PtyProbeOptions = { signal?: AbortSignal } export type IPtyProvider = { + /** Fresh local spawns currently route to an in-process, non-persistent fallback. */ + readonly routesFreshSpawnsToLocalProvider?: true + /** Re-probes a degraded durable host before main commits to fallback spawn semantics. */ + recoverFreshSpawnRouting?: () => Promise spawn(opts: PtySpawnOptions): Promise /** Whether this spawn target can append the Git guard after its final env merge. */ supportsGitCredentialGuardHost?: (sessionId?: string) => boolean diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fab9c65dd..47891ff1f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -25134,6 +25134,7 @@ describe('OrcaRuntimeService', () => { persistHostSessionBinding: true }) ) + expect(spawn.mock.calls[0]?.[0]).not.toHaveProperty('isNewSession') expect(activated.tabs).toEqual([ expect.objectContaining({ id: `host-tab::${HEADLESS_LEAF_ID}`, @@ -25204,6 +25205,7 @@ describe('OrcaRuntimeService', () => { tabId: 'host-tab', leafId: HEADLESS_LEAF_ID, sessionId: expect.stringMatching(/^serve-/), + isNewSession: true, persistHostSessionBinding: true }) ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 26db5a196..15ed6e170 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1296,6 +1296,7 @@ type TerminalCreateOptions = { tabId?: string leafId?: string sessionId?: string + isNewSession?: boolean preAllocatedHandle?: string persistHostSessionBinding?: boolean // Why: only the host-derived structured resume path may attach provider @@ -1576,6 +1577,7 @@ type RuntimePtyController = { tabId?: string leafId?: string sessionId?: string + isNewSession?: boolean persistHostSessionBinding?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { @@ -24644,6 +24646,7 @@ export class OrcaRuntimeService { : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), + ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), // Why: a headless-created pane has no renderer session writer. Persist // its tab/leaf binding at spawn so a later promoted window reattaches // the live daemon or SSH PTY instead of replacing it with a fresh one. @@ -25415,6 +25418,7 @@ export class OrcaRuntimeService { // Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn. const stableSessionId = opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) + const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false, command: opts.command, @@ -25434,6 +25438,7 @@ export class OrcaRuntimeService { : stableSessionId ? { sessionId: stableSessionId } : {}), + ...(isNewSession ? { isNewSession: true } : {}), persistHostSessionBinding: true, // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. deferMobileSessionPublish: true,