Files
orca/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
T
Neil 2aed2cf64a fix(terminal): reveal splits while the source pane binds (#23692)
* test(e2e): observe passive terminal restoration before activation

* fix(terminal): reveal persisted splits during source binding publication

* test(terminal): handle nullable persisted layout roots
2026-09-28 16:34:36 -07:00

198 lines
7.2 KiB
TypeScript

// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithSplitPtyBackedTerminal } from './orca-runtime-split-pty-backed-terminal'
import {
resolveTerminalSessionWorktreeId,
runtimeWorktreeIdsEqual
} from './runtime-worktree-path-identity'
import { makePaneKey } from '../../shared/stable-pane-id'
import { terminalLayoutContainsLeaf } from './headless-terminal-split-layout'
import type {
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse
} from './claude-agent-teams-service'
import {
ensureClaudeAgentTeamsShimDir,
resolveClaudeAgentTeamsShimBin
} from './claude-agent-teams-shim-env'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPtyBackedTerminal {
protected resolveTerminalSplitSourceAuthority(
worktreeId: string,
tabId: string,
leafId: string,
ptyId: string
): {
persisted: boolean
rendererMounted: boolean
persistedWorktreeId: string | null
persistedIncarnationId: string | null
liveIncarnationId: string | null
} | null {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null
const persistedTab = sessionWorktreeId
? session?.tabsByWorktree[sessionWorktreeId]?.find(
(tab) => tab.id === tabId && runtimeWorktreeIdsEqual(tab.worktreeId, worktreeId)
)
: undefined
const persistedLayout = session?.terminalLayoutsByTabId?.[tabId]
const persistedIncarnationId =
session?.terminalPtyIncarnationsByPaneKey?.[makePaneKey(tabId, leafId)] ?? null
const liveIncarnationId = this.ptysById.get(ptyId)?.incarnationId ?? null
if (
persistedIncarnationId &&
liveIncarnationId &&
persistedIncarnationId !== liveIncarnationId
) {
return null
}
const persisted = Boolean(
persistedTab &&
persistedLayout?.ptyIdsByLeafId?.[leafId] === ptyId &&
terminalLayoutContainsLeaf(persistedLayout.root, leafId)
)
const rendererTab = this.tabs.get(tabId)
const rendererLeaf = this.leaves.get(this.getLeafKey(tabId, leafId))
// A mounted pane can publish before its PTY binds; persisted identity fences that gap.
const rendererMounted = Boolean(
rendererTab &&
rendererLeaf &&
runtimeWorktreeIdsEqual(rendererTab.worktreeId, worktreeId) &&
runtimeWorktreeIdsEqual(rendererLeaf.worktreeId, worktreeId) &&
(rendererLeaf.ptyId === ptyId || (persisted && rendererLeaf.ptyId === null))
)
if (persisted && persistedLayout) {
return {
persisted: true,
rendererMounted,
persistedWorktreeId: sessionWorktreeId,
persistedIncarnationId,
liveIncarnationId
}
}
// Why: renderer adoption can precede graph sync; this path still requires reveal success before commit.
const projected = [...this.mobileSessionTabsByWorktree.entries()].some(
([candidateWorktreeId, snapshot]) =>
runtimeWorktreeIdsEqual(candidateWorktreeId, worktreeId) &&
snapshot.tabs.some(
(tab) =>
tab.type === 'terminal' &&
tab.parentTabId === tabId &&
tab.leafId === leafId &&
(tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[leafId] === ptyId)
)
)
if (!rendererMounted && !projected) {
return null
}
return {
persisted: false,
rendererMounted,
persistedWorktreeId: null,
persistedIncarnationId: null,
liveIncarnationId
}
}
async handleAgentTeamsTmuxCompat(
request: AgentTeamsTmuxCompatRequest
): Promise<AgentTeamsTmuxCompatResponse> {
return await this.claudeAgentTeams.handleTmuxCompat(request, {
splitTerminal: (handle, opts) => this.splitTerminal(handle, opts),
readTerminal: (handle, opts) => this.readTerminal(handle, opts),
sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options),
focusTerminal: (handle) => this.focusTerminal(handle),
closeTerminal: (handle) => this.closeTerminal(handle),
showTerminal: (handle) => this.showTerminal(handle)
})
}
async prepareClaudeAgentTeamsLeader(args: {
paneKey: string
baseEnv?: Record<string, string>
prepareAuth?: boolean
}): Promise<{ env: Record<string, string> }> {
const handle = this.getTerminalHandleForPaneKey(args.paneKey)
if (!handle) {
throw new Error('claude_agent_teams_requires_orca_terminal')
}
return await this.prepareClaudeAgentTeamsLeaderForHandle({
handle,
baseEnv: args.baseEnv,
prepareAuth: args.prepareAuth
})
}
async prepareClaudeAgentTeamsLeaderForHandle(args: {
handle: string
baseEnv?: Record<string, string>
prepareAuth?: boolean
}): Promise<{ env: Record<string, string>; envToDelete?: string[] }> {
const baseEnv = {
...process.env,
...args.baseEnv
}
const inheritedEnvKeys = new Set(Object.keys(baseEnv))
const auth = args.prepareAuth && this.prepareClaudeAuth ? await this.prepareClaudeAuth() : null
if (auth) {
applyClaudeEnvPatch(baseEnv, auth.envPatch, { stripAuthEnv: auth.stripAuthEnv })
}
const envToDelete = auth?.stripAuthEnv
? [...inheritedEnvKeys].filter((key) => !(key in baseEnv))
: undefined
const shimDir = await ensureClaudeAgentTeamsShimDir()
const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv)
const launch = this.claudeAgentTeams.createLaunchEnv({
leaderHandle: args.handle,
baseEnv,
shimDir,
shimBin
})
const env = auth ? { ...auth.envPatch, ...launch.env } : launch.env
return envToDelete ? { env, envToDelete } : { env }
}
// Why: a leader handle that never binds to a PTY (lost pane race) has no exit
// or close path to evict its team, so the abandoning caller must release it.
releaseClaudeAgentTeamsLeaderForHandle(handle: string): void {
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
}
protected waitForLeafInTab(tabId: string, leafId: string, timeoutMs = 10_000): Promise<string> {
const tryResolve = (): string | null => {
const leaf = this.leaves.get(this.getLeafKey(tabId, leafId))
return leaf?.ptyId !== null && leaf?.ptyId !== undefined ? this.issueHandle(leaf) : null
}
const existing = tryResolve()
if (existing) {
return Promise.resolve(existing)
}
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
reject(new Error('Timed out waiting for split pane handle'))
}, timeoutMs)
const check = (): void => {
const handle = tryResolve()
if (handle) {
clearTimeout(timer)
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
resolve(handle)
}
}
this.graphSyncCallbacks.push(check)
check()
})
}
}