mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(remote): the first renderer publication after a restart drops a durably closed paired tab (#29433)
This commit is contained in:
@@ -95,7 +95,7 @@ function makeDeferred() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
function createHarness(options: { restarted?: boolean } = {}) {
|
||||
let session = makeSession([TAB_A, TAB_B])
|
||||
const repo = {
|
||||
id: REPO_ID,
|
||||
@@ -196,7 +196,10 @@ function createHarness() {
|
||||
runtime as unknown as { pairedRendererSessionOwnedPtyIds: Set<string> }
|
||||
).pairedRendererSessionOwnedPtyIds.add(spec.ptyId)
|
||||
}
|
||||
publishRendererSnapshot([TAB_A, TAB_B], 1)
|
||||
// Why: a restarted host serves its disk-hydrated snapshot until the renderer's first publication.
|
||||
if (!options.restarted) {
|
||||
publishRendererSnapshot([TAB_A, TAB_B], 1)
|
||||
}
|
||||
|
||||
return {
|
||||
runtime,
|
||||
@@ -454,6 +457,29 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('red: the first renderer publication after a restart must not resurrect a retired tab', async () => {
|
||||
const harness = createHarness({ restarted: true })
|
||||
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort())
|
||||
|
||||
harness.retirePersistedTab(TAB_A)
|
||||
harness.publishRendererSnapshot([TAB_B], 1)
|
||||
|
||||
expect(
|
||||
await listParentTabIds(harness.runtime),
|
||||
'the first post-restart merge resurrected a tab the host renderer closed'
|
||||
).toEqual([TAB_B])
|
||||
expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(false)
|
||||
expect(harness.isRuntimeSessionOwned(PTY_B)).toBe(true)
|
||||
})
|
||||
|
||||
it('control: the first post-restart publication keeps a still-persisted tab it omits', async () => {
|
||||
const harness = createHarness({ restarted: true })
|
||||
harness.publishRendererSnapshot([TAB_B], 1)
|
||||
|
||||
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort())
|
||||
expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(true)
|
||||
})
|
||||
|
||||
it('control: a create still in flight is not retirement — its tab and ownership survive the omission', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
|
||||
@@ -108,22 +108,21 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
|
||||
return next
|
||||
}
|
||||
|
||||
protected releaseRuntimeSessionOwnershipForRendererRetiredTabs(
|
||||
// Why: omitted from the renderer's publication AND from persistence = durably closed; a create in flight is not.
|
||||
protected listRendererRetiredTerminalTabs(
|
||||
incoming: RuntimeMobileSessionTabsSnapshot,
|
||||
existing: RuntimeMobileSessionTabsSnapshot | undefined
|
||||
): void {
|
||||
if (!existing || this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)) {
|
||||
return
|
||||
}
|
||||
existing: RuntimeMobileSessionTabsSnapshot
|
||||
): { tab: RuntimeMobileSessionTerminalTab; scope: 'tab' | 'leaf' }[] {
|
||||
const session = this.getWorkspaceSessionForWorktree(existing.worktree)
|
||||
const persistedTabs = session?.tabsByWorktree?.[existing.worktree]
|
||||
if (!session || !persistedTabs) {
|
||||
return
|
||||
return []
|
||||
}
|
||||
const persistedTabsById = new Map(persistedTabs.map((tab) => [tab.id, tab]))
|
||||
const incomingIds = new Set(
|
||||
incoming.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab))
|
||||
)
|
||||
const retired: { tab: RuntimeMobileSessionTerminalTab; scope: 'tab' | 'leaf' }[] = []
|
||||
for (const tab of existing.tabs) {
|
||||
if (
|
||||
tab.type !== 'terminal' ||
|
||||
@@ -132,18 +131,12 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!this.hasLiveRuntimeSessionOwnedPtyBinding(existing.worktree, tab)) {
|
||||
continue
|
||||
}
|
||||
const persistedParent = persistedTabsById.get(tab.parentTabId)
|
||||
if (!persistedParent) {
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(existing.worktree, existing, tab.parentTabId)
|
||||
retired.push({ tab, scope: 'tab' })
|
||||
continue
|
||||
}
|
||||
const layout = session.terminalLayoutsByTabId?.[tab.parentTabId]
|
||||
const boundPtyIds = [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter(
|
||||
(id): id is string => typeof id === 'string'
|
||||
)
|
||||
const persistedIds = new Set(
|
||||
[persistedParent.ptyId, ...Object.values(layout?.ptyIdsByLeafId ?? {})].filter(
|
||||
(id): id is string => typeof id === 'string'
|
||||
@@ -153,14 +146,36 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
|
||||
layout &&
|
||||
!layout.ptyIdsByLeafId?.[tab.leafId] &&
|
||||
!terminalLayoutContainsLeaf(layout.root, tab.leafId) &&
|
||||
!boundPtyIds.some((id) => persistedIds.has(id))
|
||||
!getMobileTerminalLeafPtyIds(tab).some((id) => persistedIds.has(id))
|
||||
) {
|
||||
for (const ptyId of boundPtyIds) {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty?.worktreeId === existing.worktree && pty.tabId === tab.parentTabId) {
|
||||
pty.runtimeSessionOwned = false
|
||||
this.setPairedRendererSessionOwnership(ptyId, false)
|
||||
}
|
||||
retired.push({ tab, scope: 'leaf' })
|
||||
}
|
||||
}
|
||||
return retired
|
||||
}
|
||||
|
||||
protected releaseRuntimeSessionOwnershipForRendererRetiredTabs(
|
||||
incoming: RuntimeMobileSessionTabsSnapshot,
|
||||
existing: RuntimeMobileSessionTabsSnapshot | undefined
|
||||
): void {
|
||||
// Why no headless-built exemption: the first renderer publication after a restart lands on a
|
||||
// disk-hydrated base, and skipping it left a retired paired tab owned until the next sync (STA-4897).
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
for (const { tab, scope } of this.listRendererRetiredTerminalTabs(incoming, existing)) {
|
||||
if (!this.hasLiveRuntimeSessionOwnedPtyBinding(existing.worktree, tab)) {
|
||||
continue
|
||||
}
|
||||
if (scope === 'tab') {
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(existing.worktree, existing, tab.parentTabId)
|
||||
continue
|
||||
}
|
||||
for (const ptyId of getMobileTerminalLeafPtyIds(tab)) {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty?.worktreeId === existing.worktree && pty.tabId === tab.parentTabId) {
|
||||
pty.runtimeSessionOwned = false
|
||||
this.setPairedRendererSessionOwnership(ptyId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,3 +214,9 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMobileTerminalLeafPtyIds(tab: RuntimeMobileSessionTerminalTab): string[] {
|
||||
return [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter(
|
||||
(id): id is string => typeof id === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,10 +44,23 @@ export abstract class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab ex
|
||||
const incomingIds = new Set(
|
||||
incoming?.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab)) ?? []
|
||||
)
|
||||
// Why: a headless-built base preserves every terminal, so a renderer publication over it
|
||||
// (the first after a restart) must still drop the tabs the renderer durably closed (STA-4897).
|
||||
const retiredTabIds =
|
||||
incoming && this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)
|
||||
? new Set(this.listRendererRetiredTerminalTabs(incoming, existing).map(({ tab }) => tab.id))
|
||||
: null
|
||||
return existing.tabs.filter((tab) => {
|
||||
if (getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
tab.type === 'terminal' &&
|
||||
retiredTabIds?.has(tab.id) &&
|
||||
!this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(existing.worktree, tab)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return this.shouldPreserveHeadlessMobileSessionTab(existing, tab)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user