Fix serve-created mobile terminals on desktop

* Fix serve-created mobile terminals on desktop

Co-authored-by: Orca <help@stably.ai>

* Address headless mobile terminal merge review

Co-authored-by: Orca <help@stably.ai>

* Document headless mobile merge invariants

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-07 21:51:49 -04:00 committed by GitHub
parent 3d013e48e8
commit e35deb9720
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 573 additions and 25 deletions

View File

@ -7020,6 +7020,270 @@ describe('OrcaRuntimeService', () => {
})
})
it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`)
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { send: vi.fn() }
})
runtime.syncWindowGraph(0, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer-empty',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs).toEqual([
expect.objectContaining({
type: 'terminal',
id: created.tab.id,
parentTabId: created.tab.parentTabId,
leafId: created.tab.leafId,
ptyId: 'serve-mobile-pty',
status: 'ready'
})
])
})
it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.syncWindowGraph(0, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'headless:split-siblings',
snapshotVersion: 1,
activeGroupId: 'headless-group',
activeTabId: 'host-tab::pane:2',
activeTabType: 'terminal',
tabGroups: [
{
id: 'headless-group',
activeTabId: 'host-tab',
tabOrder: ['host-tab']
}
],
tabs: [
{
type: 'terminal',
id: 'host-tab::pane:1',
parentTabId: 'host-tab',
leafId: 'pane:1',
title: 'left',
isActive: false
},
{
type: 'terminal',
id: 'host-tab::pane:2',
parentTabId: 'host-tab',
leafId: 'pane:2',
title: 'right',
isActive: true
}
]
}
]
})
runtime.syncWindowGraph(0, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer-empty',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs).toEqual([
expect.objectContaining({
type: 'terminal',
id: 'host-tab::pane:1',
parentTabId: 'host-tab',
leafId: 'pane:1'
}),
expect.objectContaining({
type: 'terminal',
id: 'host-tab::pane:2',
parentTabId: 'host-tab',
leafId: 'pane:2'
})
])
expect(listed.activeTabId).toBe('host-tab::pane:2')
})
it('keeps preserved headless mobile session publication epochs idempotent', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.syncWindowGraph(0, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'headless:stable-epoch',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: 'host-tab::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'host-tab::pane:1',
parentTabId: 'host-tab',
leafId: 'pane:1',
title: 'Terminal',
isActive: true
}
]
}
]
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] })
const firstMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] })
const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch)
expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1)
})
it('hydrates persisted serve-owned mobile session terminals while a renderer is attached', async () => {
const focusTerminal = vi.fn()
const spawn = vi.fn().mockResolvedValue({ id: 'serve-persisted-pty', isReattach: true })
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId: 'serve-persisted-pty',
worktreeId: TEST_WORKTREE_ID,
title: 'Persisted Mobile Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-persisted-pty' })
}
})
)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal,
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { send: vi.fn() }
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer-empty',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs).toEqual([
expect.objectContaining({
type: 'terminal',
id: `host-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'serve-persisted-pty',
status: 'pending-handle'
})
])
expect(listed.tabGroups?.[0]).toMatchObject({
activeTabId: 'host-tab',
tabOrder: ['host-tab']
})
const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
sessionId: 'serve-persisted-pty',
persistHostSessionBinding: true,
worktreeId: TEST_WORKTREE_ID
})
)
expect(focusTerminal).not.toHaveBeenCalled()
expect(activated.tabs[0]).toMatchObject({
type: 'terminal',
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
status: 'ready'
})
})
it('hydrates legacy persisted terminal tabs without layout entries', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({

View File

@ -163,6 +163,7 @@ import type {
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabMoveResult,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTerminalTab,
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult,
@ -2127,9 +2128,13 @@ export class OrcaRuntimeService {
private hydrateHeadlessMobileSessionTabsFromWorkspaceSession(
worktreeId?: string,
options: { force?: boolean } = {}
options: {
force?: boolean
allowAttachedWindow?: boolean
onlyServeOwnedTerminals?: boolean
} = {}
): void {
if (this.getAvailableAuthoritativeWindow()) {
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
return
}
const session = this.store?.getWorkspaceSession?.()
@ -2142,35 +2147,150 @@ export class OrcaRuntimeService {
: Object.entries(session.tabsByWorktree ?? {})
for (const [entryWorktreeId, persistedTabs] of entries) {
const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId)
if (existing && existing.tabs.length > 0 && options.force !== true) {
if (
existing &&
existing.tabs.length > 0 &&
options.force !== true &&
options.onlyServeOwnedTerminals !== true
) {
continue
}
const tabs = this.buildHeadlessMobileSessionTerminalTabs(entryWorktreeId, persistedTabs)
const tabs = this.buildHeadlessMobileSessionTerminalTabs(
entryWorktreeId,
persistedTabs
).filter(
(tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab)
)
if (tabs.length === 0) {
continue
}
const activeTab = this.pickHeadlessActiveTerminalTab(tabs)
const tabOrder = this.collectHeadlessParentTabOrder(tabs)
const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId)
const mergedTabs =
options.onlyServeOwnedTerminals === true && existing
? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs)
: tabs
const mergedActiveTab =
existing?.tabs.find((tab) => tab.id === existing.activeTabId) ??
activeTab ??
(mergedTabs[0]?.type === 'terminal' ? mergedTabs[0] : null)
const mergedTerminalTabs = mergedTabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
)
this.mobileSessionTabsByWorktree.set(entryWorktreeId, {
worktree: entryWorktreeId,
worktree: existing?.worktree ?? entryWorktreeId,
publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`,
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
activeGroupId: groupId,
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab ? 'terminal' : null,
tabGroups: [
{
id: groupId,
activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null,
tabOrder
}
],
tabs
activeGroupId: existing?.activeGroupId ?? groupId,
activeTabId: mergedActiveTab?.id ?? null,
activeTabType: mergedActiveTab?.type ?? null,
tabGroups:
options.onlyServeOwnedTerminals === true && existing?.tabGroups
? this.mergeMobileSessionTabGroups(
entryWorktreeId,
existing.tabGroups,
mergedTerminalTabs,
mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null
)
: [
{
id: groupId,
activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null,
tabOrder
}
],
tabs: mergedTabs
})
}
}
private isServeOwnedPtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && ptyId.startsWith('serve-')
}
private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean {
if (this.isServeOwnedPtyId(tab.ptyId)) {
return true
}
return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) =>
this.isServeOwnedPtyId(ptyId)
)
}
private mergeMobileSessionSnapshotTabs(
baseTabs: readonly RuntimeMobileSessionSnapshotTab[],
extraTabs: readonly RuntimeMobileSessionSnapshotTab[]
): RuntimeMobileSessionSnapshotTab[] {
const seenIds = new Set<string>()
const merged: RuntimeMobileSessionSnapshotTab[] = []
const add = (tab: RuntimeMobileSessionSnapshotTab): void => {
const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab)
if (ids.some((id) => seenIds.has(id))) {
return
}
for (const id of ids) {
seenIds.add(id)
}
merged.push(tab)
}
for (const tab of baseTabs) {
add(tab)
}
for (const tab of extraTabs) {
add(tab)
}
return merged
}
private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] {
if (tab.type === 'terminal') {
// Why: split terminal leaves share one parent tab; merge dedup must stay
// leaf-scoped or preserved siblings collapse into a single surface.
return [tab.id, `${tab.parentTabId}::${tab.leafId}`]
}
if (tab.type === 'browser') {
return [tab.id, tab.browserWorkspaceId]
}
return [tab.id]
}
private mergeMobileSessionTabGroups(
worktreeId: string,
groups: readonly RuntimeMobileSessionTabGroup[],
terminalTabs: readonly RuntimeMobileSessionTerminalTab[],
activeTab: RuntimeMobileSessionTerminalTab | null
): RuntimeMobileSessionTabGroup[] {
const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs)
if (parentTabOrder.length === 0) {
return [...groups]
}
const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId)
const nextGroups =
groups.length > 0
? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
: [
{
id: targetGroupId,
activeTabId: null,
tabOrder: []
}
]
const target = nextGroups[0]!
for (const tabId of parentTabOrder) {
if (!target.tabOrder.includes(tabId)) {
target.tabOrder.push(tabId)
}
}
const activeParentId =
activeTab?.parentTabId ?? target.activeTabId ?? target.tabOrder[0] ?? null
target.activeTabId =
activeParentId && target.tabOrder.includes(activeParentId)
? activeParentId
: (target.tabOrder[0] ?? null)
return nextGroups
}
private buildHeadlessMobileSessionTerminalTabs(
worktreeId: string,
persistedTabs: readonly TerminalTab[]
@ -2468,11 +2588,14 @@ export class OrcaRuntimeService {
const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find(
(candidate) => candidate.type === 'terminal' && candidate.id === tab.id
)
if (
// Why: serve-created tabs can be visible before any renderer has adopted
// their tab id, so focusing the renderer would silently no-op.
const shouldMaterializePendingTerminal =
publicTab?.type === 'terminal' &&
publicTab.status !== 'ready' &&
!this.notifier?.focusTerminal
) {
(!this.notifier?.focusTerminal ||
this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))
if (shouldMaterializePendingTerminal) {
const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined
try {
await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, {
@ -2511,6 +2634,16 @@ export class OrcaRuntimeService {
return this.getMobileSessionTabsForWorktree(worktreeId)
}
private shouldMaterializeHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab
): boolean {
return (
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
this.hasServeOwnedPtyBinding(tab)
)
}
async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> {
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
@ -12287,26 +12420,177 @@ export class OrcaRuntimeService {
if (snapshots === undefined) {
return
}
// Why: renderer graphs are authoritative for renderer tabs, but headless
// serve terminals never enter that graph unless we preserve their bindings.
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, {
allowAttachedWindow: true,
onlyServeOwnedTerminals: true
})
const nextWorktrees = new Set<string>()
for (const snapshot of snapshots) {
nextWorktrees.add(snapshot.worktree)
const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree)
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing)
if (
!existing ||
snapshot.publicationEpoch !== existing.publicationEpoch ||
snapshot.snapshotVersion >= existing.snapshotVersion
nextSnapshot.publicationEpoch !== existing.publicationEpoch ||
nextSnapshot.snapshotVersion >= existing.snapshotVersion
) {
this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot)
}
}
for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) {
for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) {
if (!nextWorktrees.has(worktreeId)) {
this.mobileSessionTabsByWorktree.delete(worktreeId)
this.notifyMobileSessionTabsRemoved(worktreeId)
const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing)
if (preserved) {
this.mobileSessionTabsByWorktree.set(worktreeId, preserved)
nextWorktrees.add(worktreeId)
} else {
this.mobileSessionTabsByWorktree.delete(worktreeId)
this.notifyMobileSessionTabsRemoved(worktreeId)
}
}
}
}
private mergePreservedHeadlessMobileSessionTabs(
snapshot: RuntimeMobileSessionTabsSnapshot,
existing: RuntimeMobileSessionTabsSnapshot | undefined
): RuntimeMobileSessionTabsSnapshot {
if (!existing) {
return snapshot
}
const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot)
if (preservedTabs.length === 0) {
return snapshot
}
const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive)
const normalizedPreservedTabs = preservedTabs.map((tab) =>
hasIncomingActiveTab ? { ...tab, isActive: false } : tab
)
const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs)
if (tabs.length === snapshot.tabs.length) {
return snapshot
}
const activeTab =
snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ??
tabs.find((tab) => tab.id === existing.activeTabId) ??
tabs.find((tab) => tab.isActive) ??
tabs[0] ??
null
const terminalTabs = tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
)
return {
...snapshot,
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(
snapshot,
normalizedPreservedTabs
),
snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion),
activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId,
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
tabGroups: this.mergeMobileSessionTabGroups(
snapshot.worktree,
snapshot.tabGroups ?? existing.tabGroups ?? [],
terminalTabs,
activeTab?.type === 'terminal' ? activeTab : null
),
tabs
}
}
private buildPreservedHeadlessMobileSessionSnapshot(
existing: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot | null {
const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing)
if (tabs.length === 0) {
return null
}
const activeTab =
tabs.find((tab) => tab.id === existing.activeTabId) ??
tabs.find((tab) => tab.isActive) ??
tabs[0] ??
null
const terminalTabs = tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal'
)
return {
...existing,
publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs),
activeGroupId:
existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree),
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
tabGroups: this.mergeMobileSessionTabGroups(
existing.worktree,
existing.tabGroups ?? [],
terminalTabs,
activeTab?.type === 'terminal' ? activeTab : null
),
tabs
}
}
private collectPreservedHeadlessMobileSessionTabs(
existing: RuntimeMobileSessionTabsSnapshot,
incoming?: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionSnapshotTab[] {
const incomingIds = new Set(
incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? []
)
return existing.tabs.filter((tab) => {
if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) {
return false
}
return this.shouldPreserveHeadlessMobileSessionTab(existing, tab)
})
}
private shouldPreserveHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionSnapshotTab
): boolean {
if (tab.type !== 'terminal') {
return false
}
return (
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
this.hasServeOwnedPtyBinding(tab)
)
}
private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
return (
publicationEpoch.startsWith('headless:') ||
publicationEpoch.startsWith('headless-hydrated:') ||
publicationEpoch.includes(':headless-merge:')
)
}
private getMergedMobileSessionPublicationEpoch(
snapshot: RuntimeMobileSessionTabsSnapshot,
preservedTabs: readonly RuntimeMobileSessionSnapshotTab[]
): string {
// Why: preserved snapshots can be merged repeatedly; normalize the prior
// merge suffix before recomputing so the publication epoch is idempotent.
const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0]
const signature = createHash('sha1')
.update(
preservedTabs
.map((tab) =>
tab.type === 'terminal'
? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}`
: tab.id
)
.join('|')
)
.digest('hex')
.slice(0, 12)
return `${normalizedPublicationEpoch}:headless-merge:${signature}`
}
private notifyMobileSessionTabsRemoved(worktreeId: string): void {
const removed: RuntimeMobileSessionTabsRemovedResult = {
worktree: worktreeId,