mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
* test(e2e): observe passive terminal restoration before activation * fix(terminal): reveal persisted splits during source binding publication * test(terminal): handle nullable persisted layout roots
198 lines
7.2 KiB
TypeScript
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()
|
|
})
|
|
}
|
|
}
|