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:
parent
34291f07e9
commit
866bcda465
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue