fix(terminal): adopt live orphan PTYs safely

This commit is contained in:
OrcaWin
2026-07-22 22:16:50 -07:00
parent 09756dfaff
commit cc0100f30b
26 changed files with 3246 additions and 95 deletions
@@ -885,6 +885,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
})
it('reports the daemon session WSL owner', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const spawned = await adapter.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const procs = await adapter.listProcesses()
expect(procs.find((process) => process.id === spawned.id)?.wslDistro).toBe('Ubuntu')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
})
describe('hasChildProcesses / getForegroundProcess', () => {
+1
View File
@@ -921,6 +921,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}),
...(s.wslDistro !== undefined ? { wslDistro: s.wslDistro } : {}),
...this.validatedAgentSessionOwners(s.agentSessionOwners)
}
})
@@ -19,6 +19,7 @@ export function listLiveTerminalHostSessions(
shellState: session.shellState,
isAlive: true,
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
wslDistro: session.wslDistro,
pid: session.pid,
cwd: session.getCwd(),
cols: size?.cols ?? 0,
+1
View File
@@ -355,6 +355,7 @@ export type SessionInfo = {
shellState: ShellReadyState
isAlive: boolean
terminalHandle?: string
wslDistro?: string | null
pid: number | null
cwd: string | null
cols: number
@@ -1789,6 +1789,26 @@ describe('LocalPtyProvider', () => {
expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd')
expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd')
})
it('reports native and WSL ownership explicitly on Windows', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const native = await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\repo',
shellOverride: 'powershell.exe'
})
const wsl = await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const processes = await provider.listProcesses()
expect(processes.find((process) => process.id === native.id)?.wslDistro).toBeNull()
expect(processes.find((process) => process.id === wsl.id)?.wslDistro).toBe('Ubuntu')
})
})
describe('getDefaultShell', () => {
+2 -1
View File
@@ -1323,7 +1323,8 @@ export class LocalPtyProvider implements IPtyProvider {
cwd: ptyInitialCwd.get(id) ?? '',
title: proc.process || ptyShellName.get(id) || 'shell',
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}),
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {})
}))
}
+16
View File
@@ -0,0 +1,16 @@
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type PtyProcessInfo = {
id: string
incarnationId?: PtyIncarnationId
cwd: string
title: string
/** Owning worktree when the provider can report it authoritatively. */
worktreeId?: string
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
terminalHandle?: string
/** Exact WSL owner reported by the PTY provider; null means native Windows. */
wslDistro?: string | null
agentSessionOwners?: AgentSessionOwnerBinding[]
}
+3 -18
View File
@@ -29,9 +29,9 @@ import type { PtySpawnResult } from './pty-spawn-result'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type {
AgentSessionExecutionClaim,
AgentSessionOwnerBinding,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtyProcessInfo } from './pty-process-info'
export type {
PtyBackgroundStreamEvent,
@@ -114,19 +114,7 @@ export type PtySpawnOptions = {
signal?: AbortSignal
}
export type { PtySpawnResult }
export type PtyProcessInfo = {
id: string
incarnationId?: PtyIncarnationId
cwd: string
title: string
/** Owning worktree when the provider can report it authoritatively. */
worktreeId?: string
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
terminalHandle?: string
agentSessionOwners?: AgentSessionOwnerBinding[]
}
export type { PtyProcessInfo, PtySpawnResult }
type PtyProbeOptions = { signal?: AbortSignal }
@@ -393,10 +381,7 @@ export type IGitProvider = {
// ─── Provider Registry ──────────────────────────────────────────────
/**
* Routes operations to the correct provider based on connectionId.
* null/undefined connectionId = local provider.
*/
/** Routes operations by connectionId; null/undefined selects the local provider. */
export type IProviderRegistry = {
getPtyProvider(connectionId: string | null | undefined): IPtyProvider
getFilesystemProvider(connectionId: string | null | undefined): IFilesystemProvider
File diff suppressed because it is too large Load Diff
+697 -11
View File
@@ -368,6 +368,8 @@ import type {
RuntimeTerminalFocus,
RuntimeTerminalClose,
RuntimeTerminalListResult,
RuntimeTerminalOrphanAdoptionRequest,
RuntimeTerminalOrphanAdoptionResult,
RuntimeWorktreeTerminalSleepResult,
RuntimeTerminalResolvePane,
RuntimeTerminalState,
@@ -426,6 +428,11 @@ import {
buildHeadlessTabGroupMove,
buildHeadlessTabGroupSplit
} from './headless-tab-group-split-layout'
import {
hasExactTerminalOrphanGroupLayout,
mergeTerminalOrphanGroupLayout
} from './terminal-orphan-topology'
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
import {
retireTerminalSurfacesFromSnapshot,
type RetiredTerminalSurface
@@ -1134,6 +1141,7 @@ type RuntimePtyWorktreeRecord = {
lastOscTitleAt: number | null
managementTitle: string | null
managementTitleAt: number | null
controllerTitle: string | null
title: string | null
titleUpdatedAt: number | null
lastOutputAt: number | null
@@ -2462,6 +2470,10 @@ export class OrcaRuntimeService {
private handles = new Map<string, TerminalHandleRecord>()
private handleByLeafKey = new Map<string, string>()
private handleByPtyId = new Map<string, string>()
private controllerTerminalIdentityByPtyId = new Map<
string,
{ handle: string; incarnationId: string; wslDistro?: string | null }
>()
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
private graphSyncCallbacks: (() => void)[] = []
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
@@ -7141,11 +7153,29 @@ export class OrcaRuntimeService {
}
}
private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void {
private adoptControllerTerminalHandle(
ptyId: string,
handle: string | undefined,
incarnationId?: string
): void {
const trimmed = handle?.trim()
if (!trimmed || !trimmed.startsWith('term_')) {
return
}
const pty = this.ptysById.get(ptyId)
const changedIncarnation = Boolean(
incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId
)
if (changedIncarnation) {
const priorHandle = this.handleByPtyId.get(ptyId)
this.invalidateAllHandlesForPty(ptyId)
pty!.tabId = null
pty!.paneKey = null
// Reusing an exported handle would make stale client metadata name the replacement process.
if (priorHandle === trimmed) {
return
}
}
if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) {
return
}
@@ -7154,6 +7184,23 @@ export class OrcaRuntimeService {
this.registerPreAllocatedHandleForPty(ptyId, trimmed)
}
private invalidateAllHandlesForPty(ptyId: string): void {
this.handleByPtyId.delete(ptyId)
const invalidated = new Set<string>()
for (const [handle, record] of this.handles) {
if (record.ptyId === ptyId) {
invalidated.add(handle)
this.handles.delete(handle)
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
}
}
for (const [leafKey, handle] of this.handleByLeafKey) {
if (invalidated.has(handle)) {
this.handleByLeafKey.delete(leafKey)
}
}
}
// Why: adoption is best-effort restart recovery and must be first-wins.
// Re-keying a pty that already has a handle this session would strand
// waiters registered under the old handle, and provider-reported values
@@ -12417,7 +12464,7 @@ export class OrcaRuntimeService {
async listTerminals(
worktreeSelector?: string,
limit = DEFAULT_TERMINAL_LIST_LIMIT,
opts: { requireFreshPtyLiveness?: boolean } = {}
opts: { handles?: readonly string[]; requireFreshPtyLiveness?: boolean } = {}
): Promise<RuntimeTerminalListResult> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
@@ -12529,7 +12576,11 @@ export class OrcaRuntimeService {
terminals.push(this.buildPtyTerminalSummary(pty, worktreesById))
}
const listedTerminals = terminals.slice(0, limit)
const requestedHandles = opts.handles ? new Set(opts.handles) : null
const matchingTerminals = requestedHandles
? terminals.filter((terminal) => requestedHandles.has(terminal.handle))
: terminals
const listedTerminals = matchingTerminals.slice(0, limit)
const visualLayouts = this.buildTerminalVisualLayouts(
listedTerminals,
worktreesById,
@@ -12539,8 +12590,480 @@ export class OrcaRuntimeService {
return {
terminals: listedTerminals,
...(visualLayouts.length > 0 ? { visualLayouts } : {}),
totalCount: terminals.length,
truncated: terminals.length > limit
topologyRevisions: Object.fromEntries(
[...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [
worktreeId,
this.getTerminalTopologyRevision(worktreeId)
])
),
totalCount: matchingTerminals.length,
truncated: matchingTerminals.length > limit
}
}
private getTerminalTopologyRevision(worktreeId: string): number {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return (
this.store?.getWorkspaceSession?.()?.terminalTopologyRevisionByRepoId?.[repoId] ??
this.terminalTopologyRevisionByRepoId.get(repoId) ??
0
)
}
async adoptTerminalOrphans(
request: RuntimeTerminalOrphanAdoptionRequest
): Promise<RuntimeTerminalOrphanAdoptionResult> {
if (request.claims.length === 0) {
throw new Error('terminal_orphan_claims_required')
}
const worktree = await this.resolveWorktreeSelector(request.worktree)
const livePtyIds = await this.refreshPtyWorktreeRecordsFromController([worktree], worktree.id)
if (!livePtyIds) {
throw new Error('terminal_liveness_unavailable')
}
const store = this.store
const session = store?.getWorkspaceSession?.()
if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) {
throw new Error('workspace_session_unavailable')
}
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, worktree.id)
if (!sessionWorktreeId) {
throw new Error('terminal_orphan_competing_owner')
}
const repoId = getRepoIdFromWorktreeId(worktree.id)
const worktreeRepo = store.getRepo(repoId)
if (!worktreeRepo) {
throw new Error('terminal_orphan_owner_mismatch')
}
const worktreeConnectionId = worktreeRepo.connectionId ?? null
let worktreeWslDistro: string | null = null
if (!worktreeConnectionId) {
try {
worktreeWslDistro =
getLocalProjectWorktreeGitOptions(this.requireStore(), worktreeRepo).wslDistro ?? null
} catch {
throw new Error('terminal_orphan_owner_mismatch')
}
}
const currentRevision = this.getTerminalTopologyRevision(worktree.id)
const seenPtyIds = new Set<string>()
const seenPaneKeys = new Set<string>()
const validated = request.claims.map((claim) => {
const paneKey = makePaneKey(claim.tabId, claim.leafId)
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
throw new Error('terminal_orphan_claim_duplicate')
}
seenPtyIds.add(claim.ptyId)
seenPaneKeys.add(paneKey)
const live = this.getLivePtyForHandle(claim.terminal)
const pty = live?.pty
const controllerIdentity = this.controllerTerminalIdentityByPtyId.get(claim.ptyId)
if (
!pty ||
pty.ptyId !== claim.ptyId ||
controllerIdentity?.handle !== claim.terminal ||
controllerIdentity?.incarnationId !== claim.incarnationId ||
!livePtyIds.has(claim.ptyId) ||
!pty.connected ||
!pty.incarnationId ||
pty.incarnationId !== claim.incarnationId
) {
throw new Error('terminal_orphan_stale')
}
if (
!runtimeWorktreeIdsEqual(pty.worktreeId, worktree.id) ||
!terminalOrphanExecutionOwnersEqual(
{ connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro },
{
connectionId: pty.connectionId ?? null,
...(controllerIdentity?.wslDistro !== undefined
? { wslDistro: controllerIdentity.wslDistro }
: process.platform === 'win32' && !worktreeConnectionId
? {}
: { wslDistro: null })
}
)
) {
throw new Error('terminal_orphan_owner_mismatch')
}
const visualOwners = this.getLeavesForPty(claim.ptyId)
if (
visualOwners.some(
(owner) =>
!runtimeWorktreeIdsEqual(owner.worktreeId, worktree.id) ||
owner.tabId !== claim.tabId ||
owner.leafId !== claim.leafId
)
) {
throw new Error('terminal_orphan_already_visual')
}
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
throw new Error('terminal_orphan_competing_owner')
}
return { claim, pty, paneKey }
})
const persistedBindingsByPtyId = new Map<string, { worktreeId: string; paneKey: string }[]>()
const addPersistedBinding = (
ptyId: string,
binding: { worktreeId: string; paneKey: string }
): void => {
const bindings = persistedBindingsByPtyId.get(ptyId) ?? []
bindings.push(binding)
persistedBindingsByPtyId.set(ptyId, bindings)
}
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
for (const tab of tabs) {
const layout = session.terminalLayoutsByTabId[tab.id]
for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (boundPtyId) {
addPersistedBinding(boundPtyId, {
worktreeId,
paneKey: makePaneKey(tab.id, leafId)
})
}
}
if (tab.ptyId && !layout) {
addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id })
}
}
}
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
const bindings = persistedBindingsByPtyId.get(ptyId) ?? []
if (bindings.length > 1) {
throw new Error('terminal_orphan_competing_owner')
}
return bindings[0] ?? null
}
const isExactPersisted = validated.every(({ claim, paneKey }) => {
const binding = persistedBinding(claim.ptyId)
return (
binding !== null &&
runtimeWorktreeIdsEqual(binding.worktreeId, worktree.id) &&
binding.paneKey === paneKey &&
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
)
})
if (isExactPersisted && sessionWorktreeId === worktree.id) {
return {
adopted: false,
topologyRevision: currentRevision,
snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`)
}
}
if (currentRevision !== request.expectedTopologyRevision) {
throw new Error('terminal_topology_conflict')
}
const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? [])
const topologyGroups = request.topology?.groups ?? []
if (request.topology) {
const claimedLeafIdsByTabId = new Map<string, Set<string>>()
for (const { claim } of validated) {
const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set<string>()
leafIds.add(claim.leafId)
claimedLeafIdsByTabId.set(claim.tabId, leafIds)
}
if (
topologyTabsById.size !== request.topology.tabs.length ||
topologyTabsById.size !== claimedLeafIdsByTabId.size
) {
throw new Error('terminal_orphan_topology_invalid')
}
for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) {
const topologyTab = topologyTabsById.get(tabId)
if (!topologyTab) {
throw new Error('terminal_orphan_topology_invalid')
}
const topologyLeafIds = new Set<string>()
const nodes = [topologyTab.root]
let leafCount = 0
while (nodes.length > 0) {
const node = nodes.pop()!
if (node.type === 'leaf') {
leafCount += 1
topologyLeafIds.add(node.leafId)
} else {
nodes.push(node.first, node.second)
}
}
if (
leafCount !== topologyLeafIds.size ||
topologyLeafIds.size !== claimedLeafIds.size ||
[...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) ||
!topologyLeafIds.has(topologyTab.activeLeafId) ||
(topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId))
) {
throw new Error('terminal_orphan_topology_invalid')
}
}
const seenGroupIds = new Set<string>()
const groupedTabIds = new Set<string>()
for (const group of topologyGroups) {
if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) {
throw new Error('terminal_orphan_topology_invalid')
}
seenGroupIds.add(group.id)
for (const tabId of group.tabOrder) {
if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) {
throw new Error('terminal_orphan_topology_invalid')
}
groupedTabIds.add(tabId)
}
if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) {
throw new Error('terminal_orphan_topology_invalid')
}
}
if (groupedTabIds.size !== topologyTabsById.size) {
throw new Error('terminal_orphan_topology_invalid')
}
if (request.topology.groupLayout) {
if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) {
throw new Error('terminal_orphan_topology_invalid')
}
}
}
for (const { claim, paneKey } of validated) {
const existingBinding = persistedBinding(claim.ptyId)
if (
existingBinding &&
(!runtimeWorktreeIdsEqual(existingBinding.worktreeId, worktree.id) ||
existingBinding.paneKey !== paneKey)
) {
throw new Error('terminal_orphan_competing_owner')
}
const proposedPtyId =
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
throw new Error('terminal_orphan_surface_occupied')
}
const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId))
if (
graphOwner &&
(graphOwner.ptyId !== claim.ptyId ||
!runtimeWorktreeIdsEqual(graphOwner.worktreeId, worktree.id))
) {
throw new Error('terminal_orphan_surface_occupied')
}
if (
Object.entries(session.tabsByWorktree).some(
([ownerWorktreeId, tabs]) =>
!runtimeWorktreeIdsEqual(ownerWorktreeId, worktree.id) &&
tabs.some((tab) => tab.id === claim.tabId)
)
) {
throw new Error('terminal_orphan_surface_occupied')
}
if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
throw new Error('terminal_orphan_surface_retired')
}
for (const snapshot of this.mobileSessionTabsByWorktree.values()) {
const surfaceOwner = snapshot.tabs.find(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' &&
tab.parentTabId === claim.tabId &&
tab.leafId === claim.leafId
)
if (
surfaceOwner &&
(snapshot.worktree !== worktree.id || surfaceOwner.ptyId !== claim.ptyId)
) {
throw new Error('terminal_orphan_surface_occupied')
}
const owner = snapshot.tabs.find(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' && tab.ptyId === claim.ptyId
)
if (
owner &&
(snapshot.worktree !== worktree.id ||
owner.parentTabId !== claim.tabId ||
owner.leafId !== claim.leafId)
) {
throw new Error('terminal_orphan_competing_owner')
}
}
}
const next = structuredClone(session)
canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, worktree.id)
const existingTabs = next.tabsByWorktree[worktree.id] ?? []
const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab]))
for (const { claim, pty, paneKey } of validated) {
let tab = tabsById.get(claim.tabId)
if (!tab) {
const title =
getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}`
tab = {
id: claim.tabId,
ptyId: claim.ptyId,
worktreeId: worktree.id,
title,
defaultTitle: title,
customTitle: null,
color: null,
sortOrder: tabsById.size,
createdAt: Date.now(),
pendingActivationSpawn: true
}
tabsById.set(claim.tabId, tab)
}
const existingLayout = next.terminalLayoutsByTabId[claim.tabId]
const topologyTab = topologyTabsById.get(claim.tabId)
next.terminalLayoutsByTabId[claim.tabId] = topologyTab
? {
...existingLayout,
root: topologyTab.root,
activeLeafId: topologyTab.activeLeafId,
expandedLeafId: topologyTab.expandedLeafId,
ptyIdsByLeafId: {
...existingLayout?.ptyIdsByLeafId,
[claim.leafId]: claim.ptyId
}
}
: existingLayout
? {
...existingLayout,
root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId)
? existingLayout.root
: existingLayout.root === null
? { type: 'leaf', leafId: claim.leafId }
: {
type: 'split',
direction: 'vertical',
first: existingLayout.root,
second: { type: 'leaf', leafId: claim.leafId }
},
ptyIdsByLeafId: {
...existingLayout.ptyIdsByLeafId,
[claim.leafId]: claim.ptyId
}
}
: {
root: { type: 'leaf', leafId: claim.leafId },
activeLeafId: claim.leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [claim.leafId]: claim.ptyId }
}
next.terminalPtyIncarnationsByPaneKey = {
...next.terminalPtyIncarnationsByPaneKey,
[paneKey]: claim.incarnationId
}
}
const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))]
next.tabsByWorktree[worktree.id] = [...tabsById.values()]
const activeTabId =
request.activeTabId && tabsById.has(request.activeTabId)
? request.activeTabId
: (adoptedTabIds[0] ?? null)
const existingGroups = next.tabGroups?.[worktree.id] ?? []
const targetGroupId =
(request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId)
? request.activeGroupId
: existingGroups[0]?.id) ??
request.activeGroupId ??
randomUUID()
const proposedGroups = topologyGroups.map((group) => ({
...group,
worktreeId: worktree.id
}))
const groups =
existingGroups.length === 0 && proposedGroups.length > 0
? proposedGroups
: existingGroups.length > 0
? existingGroups
.map((group) => {
const proposed = proposedGroups.find((candidate) => candidate.id === group.id)
const tabOrder = proposed
? [
...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)),
...proposed.tabOrder
]
: group.id === targetGroupId && proposedGroups.length === 0
? [...new Set([...group.tabOrder, ...adoptedTabIds])]
: group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId))
return {
...group,
tabOrder,
activeTabId: proposed
? proposed.activeTabId
: group.id === targetGroupId && activeTabId
? activeTabId
: group.activeTabId && tabOrder.includes(group.activeTabId)
? group.activeTabId
: (tabOrder[0] ?? null),
...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {})
}
})
.concat(
proposedGroups.filter(
(proposed) => !existingGroups.some((group) => group.id === proposed.id)
)
)
: [{ id: targetGroupId, worktreeId: worktree.id, activeTabId, tabOrder: adoptedTabIds }]
const retainedGroups = groups.filter((group) => group.tabOrder.length > 0)
next.tabGroups = {
...next.tabGroups,
[worktree.id]: retainedGroups
}
const mergedGroupLayout = mergeTerminalOrphanGroupLayout({
existingLayout: next.tabGroupLayouts?.[worktree.id],
existingGroupIds: existingGroups.map((group) => group.id),
proposedLayout: request.topology?.groupLayout,
proposedGroupIds: proposedGroups.map((group) => group.id),
mergedGroupIds: retainedGroups.map((group) => group.id)
})
if (mergedGroupLayout) {
next.tabGroupLayouts = {
...next.tabGroupLayouts,
[worktree.id]: mergedGroupLayout
}
}
const activeGroup =
(request.activeGroupId
? retainedGroups.find(
(group) =>
group.id === request.activeGroupId &&
(!activeTabId || group.tabOrder.includes(activeTabId))
)
: undefined) ??
retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ??
retainedGroups[0]!
const convergedActiveTabId =
activeTabId && activeGroup.tabOrder.includes(activeTabId)
? activeTabId
: activeGroup.activeTabId
next.activeTabIdByWorktree = {
...next.activeTabIdByWorktree,
...(convergedActiveTabId ? { [worktree.id]: convergedActiveTabId } : {})
}
next.activeGroupIdByWorktree = {
...next.activeGroupIdByWorktree,
[worktree.id]: activeGroup.id
}
const persisted = advanceTerminalTopologyRevision(next, worktree.id)
try {
store.setWorkspaceSession(persisted)
store.flushOrThrow()
} catch (error) {
store.setWorkspaceSession(session)
throw error
}
for (const { claim, pty, paneKey } of validated) {
pty.tabId = claim.tabId
pty.paneKey = paneKey
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
force: true,
allowAttachedWindow: true,
onlyServeOwnedTerminals: true
})
this.notifyMobileSessionTabsChanged(worktree.id)
return {
adopted: true,
topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1,
snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`)
}
}
@@ -12643,7 +13166,10 @@ export class OrcaRuntimeService {
return {
type: 'group',
groupId: group.id,
activeTabId: group.activeTabId,
activeTabId:
group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId)
? group.activeTabId
: (tabs[0]?.tabId ?? null),
tabs
}
})
@@ -24394,6 +24920,7 @@ export class OrcaRuntimeService {
lastOscTitleAt: null,
managementTitle: null,
managementTitleAt: null,
controllerTitle: null,
title: state.title ?? null,
titleUpdatedAt: titleObservedAt,
lastOutputAt: state.lastOutputAt ?? null,
@@ -24519,13 +25046,56 @@ export class OrcaRuntimeService {
return null
}
const sessions = sessionsResult.value
const controllerIdentityByPtyId = new Map<
string,
{ handle: string; incarnationId: string; wslDistro?: string | null }
>()
const ptyIdByControllerHandle = new Map<string, string>()
const ambiguousControllerPtyIds = new Set<string>()
for (const session of sessions) {
const handle = session.terminalHandle?.trim()
const incarnationId = session.incarnationId?.trim()
if (!handle?.startsWith('term_') || !incarnationId) {
continue
}
const priorPtyId = ptyIdByControllerHandle.get(handle)
if (priorPtyId && priorPtyId !== session.id) {
ambiguousControllerPtyIds.add(priorPtyId)
ambiguousControllerPtyIds.add(session.id)
controllerIdentityByPtyId.delete(priorPtyId)
continue
}
if (controllerIdentityByPtyId.has(session.id)) {
ambiguousControllerPtyIds.add(session.id)
controllerIdentityByPtyId.delete(session.id)
continue
}
ptyIdByControllerHandle.set(handle, session.id)
controllerIdentityByPtyId.set(session.id, {
handle,
incarnationId,
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {})
})
}
for (const ptyId of ambiguousControllerPtyIds) {
controllerIdentityByPtyId.delete(ptyId)
}
this.controllerTerminalIdentityByPtyId = controllerIdentityByPtyId
const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings(
this.store?.getWorkspaceSession?.()
)
const persistedSurfaceByPtyId = indexPersistedPtySurfaceBindings(
this.store?.getWorkspaceSession?.()
)
const allLivePtyIds = new Set(sessions.map((session) => session.id))
const selectedLivePtyIds = new Set<string>()
for (const session of sessions) {
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
const controllerIdentity = controllerIdentityByPtyId.get(session.id)
this.adoptControllerTerminalHandle(
session.id,
controllerIdentity?.handle ?? session.terminalHandle,
controllerIdentity?.incarnationId ?? session.incarnationId
)
const persistedWorktreeId = persistedWorktreeIdByPtyId.get(session.id)
const providerWorktree = resolvedWorktrees.find(
(worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId)
@@ -24564,10 +25134,23 @@ export class OrcaRuntimeService {
continue
}
if (worktreeId) {
this.recordPtyWorktree(session.id, worktreeId, {
const persistedSurface = persistedSurfaceByPtyId.get(session.id)
const restoresExactSurface =
persistedSurface &&
session.incarnationId &&
persistedSurface.incarnationId === session.incarnationId &&
runtimeWorktreeIdsEqual(persistedSurface.worktreeId, worktreeId)
const pty = this.recordPtyWorktree(session.id, worktreeId, {
connected: true,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {})
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
...(session.wslDistro !== undefined
? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro }
: {}),
...(restoresExactSurface
? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey }
: {})
})
pty.controllerTitle = session.title?.trim() || null
}
// Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below.
this.refreshPtyForegroundAgent(session.id)
@@ -24805,9 +25388,12 @@ export class OrcaRuntimeService {
const worktree = worktreesById.get(leaf.worktreeId)
const tab = this.tabs.get(leaf.tabId) ?? null
const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined
return {
handle: this.issueHandle(leaf),
ptyId: leaf.ptyId,
incarnationId: pty?.incarnationId ?? null,
orphaned: false,
worktreeId: leaf.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
@@ -26155,14 +26741,18 @@ export class OrcaRuntimeService {
): RuntimeTerminalSummary {
const worktree = worktreesById.get(pty.worktreeId)
const pane = parsePaneKey(pty.paneKey ?? '')
const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId
return {
handle: this.issuePtyHandle(pty),
ptyId: pty.ptyId,
incarnationId: pty.incarnationId,
orphaned,
worktreeId: pty.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
tabId: `pty:${pty.ptyId}`,
leafId: `pty:${pty.ptyId}`,
tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!,
leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId,
title: getLatestPtyTitle(pty),
connected: pty.connected,
writable: pty.connected,
@@ -31425,6 +32015,59 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string {
: worktreeId
}
function resolveTerminalSessionWorktreeId(
session: WorkspaceSessionState,
targetWorktreeId: string
): string | null {
const keyedWorktreeIds = new Set([
...Object.keys(session.tabsByWorktree),
...Object.keys(session.tabGroups ?? {}),
...Object.keys(session.tabGroupLayouts ?? {}),
...Object.keys(session.activeTabIdByWorktree ?? {}),
...Object.keys(session.activeGroupIdByWorktree ?? {})
])
const matches = [...keyedWorktreeIds].filter((worktreeId) =>
runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)
)
return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId)
}
function canonicalizeTerminalSessionWorktreeId(
session: WorkspaceSessionState,
sourceWorktreeId: string,
targetWorktreeId: string
): void {
if (sourceWorktreeId === targetWorktreeId) {
return
}
const tabs = session.tabsByWorktree[sourceWorktreeId] ?? []
delete session.tabsByWorktree[sourceWorktreeId]
session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({
...tab,
worktreeId: targetWorktreeId
}))
const groups = session.tabGroups?.[sourceWorktreeId]
if (groups) {
delete session.tabGroups![sourceWorktreeId]
session.tabGroups![targetWorktreeId] = groups.map((group) => ({
...group,
worktreeId: targetWorktreeId
}))
}
for (const keyedState of [
session.tabGroupLayouts,
session.activeTabIdByWorktree,
session.activeGroupIdByWorktree
]) {
if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) {
continue
}
keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never
delete keyedState[sourceWorktreeId]
}
}
function inferWorktreeIdFromPtyId(ptyId: string): string | null {
return parsePtySessionId(ptyId).worktreeId
}
@@ -31461,6 +32104,49 @@ function indexPersistedPtyWorktreeBindings(
return worktreeIdByPtyId
}
function indexPersistedPtySurfaceBindings(
session: WorkspaceSessionState | null | undefined
): ReadonlyMap<
string,
{ worktreeId: string; tabId: string; paneKey: string; incarnationId: string }
> {
const bindingByPtyId = new Map<
string,
{ worktreeId: string; tabId: string; paneKey: string; incarnationId: string }
>()
const ambiguousPtyIds = new Set<string>()
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
for (const [leafId, ptyId] of Object.entries(
session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}
)) {
if (!ptyId || ambiguousPtyIds.has(ptyId)) {
continue
}
const paneKey = makePaneKey(tab.id, leafId)
const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey]
if (!incarnationId) {
continue
}
const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId }
const existing = bindingByPtyId.get(ptyId)
if (
existing &&
(existing.worktreeId !== worktreeId ||
existing.paneKey !== paneKey ||
existing.incarnationId !== incarnationId)
) {
bindingByPtyId.delete(ptyId)
ambiguousPtyIds.add(ptyId)
continue
}
bindingByPtyId.set(ptyId, binding)
}
}
}
return bindingByPtyId
}
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a.size !== b.size) {
return false
+2
View File
@@ -5,6 +5,7 @@ import { AUTOMATION_METHODS } from './automations'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
import { TERMINAL_METHODS } from './terminal'
import { TERMINAL_ORPHAN_METHODS } from './terminal-orphan'
import { BROWSER_CORE_METHODS } from './browser-core'
import { BROWSER_EXTRA_METHODS } from './browser-extras'
import { BROWSER_SCREENCAST_METHODS } from './browser-screencast'
@@ -49,6 +50,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...WORKTREE_METHODS,
...AGENT_SESSION_METHODS,
...TERMINAL_METHODS,
...TERMINAL_ORPHAN_METHODS,
...BROWSER_CORE_METHODS,
...BROWSER_SCREENCAST_METHODS,
...BROWSER_EXTRA_METHODS,
@@ -81,7 +81,10 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu
}
if (
node.ratio !== undefined &&
(typeof node.ratio !== 'number' || node.ratio < 0 || node.ratio > 1)
(typeof node.ratio !== 'number' ||
!Number.isFinite(node.ratio) ||
node.ratio < 0 ||
node.ratio > 1)
) {
return null
}
@@ -93,7 +96,7 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu
return value as TerminalPaneLayoutNodeInput
}
const TerminalPaneLayoutNodeSchema = z
export const TerminalPaneLayoutNodeSchema = z
.unknown()
.transform((value) => parseTerminalPaneLayoutNode(value))
.pipe(
@@ -0,0 +1,107 @@
import { z } from 'zod'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { defineMethod, type RpcAnyMethod } from '../core'
import { OptionalString, requiredString } from '../schemas'
import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas'
function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null {
const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }]
let count = 0
while (stack.length > 0) {
const current = stack.pop()!
if (
current.depth > 64 ||
++count > 1_024 ||
!current.value ||
typeof current.value !== 'object'
) {
return null
}
const node = current.value as Record<string, unknown>
if (node.type === 'leaf') {
if (
typeof node.groupId !== 'string' ||
node.groupId.length < 1 ||
node.groupId.length > 256
) {
return null
}
continue
}
if (
node.type !== 'split' ||
(node.direction !== 'horizontal' && node.direction !== 'vertical') ||
(node.ratio !== undefined &&
(typeof node.ratio !== 'number' ||
!Number.isFinite(node.ratio) ||
node.ratio < 0 ||
node.ratio > 1))
) {
return null
}
stack.push(
{ value: node.first, depth: current.depth + 1 },
{ value: node.second, depth: current.depth + 1 }
)
}
return value as TabGroupLayoutNode
}
const TerminalOrphanGroupLayout = z
.unknown()
.transform(parseOrphanGroupLayout)
.pipe(z.custom<TabGroupLayoutNode>((value) => value !== null, 'Invalid orphan group layout'))
const TerminalOrphanTopology = z.object({
tabs: z
.array(
z.object({
tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)),
root: TerminalPaneLayoutNodeSchema,
activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)),
expandedLeafId: z.string().max(128).nullable()
})
)
.min(1)
.max(64),
groups: z
.array(
z.object({
id: z.string().min(1).max(256),
activeTabId: z.string().min(1).max(256),
tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64),
recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional()
})
)
.min(1)
.max(64),
groupLayout: TerminalOrphanGroupLayout.optional()
})
const TerminalAdoptOrphans = z.object({
worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)),
expectedTopologyRevision: z.number().int().nonnegative(),
claims: z
.array(
z.object({
terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)),
ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)),
incarnationId: requiredString('Missing PTY incarnation').pipe(z.string().max(256)),
tabId: requiredString('Missing tab id').pipe(z.string().max(256)),
leafId: requiredString('Missing leaf id').pipe(z.string().max(128))
})
)
.min(1)
.max(64),
activeTabId: OptionalString.pipe(z.string().max(256).optional()),
activeGroupId: OptionalString.pipe(z.string().max(256).optional()),
topology: TerminalOrphanTopology.optional()
})
export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.adoptOrphans',
params: TerminalAdoptOrphans,
handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params)
})
]
+5
View File
@@ -831,6 +831,10 @@ const TerminalFocus = TerminalHandle.extend({
const TerminalListParams = z.object({
worktree: OptionalString,
limit: OptionalFiniteNumber,
handles: z
.array(requiredString('Missing terminal handle').pipe(z.string().max(256)))
.max(64)
.optional(),
requireFreshPtyLiveness: z.boolean().optional()
})
@@ -1103,6 +1107,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalListParams,
handler: async (params, { runtime }) =>
runtime.listTerminals(params.worktree, params.limit, {
handles: params.handles,
requireFreshPtyLiveness: params.requireFreshPtyLiveness
})
}),
+80
View File
@@ -17,6 +17,7 @@ import {
Wait
} from './methods/browser-schemas'
import { TERMINAL_METHODS } from './methods/terminal'
import { TERMINAL_ORPHAN_METHODS } from './methods/terminal-orphan'
import { WORKTREE_METHODS } from './methods/worktree'
function expectParses(schema: ZodType, value: unknown): void {
@@ -85,6 +86,85 @@ describe('RPC optional pipe schemas', () => {
expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' })
})
it('requires complete, bounded orphan adoption claims and a topology revision', () => {
const adopt = methodParams(TERMINAL_ORPHAN_METHODS, 'terminal.adoptOrphans')
const claim = {
terminal: 'term-live',
ptyId: 'pty-live',
incarnationId: 'inc-live',
tabId: 'tab-live',
leafId: 'leaf-live'
}
expectParses(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [claim],
topology: {
tabs: [
{
tabId: 'tab-live',
root: { type: 'leaf', leafId: 'leaf-live' },
activeLeafId: 'leaf-live',
expandedLeafId: null
}
],
groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }],
groupLayout: { type: 'leaf', groupId: 'group-live' }
}
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: -1,
claims: [claim]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [{ ...claim, incarnationId: '' }]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: []
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [{ ...claim, terminal: 't'.repeat(257) }]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [claim],
topology: {
tabs: [
{
tabId: 'tab-live',
root: {
type: 'split',
direction: 'horizontal',
ratio: Number.NaN,
first: { type: 'leaf', leafId: 'leaf-live' },
second: { type: 'leaf', leafId: 'leaf-other' }
},
activeLeafId: 'leaf-live',
expandedLeafId: null
}
],
groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }]
}
})
})
it('bounds targeted terminal listing used by orphan recovery', () => {
const list = methodParams(TERMINAL_METHODS, 'terminal.list')
expectParses(list, { worktree: 'id:repo::/worktree', handles: ['term-live'] })
expectRejects(list, { handles: [''] })
expectRejects(list, { handles: Array.from({ length: 65 }, (_, index) => `term-${index}`) })
})
it('accepts worktree.create payloads sent by the previous mobile protocol', () => {
const create = methodParams(WORKTREE_METHODS, 'worktree.create')
+1
View File
@@ -362,6 +362,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'terminal.ensureAgentSession',
'terminal.focus',
'terminal.agentStatus',
'terminal.adoptOrphans',
'terminal.getAutoRestoreFit',
'terminal.isRunningAgent',
'terminal.list',
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
describe('terminal orphan execution owner', () => {
it('requires exact SSH host ownership', () => {
expect(
terminalOrphanExecutionOwnersEqual(
{ connectionId: 'ssh-a', wslDistro: null },
{ connectionId: 'ssh-b', wslDistro: null }
)
).toBe(false)
expect(
terminalOrphanExecutionOwnersEqual(
{ connectionId: 'ssh-a', wslDistro: null },
{ connectionId: 'ssh-a' }
)
).toBe(true)
})
it('matches WSL distro case-insensitively but never crosses native or another distro', () => {
const expected = { connectionId: null, wslDistro: 'Ubuntu' }
expect(
terminalOrphanExecutionOwnersEqual(expected, {
connectionId: null,
wslDistro: 'ubuntu'
})
).toBe(true)
expect(
terminalOrphanExecutionOwnersEqual(expected, { connectionId: null, wslDistro: null })
).toBe(false)
expect(
terminalOrphanExecutionOwnersEqual(expected, {
connectionId: null,
wslDistro: 'Debian'
})
).toBe(false)
expect(terminalOrphanExecutionOwnersEqual(expected, { connectionId: null })).toBe(false)
})
})
+25
View File
@@ -0,0 +1,25 @@
export type TerminalOrphanExecutionOwner = {
connectionId: string | null
wslDistro?: string | null
}
function normalizeWslDistro(distro: string | null): string | null {
const normalized = distro?.trim().toLowerCase() ?? ''
return normalized || null
}
export function terminalOrphanExecutionOwnersEqual(
expected: TerminalOrphanExecutionOwner,
actual: TerminalOrphanExecutionOwner
): boolean {
if (expected.connectionId !== actual.connectionId) {
return false
}
if (expected.connectionId !== null) {
return true
}
if (expected.wslDistro === undefined || actual.wslDistro === undefined) {
return false
}
return normalizeWslDistro(expected.wslDistro) === normalizeWslDistro(actual.wslDistro)
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
hasExactTerminalOrphanGroupLayout,
mergeTerminalOrphanGroupLayout
} from './terminal-orphan-topology'
describe('terminal orphan topology', () => {
it('rejects duplicate and missing group leaves', () => {
expect(
hasExactTerminalOrphanGroupLayout(
{
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-a' }
},
new Set(['group-a', 'group-b'])
)
).toBe(false)
})
it('keeps current host layout while preserving an unrelated proposed subtree', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: { type: 'leaf', groupId: 'group-live' },
existingGroupIds: ['group-live'],
proposedLayout: {
type: 'split',
direction: 'vertical',
ratio: 0.6,
first: { type: 'leaf', groupId: 'group-old-left' },
second: { type: 'leaf', groupId: 'group-old-right' }
},
proposedGroupIds: ['group-old-left', 'group-old-right'],
mergedGroupIds: ['group-live', 'group-old-left', 'group-old-right']
})
).toEqual({
type: 'split',
direction: 'vertical',
ratio: 0.6,
first: { type: 'leaf', groupId: 'group-live' },
second: {
type: 'split',
direction: 'vertical',
ratio: 0.6,
first: { type: 'leaf', groupId: 'group-old-left' },
second: { type: 'leaf', groupId: 'group-old-right' }
}
})
})
it('grafts proposed groups beside their one current anchor without duplicating it', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', groupId: 'group-live' },
second: { type: 'leaf', groupId: 'group-other' }
},
existingGroupIds: ['group-live', 'group-other'],
proposedLayout: {
type: 'split',
direction: 'horizontal',
ratio: 0.7,
first: { type: 'leaf', groupId: 'group-live' },
second: { type: 'leaf', groupId: 'group-recovered' }
},
proposedGroupIds: ['group-live', 'group-recovered'],
mergedGroupIds: ['group-live', 'group-other', 'group-recovered']
})
).toEqual({
type: 'split',
direction: 'vertical',
first: {
type: 'split',
direction: 'horizontal',
ratio: 0.7,
first: { type: 'leaf', groupId: 'group-live' },
second: { type: 'leaf', groupId: 'group-recovered' }
},
second: { type: 'leaf', groupId: 'group-other' }
})
})
})
@@ -0,0 +1,137 @@
import type { TabGroupLayoutNode } from '../../shared/types'
function collectLayoutGroupIds(node: TabGroupLayoutNode | null | undefined, ids: string[]): void {
if (!node) {
return
}
if (node.type === 'leaf') {
ids.push(node.groupId)
return
}
collectLayoutGroupIds(node.first, ids)
collectLayoutGroupIds(node.second, ids)
}
function pruneLayout(
node: TabGroupLayoutNode | null | undefined,
retainedGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return retainedGroupIds.has(node.groupId) ? node : null
}
const first = pruneLayout(node.first, retainedGroupIds)
const second = pruneLayout(node.second, retainedGroupIds)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...node, first, second }
}
function appendMissingGroups(
layout: TabGroupLayoutNode | null,
orderedGroupIds: readonly string[]
): TabGroupLayoutNode | null {
const present: string[] = []
collectLayoutGroupIds(layout, present)
const presentSet = new Set(present)
let next = layout
for (const groupId of orderedGroupIds) {
if (presentSet.has(groupId)) {
continue
}
const leaf = { type: 'leaf' as const, groupId }
next = next
? { type: 'split', direction: 'horizontal', first: next, second: leaf, ratio: 0.5 }
: leaf
presentSet.add(groupId)
}
return next
}
function replaceLeaf(
node: TabGroupLayoutNode,
groupId: string,
replacement: TabGroupLayoutNode
): TabGroupLayoutNode {
if (node.type === 'leaf') {
return node.groupId === groupId ? replacement : node
}
return {
...node,
first: replaceLeaf(node.first, groupId, replacement),
second: replaceLeaf(node.second, groupId, replacement)
}
}
export function hasExactTerminalOrphanGroupLayout(
layout: TabGroupLayoutNode,
expectedGroupIds: ReadonlySet<string>
): boolean {
const groupIds: string[] = []
collectLayoutGroupIds(layout, groupIds)
return (
groupIds.length === expectedGroupIds.size &&
new Set(groupIds).size === groupIds.length &&
groupIds.every((groupId) => expectedGroupIds.has(groupId))
)
}
export function mergeTerminalOrphanGroupLayout(args: {
existingLayout: TabGroupLayoutNode | null | undefined
existingGroupIds: readonly string[]
proposedLayout: TabGroupLayoutNode | null | undefined
proposedGroupIds: readonly string[]
mergedGroupIds: readonly string[]
}): TabGroupLayoutNode | undefined {
const mergedGroupIdSet = new Set(args.mergedGroupIds)
const existingGroupIdSet = new Set(args.existingGroupIds)
const proposedGroupIdSet = new Set(args.proposedGroupIds)
let existing = appendMissingGroups(
pruneLayout(args.existingLayout, existingGroupIdSet),
args.existingGroupIds
)
const proposed = appendMissingGroups(
pruneLayout(args.proposedLayout, proposedGroupIdSet),
args.proposedGroupIds
)
if (!existing) {
return appendMissingGroups(proposed, args.mergedGroupIds) ?? undefined
}
if (!proposed) {
return appendMissingGroups(existing, args.mergedGroupIds) ?? undefined
}
const sharedGroupIds = args.proposedGroupIds.filter((groupId) => existingGroupIdSet.has(groupId))
const newGroupIds = new Set(
args.proposedGroupIds.filter((groupId) => !existingGroupIdSet.has(groupId))
)
if (newGroupIds.size > 0 && sharedGroupIds.length === 1) {
const anchorGroupId = sharedGroupIds[0]!
const proposalAtAnchor = pruneLayout(proposed, new Set([anchorGroupId, ...newGroupIds]))
if (proposalAtAnchor) {
existing = replaceLeaf(existing, anchorGroupId, proposalAtAnchor)
}
} else if (newGroupIds.size > 0) {
const newSubtree = pruneLayout(proposed, newGroupIds)
if (newSubtree) {
existing = {
type: 'split',
direction: proposed.type === 'split' ? proposed.direction : 'horizontal',
first: existing,
second: newSubtree,
ratio: proposed.type === 'split' ? proposed.ratio : 0.5
}
}
}
return (
appendMissingGroups(pruneLayout(existing, mergedGroupIdSet), args.mergedGroupIds) ?? undefined
)
}
+115 -62
View File
@@ -74,6 +74,7 @@ import {
} from './web-runtime-wake-terminal-respawn'
import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay'
import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events'
import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery'
import {
clearWebAgentSessionHandoff,
clearWebAgentSessionHandoffsForEnvironment,
@@ -2684,7 +2685,7 @@ export function useWebSessionTabsSync(): void {
timeoutMs: 15_000,
expectedEnvironmentPairingRevision
})
.then((response: RuntimeRpcResponse<unknown>) => {
.then(async (response: RuntimeRpcResponse<unknown>) => {
if (
disposed ||
getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision
@@ -2700,8 +2701,23 @@ export function useWebSessionTabsSync(): void {
console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload')
return
}
const recovered = await Promise.all(
result.snapshots.map((snapshot) =>
recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId
)
)
)
if (disposed) {
return
}
const applicable = recovered.filter(
(snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null
)
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId)
applyFreshWebSessionTabsSnapshots(state, applicable, environmentId)
)
})
.catch((error) => {
@@ -2745,9 +2761,24 @@ export function useWebSessionTabsSync(): void {
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
}
}
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId)
)
void Promise.all(
event.snapshots.map((snapshot) =>
recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId
)
)
).then((recovered) => {
if (!disposed) {
const applicable = recovered.filter(
(snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null
)
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, applicable, environmentId)
)
}
})
return
}
if (event.type !== 'snapshot' && event.type !== 'updated') {
@@ -2756,9 +2787,17 @@ export function useWebSessionTabsSync(): void {
if (replayed) {
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
}
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshot(state, event, environmentId)
)
void recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
event,
environmentId
).then((recovered) => {
if (!disposed && recovered) {
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshot(state, recovered, environmentId)
)
}
})
},
onError: (error) => {
console.warn('[web-session-tabs-sync] global subscription error:', error.message)
@@ -2822,6 +2861,73 @@ export function useWebSessionTabsSync(): void {
let requestedInitialTerminal = false
let requestedRespawnAfterWake = false
let unsubscribe: (() => void) | null = null
const applyActiveSnapshot = async (
event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' },
response: RuntimeRpcResponse<unknown>
): Promise<void> => {
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
event,
environmentId
)
if (disposed || !recovered) {
return
}
if (isRuntimeSubscriptionReplayResponse(response)) {
acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree)
}
const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type }
const fresh = shouldApplyWebSessionTabsSnapshot(recovered, environmentId)
const syncState = useAppStore.getState()
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
const localTerminalCount = localWorktreeTabs.length
const hasLiveLocalPty = localWorktreeTabs.some(
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
)
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
event: recoveredEvent,
activeWorktreeId,
requestedInitialTerminal,
snapshotIsFresh: fresh,
localTerminalCount
})
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
event: recoveredEvent,
activeWorktreeId,
requestedRespawnAfterWake,
snapshotIsFresh: fresh,
localTerminalCount,
hasLiveLocalPty,
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
if (fresh) {
applyWebSessionTabsStorePatch((state) =>
applyWebSessionTabsSnapshot(state, recovered, environmentId)
)
}
if (!disposed && shouldBootstrapInitialTerminal) {
requestedInitialTerminal = true
void createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true
})
} else if (
!disposed &&
shouldRespawnAfterWake &&
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
) {
requestedRespawnAfterWake = true
void createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true,
selectWorktree: false
}).finally(() => {
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
}
}
void window.api.runtimeEnvironments
.subscribe(
{
@@ -2847,60 +2953,7 @@ export function useWebSessionTabsSync(): void {
if (event.type !== 'snapshot' && event.type !== 'updated') {
return
}
if (isRuntimeSubscriptionReplayResponse(response)) {
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
}
const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId)
const syncState = useAppStore.getState()
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
const localTerminalCount = localWorktreeTabs.length
const hasLiveLocalPty = localWorktreeTabs.some(
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
)
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
event,
activeWorktreeId,
requestedInitialTerminal,
snapshotIsFresh: fresh,
localTerminalCount
})
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
event,
activeWorktreeId,
requestedRespawnAfterWake,
snapshotIsFresh: fresh,
localTerminalCount,
hasLiveLocalPty,
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
if (fresh) {
applyWebSessionTabsStorePatch((state) =>
applyWebSessionTabsSnapshot(state, event, environmentId)
)
}
if (!disposed && shouldBootstrapInitialTerminal) {
requestedInitialTerminal = true
void createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true
})
} else if (
!disposed &&
shouldRespawnAfterWake &&
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
) {
requestedRespawnAfterWake = true
// Why: recreate the terminal without changing selected worktree to avoid re-triggering activation churn.
void createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true,
selectWorktree: false
}).finally(() => {
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
}
void applyActiveSnapshot(event, response)
},
onError: (error) => {
console.warn('[web-session-tabs-sync] subscription error:', error.message)
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toRemoteRuntimePtyId } from './runtime-terminal-stream'
import {
clearWebSessionTerminalOrphanRecoveryForTests,
recoverWebSessionTerminalOrphansBeforeApply
} from './web-session-terminal-orphan-recovery'
const worktree = 'repo::/worktree'
function legacyRecoveryState() {
return {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
}
}
},
activeTabIdByWorktree: {},
activeGroupIdByWorktree: {}
}
}
const missingSnapshot = {
worktree,
publicationEpoch: 'mixed-version',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
describe('mixed-version web terminal orphan recovery', () => {
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
it.each([
{
name: 'incarnation evidence is unavailable',
result: {
terminals: [{ handle: 'term_live', ptyId: 'pty-live', worktreeId: worktree }],
totalCount: 1,
truncated: false
}
},
{
name: 'a legacy unfiltered listing truncates before the candidate',
result: {
terminals: [{ handle: 'term_other', ptyId: 'pty-other', worktreeId: worktree }],
totalCount: 101,
truncated: true
}
}
])('keeps the live candidate visible when $name', async ({ result }) => {
const call = vi.fn(async () => ({ ok: true as const, result }))
await expect(
recoverWebSessionTerminalOrphansBeforeApply(
legacyRecoveryState(),
missingSnapshot,
'windows-2',
call as never
)
).resolves.toBeNull()
expect(call).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,449 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toRemoteRuntimePtyId } from './runtime-terminal-stream'
import {
clearWebSessionTerminalOrphanRecoveryForTests,
recoverWebSessionTerminalOrphansBeforeApply
} from './web-session-terminal-orphan-recovery'
describe('web session terminal orphan recovery', () => {
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
it('keeps a missing mirror pending until exact live orphan adoption returns', async () => {
let resolveAdoption: ((value: never) => void) | null = null
const adoptedSnapshot = {
worktree: 'repo::C:\\worktree',
publicationEpoch: 'adopted',
snapshotVersion: 2,
activeGroupId: 'group-1',
activeTabId: 'host-tab::leaf-1',
activeTabType: 'terminal' as const,
tabs: [
{
type: 'terminal' as const,
id: 'host-tab::leaf-1',
parentTabId: 'host-tab',
leafId: 'leaf-1',
title: 'Claude',
isActive: true,
status: 'ready' as const,
terminal: 'term_live'
}
]
}
const call = vi.fn(async ({ method }) => {
if (method === 'terminal.list') {
return {
ok: true as const,
result: {
terminals: [
{
handle: 'term_live',
ptyId: 'native-pty',
incarnationId: 'inc-1',
orphaned: true,
worktreeId: adoptedSnapshot.worktree
}
],
topologyRevisions: { [adoptedSnapshot.worktree]: 0 },
totalCount: 1,
truncated: false
}
}
}
return await new Promise((resolve) => {
resolveAdoption = resolve as (value: never) => void
})
})
const state = {
tabsByWorktree: {
[adoptedSnapshot.worktree]: [
{ id: 'web-terminal-host-tab', worktreeId: adoptedSnapshot.worktree } as never
]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') }
}
},
activeTabIdByWorktree: { [adoptedSnapshot.worktree]: 'web-terminal-host-tab' },
activeGroupIdByWorktree: { [adoptedSnapshot.worktree]: 'group-1' },
groupsByWorktree: {
[adoptedSnapshot.worktree]: [
{
id: 'group-1',
worktreeId: adoptedSnapshot.worktree,
activeTabId: 'web-terminal-host-tab',
tabOrder: ['web-terminal-host-tab']
}
]
},
layoutByWorktree: {
[adoptedSnapshot.worktree]: { type: 'leaf' as const, groupId: 'group-1' }
}
}
const missingSnapshot = { ...adoptedSnapshot, publicationEpoch: 'missing', tabs: [] }
let settled = false
const recovery = recoverWebSessionTerminalOrphansBeforeApply(
state,
missingSnapshot,
'windows-2',
call as never
).then((result) => {
settled = true
return result
})
await vi.waitFor(() => expect(resolveAdoption).not.toBeNull())
expect(settled).toBe(false)
expect(call).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: 'terminal.list',
params: expect.objectContaining({ handles: ['term_live'] })
})
)
resolveAdoption!({
ok: true,
result: { adopted: true, topologyRevision: 1, snapshot: adoptedSnapshot }
} as never)
await expect(recovery).resolves.toEqual(adoptedSnapshot)
expect(call).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'terminal.adoptOrphans',
params: expect.objectContaining({
expectedTopologyRevision: 0,
activeTabId: 'host-tab',
claims: [
expect.objectContaining({
terminal: 'term_live',
ptyId: 'native-pty',
incarnationId: 'inc-1',
tabId: 'host-tab',
leafId: 'leaf-1'
})
],
topology: {
tabs: [
{
tabId: 'host-tab',
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null
}
],
groups: [{ id: 'group-1', activeTabId: 'host-tab', tabOrder: ['host-tab'] }],
groupLayout: { type: 'leaf', groupId: 'group-1' }
}
})
})
)
})
it('does not apply absence when an exact recoverable orphan cannot be adopted yet', async () => {
const worktree = 'repo::/worktree'
const call = vi.fn(async ({ method }) =>
method === 'terminal.list'
? {
ok: true as const,
result: {
terminals: [
{
handle: 'term_live',
ptyId: 'pty-live',
incarnationId: 'inc-live',
orphaned: true
}
],
topologyRevisions: { [worktree]: 4 },
totalCount: 1,
truncated: false
}
}
: { ok: false as const, error: { code: 'conflict', message: 'retry' } }
)
const state = {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
}
}
},
activeTabIdByWorktree: {},
activeGroupIdByWorktree: {}
}
const missing = {
worktree,
publicationEpoch: 'missing',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
await expect(
recoverWebSessionTerminalOrphansBeforeApply(state, missing, 'windows-2', call as never)
).resolves.toBeNull()
})
it('proposes pruned pane and group topology using host tab identities', async () => {
const worktree = 'repo::/worktree'
const adoptedSnapshot = {
worktree,
publicationEpoch: 'adopted',
snapshotVersion: 2,
activeGroupId: 'group-right',
activeTabId: 'shell-tab',
activeTabType: 'terminal' as const,
tabs: []
}
const call = vi.fn(async ({ method }) =>
method === 'terminal.list'
? {
ok: true as const,
result: {
terminals: [
{
handle: 'term_agent',
ptyId: 'pty-agent',
incarnationId: 'inc-agent',
orphaned: true
},
{
handle: 'term_setup',
ptyId: 'pty-setup',
incarnationId: 'inc-setup',
orphaned: true
},
{
handle: 'term_shell',
ptyId: 'pty-shell',
incarnationId: 'inc-shell',
orphaned: true
}
],
topologyRevisions: { [worktree]: 8 },
totalCount: 3,
truncated: false
}
}
: {
ok: true as const,
result: { adopted: true, topologyRevision: 9, snapshot: adoptedSnapshot }
}
)
const state = {
tabsByWorktree: {
[worktree]: [
{ id: 'web-terminal-agent-tab', worktreeId: worktree } as never,
{ id: 'web-terminal-shell-tab', worktreeId: worktree } as never
]
},
terminalLayoutsByTabId: {
'web-terminal-agent-tab': {
root: {
type: 'split' as const,
direction: 'horizontal' as const,
ratio: 0.7,
first: { type: 'leaf' as const, leafId: 'leaf-agent' },
second: { type: 'leaf' as const, leafId: 'leaf-setup' }
},
activeLeafId: 'leaf-setup',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-agent': toRemoteRuntimePtyId('term_agent', 'windows-2'),
'leaf-setup': toRemoteRuntimePtyId('term_setup', 'windows-2')
}
},
'web-terminal-shell-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-shell' },
activeLeafId: 'leaf-shell',
expandedLeafId: 'leaf-shell',
ptyIdsByLeafId: {
'leaf-shell': toRemoteRuntimePtyId('term_shell', 'windows-2')
}
}
},
activeTabIdByWorktree: { [worktree]: 'web-terminal-shell-tab' },
activeGroupIdByWorktree: { [worktree]: 'group-right' },
groupsByWorktree: {
[worktree]: [
{
id: 'group-left',
worktreeId: worktree,
activeTabId: 'web-terminal-agent-tab',
tabOrder: ['web-terminal-agent-tab']
},
{
id: 'group-right',
worktreeId: worktree,
activeTabId: 'web-terminal-shell-tab',
tabOrder: ['web-terminal-shell-tab']
}
]
},
layoutByWorktree: {
[worktree]: {
type: 'split' as const,
direction: 'vertical' as const,
ratio: 0.6,
first: { type: 'leaf' as const, groupId: 'group-left' },
second: { type: 'leaf' as const, groupId: 'group-right' }
}
}
}
await expect(
recoverWebSessionTerminalOrphansBeforeApply(
state,
{ ...adoptedSnapshot, publicationEpoch: 'missing' },
'windows-2',
call as never
)
).resolves.toEqual(adoptedSnapshot)
expect(call).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'terminal.adoptOrphans',
params: expect.objectContaining({
expectedTopologyRevision: 8,
activeTabId: 'shell-tab',
activeGroupId: 'group-right',
topology: {
tabs: [
expect.objectContaining({
tabId: 'agent-tab',
root: expect.objectContaining({
type: 'split',
direction: 'horizontal',
ratio: 0.7
}),
activeLeafId: 'leaf-setup'
}),
expect.objectContaining({
tabId: 'shell-tab',
expandedLeafId: 'leaf-shell'
})
],
groups: [
expect.objectContaining({
id: 'group-left',
activeTabId: 'agent-tab',
tabOrder: ['agent-tab']
}),
expect.objectContaining({
id: 'group-right',
activeTabId: 'shell-tab',
tabOrder: ['shell-tab']
})
],
groupLayout: expect.objectContaining({
type: 'split',
direction: 'vertical',
ratio: 0.6
})
}
})
})
)
})
it('serializes a newer convergence snapshot after an in-flight adoption conflict', async () => {
const worktree = 'repo::/worktree'
let rejectFirstAdoption: (() => void) | null = null
const call = vi.fn(async ({ method }) => {
if (method === 'terminal.list') {
return {
ok: true as const,
result: {
terminals: [
{
handle: 'term_live',
ptyId: 'pty-live',
incarnationId: 'inc-live',
orphaned: true
}
],
topologyRevisions: { [worktree]: 1 },
totalCount: 1,
truncated: false
}
}
}
return await new Promise((resolve) => {
rejectFirstAdoption = () =>
resolve({ ok: false as const, error: { code: 'conflict', message: 'closed' } })
})
})
const state = {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
}
}
},
activeTabIdByWorktree: {},
activeGroupIdByWorktree: {}
}
const missing = {
worktree,
publicationEpoch: 'missing',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
const converged = {
...missing,
publicationEpoch: 'closed',
snapshotVersion: 2,
tabs: [
{
type: 'terminal' as const,
id: 'host-tab::leaf-1',
parentTabId: 'host-tab',
leafId: 'leaf-1',
title: 'closed',
isActive: false,
status: 'pending-handle' as const,
terminal: null
}
]
}
const first = recoverWebSessionTerminalOrphansBeforeApply(
state,
missing,
'windows-2',
call as never
)
await vi.waitFor(() => expect(rejectFirstAdoption).not.toBeNull())
const second = recoverWebSessionTerminalOrphansBeforeApply(
state,
converged,
'windows-2',
call as never
)
rejectFirstAdoption!()
await expect(first).resolves.toBeNull()
await expect(second).resolves.toEqual(converged)
})
})
@@ -0,0 +1,196 @@
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalListResult,
RuntimeTerminalOrphanAdoptionResult
} from '../../../shared/runtime-types'
import type { TerminalTab } from '../../../shared/types'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
import {
buildWebTerminalOrphanTopologyProposal,
type WebTerminalOrphanTopologyState
} from './web-session-terminal-orphan-topology'
type TerminalOrphanRecoveryState = WebTerminalOrphanTopologyState & {
tabsByWorktree: Record<string, TerminalTab[]>
}
type RuntimeCall = (args: {
selector: string
method: string
params: unknown
timeoutMs: number
}) => Promise<RuntimeRpcResponse<unknown>>
const inFlightRecoveryByWorktree = new Map<string, Promise<RuntimeMobileSessionTabsResult | null>>()
function recoveryKey(environmentId: string, worktreeId: string): string {
return `${environmentId}\0${worktreeId}`
}
function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult {
return (
Boolean(value) &&
typeof value === 'object' &&
Array.isArray((value as { terminals?: unknown }).terminals)
)
}
function isAdoptionResult(value: unknown): value is RuntimeTerminalOrphanAdoptionResult {
return (
Boolean(value) &&
typeof value === 'object' &&
Boolean((value as { snapshot?: unknown }).snapshot) &&
Array.isArray((value as { snapshot?: { tabs?: unknown } }).snapshot?.tabs)
)
}
async function recoverTerminalOrphans(
state: TerminalOrphanRecoveryState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
call: RuntimeCall
): Promise<RuntimeMobileSessionTabsResult | null> {
const hostTabIds = new Set(
snapshot.tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.parentTabId)
)
const candidates = (state.tabsByWorktree[snapshot.worktree] ?? []).filter(
(tab) => isWebTerminalSurfaceTabId(tab.id) && !hostTabIds.has(toHostSessionTabId(tab.id))
)
if (candidates.length === 0) {
return snapshot
}
const candidateSurfaces = candidates.flatMap((tab) => {
const layout = state.terminalLayoutsByTabId[tab.id]
return Object.entries(layout?.ptyIdsByLeafId ?? {}).flatMap(([leafId, remotePtyId]) => {
const remote = parseRemoteRuntimePtyId(remotePtyId)
return remote?.environmentId === environmentId
? [{ tabId: toHostSessionTabId(tab.id), leafId, handle: remote.handle }]
: []
})
})
const candidateHandles = new Set(candidateSurfaces.map((surface) => surface.handle))
if (candidateHandles.size === 0) {
return snapshot
}
if (candidateHandles.size > 64) {
return null
}
const listedResponse = await call({
selector: environmentId,
method: 'terminal.list',
params: {
worktree: toRuntimeWorktreeSelector(snapshot.worktree),
handles: [...candidateHandles],
requireFreshPtyLiveness: true
},
timeoutMs: 15_000
})
if (listedResponse.ok === false || !isTerminalListResult(listedResponse.result)) {
return null
}
const listed = listedResponse.result
const orphanByHandle = new Map(
listed.terminals
.filter(
(terminal) =>
terminal.orphaned === true &&
typeof terminal.ptyId === 'string' &&
typeof terminal.incarnationId === 'string'
)
.map((terminal) => [terminal.handle, terminal])
)
const claims = candidateSurfaces.flatMap(({ tabId, leafId, handle }) => {
const orphan = orphanByHandle.get(handle)
if (!orphan?.ptyId || !orphan.incarnationId) {
return []
}
return [
{
terminal: orphan.handle,
ptyId: orphan.ptyId,
incarnationId: orphan.incarnationId,
tabId,
leafId
}
]
})
const claimedHandles = new Set(claims.map((claim) => claim.terminal))
const listedCandidateHandles = new Set(
listed.terminals
.filter((terminal) => candidateHandles.has(terminal.handle))
.map((terminal) => terminal.handle)
)
if (
listed.truncated &&
[...candidateHandles].some((handle) => !listedCandidateHandles.has(handle))
) {
return null
}
const hasUnresolvedLiveCandidate = listed.terminals.some(
(terminal) => candidateHandles.has(terminal.handle) && !claimedHandles.has(terminal.handle)
)
if (hasUnresolvedLiveCandidate) {
return null
}
if (claims.length === 0) {
return snapshot
}
const localActiveTabId = state.activeTabIdByWorktree[snapshot.worktree]
const activeTabId =
localActiveTabId && isWebTerminalSurfaceTabId(localActiveTabId)
? toHostSessionTabId(localActiveTabId)
: undefined
const activeGroupId = state.activeGroupIdByWorktree[snapshot.worktree] ?? undefined
const topology = buildWebTerminalOrphanTopologyProposal(
state,
snapshot.worktree,
candidates,
claims
)
const response = await call({
selector: environmentId,
method: 'terminal.adoptOrphans',
params: {
worktree: toRuntimeWorktreeSelector(snapshot.worktree),
expectedTopologyRevision: listed.topologyRevisions?.[snapshot.worktree] ?? 0,
claims,
...(activeTabId ? { activeTabId } : {}),
...(activeGroupId ? { activeGroupId } : {}),
...(topology ? { topology } : {})
},
timeoutMs: 15_000
})
return response.ok !== false &&
isAdoptionResult(response.result) &&
response.result.snapshot.worktree === snapshot.worktree
? response.result.snapshot
: null
}
export function recoverWebSessionTerminalOrphansBeforeApply(
state: TerminalOrphanRecoveryState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args)
): Promise<RuntimeMobileSessionTabsResult | null> {
const key = recoveryKey(environmentId, snapshot.worktree)
const existing = inFlightRecoveryByWorktree.get(key)
const recovery = (existing ?? Promise.resolve(null))
.catch(() => null)
.then(() => recoverTerminalOrphans(state, snapshot, environmentId, call))
.catch(() => null)
.finally(() => {
if (inFlightRecoveryByWorktree.get(key) === recovery) {
inFlightRecoveryByWorktree.delete(key)
}
})
inFlightRecoveryByWorktree.set(key, recovery)
return recovery
}
export function clearWebSessionTerminalOrphanRecoveryForTests(): void {
inFlightRecoveryByWorktree.clear()
}
@@ -0,0 +1,140 @@
import type { RuntimeTerminalOrphanTopology } from '../../../shared/runtime-types'
import type {
TabGroup,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalTab
} from '../../../shared/types'
import { toHostSessionTabId } from './web-terminal-surface-id'
export type WebTerminalOrphanTopologyState = {
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
activeTabIdByWorktree: Record<string, string | null | undefined>
activeGroupIdByWorktree: Record<string, string | null | undefined>
groupsByWorktree?: Record<string, TabGroup[] | undefined>
layoutByWorktree?: Record<string, TabGroupLayoutNode | undefined>
}
function prunePaneLayout(
node: TerminalPaneLayoutNode | null,
retainedLeafIds: ReadonlySet<string>
): TerminalPaneLayoutNode | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return retainedLeafIds.has(node.leafId) ? node : null
}
const first = prunePaneLayout(node.first, retainedLeafIds)
const second = prunePaneLayout(node.second, retainedLeafIds)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...node, first, second }
}
function pruneGroupLayout(
node: TabGroupLayoutNode | undefined,
retainedGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | undefined {
if (!node) {
return undefined
}
if (node.type === 'leaf') {
return retainedGroupIds.has(node.groupId) ? node : undefined
}
const first = pruneGroupLayout(node.first, retainedGroupIds)
const second = pruneGroupLayout(node.second, retainedGroupIds)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...node, first, second }
}
export function buildWebTerminalOrphanTopologyProposal(
state: WebTerminalOrphanTopologyState,
worktreeId: string,
candidates: readonly TerminalTab[],
claims: readonly { tabId: string; leafId: string }[]
): RuntimeTerminalOrphanTopology | undefined {
const leafIdsByTabId = new Map<string, Set<string>>()
for (const claim of claims) {
const leafIds = leafIdsByTabId.get(claim.tabId) ?? new Set<string>()
leafIds.add(claim.leafId)
leafIdsByTabId.set(claim.tabId, leafIds)
}
const hostTabIdByLocalId = new Map(
candidates.map((tab) => [tab.id, toHostSessionTabId(tab.id)] as const)
)
const tabs = candidates.flatMap((tab) => {
const tabId = hostTabIdByLocalId.get(tab.id)!
const retainedLeafIds = leafIdsByTabId.get(tabId)
const layout = state.terminalLayoutsByTabId[tab.id]
const root = retainedLeafIds ? prunePaneLayout(layout?.root ?? null, retainedLeafIds) : null
if (!layout || !root || !retainedLeafIds || retainedLeafIds.size === 0) {
return []
}
const fallbackLeafId = [...retainedLeafIds][0]!
return [
{
tabId,
root,
activeLeafId: retainedLeafIds.has(layout.activeLeafId ?? '')
? layout.activeLeafId!
: fallbackLeafId,
expandedLeafId:
layout.expandedLeafId && retainedLeafIds.has(layout.expandedLeafId)
? layout.expandedLeafId
: null
}
]
})
if (tabs.length !== leafIdsByTabId.size) {
return undefined
}
const adoptedTabIds = new Set(tabs.map((tab) => tab.tabId))
const groups = (state.groupsByWorktree?.[worktreeId] ?? []).flatMap((group) => {
const tabOrder = group.tabOrder
.map((tabId) => hostTabIdByLocalId.get(tabId))
.filter((tabId): tabId is string => Boolean(tabId && adoptedTabIds.has(tabId)))
if (tabOrder.length === 0) {
return []
}
const requestedActive = group.activeTabId
? hostTabIdByLocalId.get(group.activeTabId)
: undefined
const recentTabIds = group.recentTabIds
?.map((tabId) => hostTabIdByLocalId.get(tabId))
.filter((tabId): tabId is string => Boolean(tabId && tabOrder.includes(tabId)))
return [
{
id: group.id,
activeTabId:
requestedActive && tabOrder.includes(requestedActive) ? requestedActive : tabOrder[0]!,
tabOrder,
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
}
]
})
const completeGroups =
groups.length > 0
? groups
: [
{
id: state.activeGroupIdByWorktree[worktreeId] ?? 'recovered-orphans',
activeTabId: tabs[0]!.tabId,
tabOrder: tabs.map((tab) => tab.tabId)
}
]
const groupIds = new Set(completeGroups.map((group) => group.id))
const groupLayout = pruneGroupLayout(state.layoutByWorktree?.[worktreeId], groupIds)
return { tabs, groups: completeGroups, ...(groupLayout ? { groupLayout } : {}) }
}
+46
View File
@@ -417,6 +417,8 @@ export type RuntimeFileReadChunkResult = {
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
incarnationId?: string | null
orphaned?: boolean
worktreeId: string
worktreePath: string
branch: string
@@ -480,10 +482,54 @@ export type RuntimeTerminalVisualLayout = {
export type RuntimeTerminalListResult = {
terminals: RuntimeTerminalSummary[]
visualLayouts?: RuntimeTerminalVisualLayout[]
topologyRevisions?: Record<string, number>
totalCount: number
truncated: boolean
}
export type RuntimeTerminalOrphanAdoptionClaim = {
terminal: string
ptyId: string
incarnationId: string
tabId: string
leafId: string
}
export type RuntimeTerminalOrphanTopologyTab = {
tabId: string
root: TerminalPaneLayoutNode
activeLeafId: string
expandedLeafId: string | null
}
export type RuntimeTerminalOrphanTopologyGroup = {
id: string
activeTabId: string
tabOrder: string[]
recentTabIds?: string[]
}
export type RuntimeTerminalOrphanTopology = {
tabs: RuntimeTerminalOrphanTopologyTab[]
groups: RuntimeTerminalOrphanTopologyGroup[]
groupLayout?: TabGroupLayoutNode
}
export type RuntimeTerminalOrphanAdoptionRequest = {
worktree: string
expectedTopologyRevision: number
claims: RuntimeTerminalOrphanAdoptionClaim[]
activeTabId?: string
activeGroupId?: string
topology?: RuntimeTerminalOrphanTopology
}
export type RuntimeTerminalOrphanAdoptionResult = {
adopted: boolean
topologyRevision: number
snapshot: RuntimeMobileSessionTabsResult
}
export type RuntimeWorktreeTerminalSleepFailure =
| 'terminal_liveness_unavailable'
| 'terminal_worktree_sleep_still_live'