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
This commit is contained in:
Brennan Benson 2026-08-03 12:47:41 -07:00 committed by GitHub
parent 34291f07e9
commit 866bcda465
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 395 additions and 43 deletions

View File

@ -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<typeof DegradedDaemonPtyProvider>
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()

View File

@ -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({

View File

@ -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<boolean> | null = null
private retryAfterMs = 0
constructor(
private readonly current: IPtyProvider,
private readonly fallback: IPtyProvider,
private readonly sessionProviders: Map<string, IPtyProvider>,
private readonly probeCurrent: (() => Promise<boolean>) | 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<boolean> {
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<PtySpawnResult> {
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
}
}

View File

@ -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<boolean>>()
.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<boolean>((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')

View File

@ -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<string, IPtyProvider>()
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<boolean>
}) {
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<void> {
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<void> {
return discoverDegradedDaemonSessions(this.allDaemonAdapters(), this.sessionProviders)
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
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<void> {
await this.providerFor(id).attach(id)
}
recoverFreshSpawnRouting = (): Promise<boolean> => this.freshSpawns.recover()
supportsGitCredentialGuardHost = (id?: string): boolean =>
this.freshSpawns.supportsGitGuardHost(id)
canProvideAuthoritativeBufferSnapshot = (id: string): boolean =>
this.freshSpawns.canProvideSnapshot(id)
spawn = (opts: PtySpawnOptions): Promise<PtySpawnResult> => this.freshSpawns.spawn(opts)
attach = (id: string): Promise<void> => 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<void> {
await this.providerFor(id).clearBuffer(id)
}
clearBuffer = (id: string): Promise<void> => this.providerFor(id).clearBuffer(id)
async closeStartupQueryAuthority(id: string): Promise<number> {
return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0

View File

@ -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<string, IPtyProvider>
): Promise<void> {
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<string, IPtyProvider>,
provider: IPtyProvider

View File

@ -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<boolean> {
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)

View File

@ -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<DaemonSessionInfo[]> {

View File

@ -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,

View File

@ -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<string, IPtyProvider>()
const sshProvidersByGeneration = new Map<number, IPtyProvider>()
@ -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<boolean> | 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()) {

View File

@ -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<boolean>
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
/** Whether this spawn target can append the Git guard after its final env merge. */
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean

View File

@ -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
})
)

View File

@ -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,