Fix terminal split source incarnation and rejection cleanup (#14238)

* fix(terminal): fence split source incarnation

* fix(terminal): retire rejected split safely

- Track retired rejected PTYs to prevent synthetic exits from landing after
  split rejection completes.
- Validate splits using persisted incarnation IDs only, allowing restored
  sessions without incarnation maps to work correctly.
- Reduce stop timeout from 10s to 2s to avoid stalling on unreachable hosts.
This commit is contained in:
Jinjing
2026-08-13 01:07:40 -07:00
committed by GitHub
parent a478b914da
commit 00cab82fc0
6 changed files with 408 additions and 37 deletions
+10 -2
View File
@@ -6296,7 +6296,7 @@ describe('registerPtyHandlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
@@ -6339,22 +6339,30 @@ describe('registerPtyHandlers', () => {
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
retireRejectedPty: (ptyId: string) => void
}
try {
expect(controller.kill('remote-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
controller.retireRejectedPty('remote-pty')
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
+48 -6
View File
@@ -3563,6 +3563,23 @@ export function registerPtyHandlers(
syntheticKillExitPtyIds.set(id, cleanupTimer)
}
// Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late
// synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the
// surface the retirement just removed. Timed like the duplicate window so a reused id is free.
const retiredRejectedPtyIds = new Map<string, NodeJS.Timeout>()
function rememberRetiredRejectedPty(id: string): void {
const existing = retiredRejectedPtyIds.get(id)
if (existing) {
clearTimeout(existing)
}
const cleanupTimer = setTimeout(() => {
retiredRejectedPtyIds.delete(id)
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
cleanupTimer.unref?.()
retiredRejectedPtyIds.set(id, cleanupTimer)
}
function consumeSyntheticKillExit(id: string): boolean {
const cleanupTimer = syntheticKillExitPtyIds.get(id)
if (!cleanupTimer) {
@@ -5472,19 +5489,23 @@ export function registerPtyHandlers(
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
.then((providerExitObserved) => {
const retired = retiredRejectedPtyIds.has(ptyId)
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
if (!providerExitObserved && !retired) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
})
.catch((err) => {
const retired = retiredRejectedPtyIds.has(ptyId)
if (isPtyAlreadyGoneError(err)) {
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
if (!retired) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
return
}
console.warn(
@@ -5492,7 +5513,9 @@ export function registerPtyHandlers(
)
// Why: close runtime tails without clearing provider ownership, so
// a retry can still target a PTY that survived the failed shutdown.
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
if (!retired) {
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
return true
}
@@ -5503,12 +5526,31 @@ export function registerPtyHandlers(
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
if (!retiredRejectedPtyIds.has(ptyId)) {
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
return true
}
return killWithCurrentProvider()
},
retireRejectedPty: (ptyId) => {
rememberRetiredRejectedPty(ptyId)
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
if (!ptyOwnership.has(ptyId)) {
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
return
}
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, 0, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: 0 })
},
markReversibleStops: (ptyIds) => {
for (const ptyId of ptyIds) {
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
+93
View File
@@ -9765,6 +9765,99 @@ describe('Store', () => {
})
})
it('requires an expected split source incarnation in the owning host partition', async () => {
for (const hostId of [undefined, 'ssh:ssh-1']) {
const store = await createStore()
const sourceSession: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
}
}
}
if (hostId) {
store.setWorkspaceSession(
{
...sourceSession,
terminalPtyIncarnationsByPaneKey: {
[`tab1:${TEST_LEAF_1}`]: 'live-incarnation'
}
},
undefined
)
}
store.setWorkspaceSession(sourceSession, hostId)
expect(
store.persistPtyBinding(
{
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_2,
ptyId: 'pty-split',
expectedSourceBinding: {
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-source',
incarnationId: 'live-incarnation'
}
},
hostId
)
).toBe(false)
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId.tab1.root).toEqual({
type: 'leaf',
leafId: TEST_LEAF_1
})
}
})
// Why: a session restored without an incarnation map still owns a valid source binding, so the
// split path must be able to fence on the pane alone instead of an id persistence never recorded.
it('admits a split whose source pane has no persisted incarnation', async () => {
const store = await createStore()
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' }
}
}
},
undefined
)
expect(
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_2,
ptyId: 'pty-split',
expectedSourceBinding: {
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_1,
ptyId: 'pty-source'
}
})
).toBe(true)
})
it('rejects competing PTY and incarnation changes during reconciliation', async () => {
const store = await createStore()
const paneKey = `tab1:${TEST_LEAF_1}`
@@ -69,35 +69,69 @@ function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot {
}
}
function createHarness(includeSource = true) {
function createHarness(
includeSource = true,
options: {
connectionId?: string | null
deferReveal?: boolean
deferSpawn?: boolean
includePairedSnapshot?: boolean
rendererMounted?: boolean
sourceIncarnationId?: string
stopAndWaitResult?: boolean
} = {}
) {
let session = persistedSession(includeSource)
const connectionId = options.connectionId ?? null
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
const requestedSessionHostIds: (string | undefined)[] = []
const repo = {
id: REPO_ID,
path: '/workspace',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
addedAt: 1,
...(connectionId ? { connectionId } : {})
}
const store = {
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getWorkspaceSession: () => session,
getWorkspaceSession: (hostId?: string) => {
requestedSessionHostIds.push(hostId)
return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession()
},
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
persistPtyBinding: () => true
}
const spawn = vi.fn(async () => ({ id: SPLIT_PTY_ID }))
let resolveSpawn: ((result: { id: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
: vi.fn(async () => ({ id: SPLIT_PTY_ID }))
const kill = vi.fn(() => true)
const revealTerminalSession = vi
.fn()
.mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const retireRejectedPty = vi.fn()
const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true)
let resolveReveal: ((result: { tabId: string }) => void) | undefined
const revealTerminalSession = options.deferReveal
? vi.fn(
() =>
new Promise<{ tabId: string }>((resolve) => {
resolveReveal = resolve
})
)
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const runtime = new OrcaRuntimeService(store as never)
Object.assign(runtime, {
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: WORKTREE_ID,
path: '/workspace',
connectionId: null,
connectionId,
repo,
folderWorkspace: null
}))
@@ -106,35 +140,77 @@ function createHarness(includeSource = true) {
spawn,
write: () => true,
kill,
retireRejectedPty,
...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}),
getForegroundProcess: async () => null
})
runtime.setNotifier({ revealTerminalSession } as never)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: includeSource ? [remoteSnapshot()] : []
tabs:
includeSource && options.rendererMounted
? [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Restored terminal',
activeLeafId: SOURCE_LEAF_ID,
layout: { type: 'leaf', leafId: SOURCE_LEAF_ID } as const
}
]
: [],
leaves:
includeSource && options.rendererMounted
? [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: SOURCE_LEAF_ID,
paneRuntimeId: 1,
ptyId: SOURCE_PTY_ID
}
]
: [],
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
})
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, null, {
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
})
const internals = runtime as unknown as {
getTerminalHandleForPaneKey: (paneKey: string) => string | null
issuePtyHandle: (pty: unknown) => string
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
ptysById: Map<string, unknown>
}
const handle =
internals.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, SOURCE_LEAF_ID)) ??
internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
return {
runtime,
handle,
spawn,
kill,
retireRejectedPty,
stopAndWait,
revealTerminalSession,
getSession: () => session,
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID)
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
requestedSessionHostIds,
replaceSourceIncarnation: (incarnationId: string) =>
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
incarnationId
}),
replacePersistedSourceIncarnation: (incarnationId: string) => {
session = {
...session,
terminalPtyIncarnationsByPaneKey: {
...session.terminalPtyIncarnationsByPaneKey,
[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]: incarnationId
}
}
},
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID })
}
}
@@ -185,4 +261,118 @@ describe('remote runtime terminal split authority', () => {
expect.soft(harness.kill).not.toHaveBeenCalled()
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it.each([
{ label: 'local', connectionId: null, expectedHostId: 'local' },
{ label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' }
])(
'rejects a same-ID $label source replacement when restored persistence lacks an incarnation',
async ({ connectionId, expectedHostId }) => {
const harness = createHarness(true, {
connectionId,
deferSpawn: true,
includePairedSnapshot: false,
rendererMounted: true,
sourceIncarnationId: 'source-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
expect(
harness.getSession().terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]
).toBeUndefined()
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.objectContaining({ ptyId: SOURCE_PTY_ID })
})
)
// Why: persistence never recorded this incarnation, so sending it would make the store
// reject every split from a restored pane; the live id is fenced in-runtime instead.
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
})
)
harness.replaceSourceIncarnation('source-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.requestedSessionHostIds).toContain(expectedHostId)
}
)
it('rejects a same-ID paired-runtime source replacement recovered without an incarnation map', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: true,
rendererMounted: false,
sourceIncarnationId: 'remote-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
})
)
harness.replaceSourceIncarnation('remote-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it('rejects a persisted-only source incarnation change during spawn', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
stopAndWaitResult: true
})
harness.replacePersistedSourceIncarnation('persisted-before')
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
harness.replacePersistedSourceIncarnation('persisted-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
})
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
stopAndWaitResult: false
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
)
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
})
})
+2
View File
@@ -14898,6 +14898,8 @@ describe('OrcaRuntimeService', () => {
const splitSpawn = spawn.mock.calls[0]?.[0] as
| { expectedSourceBinding?: { incarnationId?: string } }
| undefined
// Why: persistence never recorded an incarnation for this pane, so sending the live-only id
// would make the store's fence reject every split from a restored session.
expect(splitSpawn?.expectedSourceBinding).not.toHaveProperty('incarnationId')
runtime.syncWindowGraph(1, {
+47 -11
View File
@@ -1725,6 +1725,7 @@ type RuntimePtyController = {
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
attach?(ptyId: string): Promise<boolean>
kill(ptyId: string): boolean
retireRejectedPty?(ptyId: string): void
stopAndWait?(
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
@@ -1828,6 +1829,9 @@ const BRACKETED_PASTE_END = '\x1b[201~'
const BRACKETED_PASTE_QUIET_MS = 1500
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
// Why: the split already failed; the caller waits on this teardown only to learn whether the
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
const SSH_PANE_RECOVERY_GRACE_MS = 30_000
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
@@ -27376,6 +27380,8 @@ export class OrcaRuntimeService {
if (!sourceAuthority) {
throw new Error('terminal_split_source_not_found')
}
const sourceIncarnationId =
sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId
const leafId = randomUUID()
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const paneKey = makePaneKey(parentTabId, leafId)
@@ -27402,6 +27408,9 @@ export class OrcaRuntimeService {
tabId: parentTabId,
leafId: parsedPaneKey.leafId,
ptyId: pty.ptyId,
// Why: the store can only match its own persisted map, so a live-only id it never
// recorded would reject every split from a session restored without incarnations.
// The live id is fenced by revalidateSourceAuthority below instead.
...(sourceAuthority.persistedIncarnationId
? { incarnationId: sourceAuthority.persistedIncarnationId }
: {})
@@ -27440,17 +27449,29 @@ export class OrcaRuntimeService {
}
try {
const revalidatedAuthority = this.resolveTerminalSplitSourceAuthority(
workspace.id,
parentTabId,
parsedPaneKey.leafId,
pty.ptyId
)
if (!revalidatedAuthority || (sourceAuthority.persisted && !revalidatedAuthority.persisted)) {
throw new Error('terminal_split_source_not_found')
const revalidateSourceAuthority = (): void => {
const current = this.resolveTerminalSplitSourceAuthority(
workspace.id,
parentTabId,
parsedPaneKey.leafId,
pty.ptyId
)
if (
!current ||
(sourceAuthority.persisted && !current.persisted) ||
(sourceIncarnationId !== null &&
(current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId)
) {
throw new Error('terminal_split_source_not_found')
}
}
revalidateSourceAuthority()
if (!sourceAuthority.persisted) {
await revealSplit()
// Why: rejecting here unmounts the pane the reveal just added only because the retire
// below always emits its exit and the tab still holds the source sibling — the renderer's
// exit handler closes non-final panes. Never close it by tabId: that drops the whole tab.
revalidateSourceAuthority()
}
if (createdPty) {
const persisted = this.persistHeadlessTerminalSplit({
@@ -27474,7 +27495,19 @@ export class OrcaRuntimeService {
}
} catch (error) {
this.setPairedRendererSessionOwnership(result.id, false)
this.ptyController.kill?.(result.id)
let stopped = false
try {
stopped =
(await this.ptyController.stopAndWait?.(result.id, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS
})) ?? false
} catch {
// Best-effort fallback below preserves the original split authority error.
}
if (!stopped) {
this.ptyController.kill(result.id)
}
this.ptyController.retireRejectedPty?.(result.id)
throw error
}
const committedSourceAuthority = sourceAuthority.persisted
@@ -27503,6 +27536,7 @@ export class OrcaRuntimeService {
rendererMounted: boolean
persistedWorktreeId: string | null
persistedIncarnationId: string | null
liveIncarnationId: string | null
} | null {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null
@@ -27541,7 +27575,8 @@ export class OrcaRuntimeService {
persisted: true,
rendererMounted,
persistedWorktreeId: sessionWorktreeId,
persistedIncarnationId
persistedIncarnationId,
liveIncarnationId
}
}
// Why: renderer adoption can precede graph sync; this path still requires reveal success before commit.
@@ -27563,7 +27598,8 @@ export class OrcaRuntimeService {
persisted: false,
rendererMounted,
persistedWorktreeId: null,
persistedIncarnationId: null
persistedIncarnationId: null,
liveIncarnationId
}
}