Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/scripts/vitest-node-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/automations/automation-worker-durability.test.ts',
'src/**/*.node-pty.test.ts',
'src/**/node-pty-*.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/**/*.live-shell.test.ts',
'src/**/*real-pty*.test.ts',
'src/**/*-native.test.ts',
Expand Down
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/claude-structured-session-integration.test.ts',
'src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts',
'src/main/runtime/exit-provenance-audit.test.ts',
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
'src/main/runtime/orchestration-cli-subprocess.test.ts',
'src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts',
'src/main/runtime/orchestration-mailbox-crash-recovery.test.ts',
Expand Down
51 changes: 42 additions & 9 deletions src/main/runtime/orca-runtime-sync-window-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import type {
} from '../../shared/runtime-types'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
import {
collectRendererPublishedEmptyTerminalPanes,
createRuntimeOwnedPtyResolver,
chooseProjectedPtyId,
makeRuntimePaneIdentity,
shouldPreservePublishedRuntimePane
} from './runtime-owned-terminal-projection'

/** The runtime indexes graph tabs by bare id, so duplicate ids cannot be routed safely. */
function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void {
Expand Down Expand Up @@ -98,6 +105,17 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow
// Why: renderer reloads can briefly republish the same leaf with no ptyId;
// keep live CLI handles usable while the UI graph rebuilds.
const preserveLivePtysDuringReload = this.graphStatus === 'reloading'
const incomingPtyIds = new Set(lifecycleLeaves.map((leaf) => leaf.ptyId))
const published = collectRendererPublishedEmptyTerminalPanes(
graph,
this.mobileSessionTabsByWorktree,
this.acceptedRendererMobileSnapshotByWorktree
)
const retainedRuntimePtyId = createRuntimeOwnedPtyResolver(
this.ptysById.values(),
(ptyId) => this.getPtyLivenessVerdict(ptyId)?.status === 'exited',
incomingPtyIds
)
for (const leaf of lifecycleLeaves) {
if (leaf.ptyId) {
if (leaf.parked) {
Expand All @@ -108,10 +126,13 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow
}
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
const existing = this.leaves.get(leafKey)
const ptyId =
preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId
? existing.ptyId
: leaf.ptyId
const ptyId = chooseProjectedPtyId(
leaf.ptyId,
existing?.worktreeId === leaf.worktreeId ? existing.ptyId : null,
preserveLivePtysDuringReload,
makeRuntimePaneIdentity(leaf, this.makeRuntimePaneKey(leaf)),
retainedRuntimePtyId
)
const ptyGeneration =
existing && existing.ptyId !== ptyId
? existing.ptyGeneration + 1
Expand Down Expand Up @@ -190,13 +211,25 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow
const retainedIncarnation = oldLeaf?.ptyId
? this.handleByPtyIncarnation.get(oldLeaf.ptyId)
: undefined
// Inactive panes may lack transport leaves while the renderer still publishes their exact surface.
const preservePublishedRuntimePane =
oldLeaf?.ptyId !== null &&
oldLeaf?.ptyId !== undefined &&
shouldPreservePublishedRuntimePane(
makeRuntimePaneIdentity(oldLeaf, this.makeRuntimePaneKey(oldLeaf)),
oldLeaf.ptyId,
published.emptyPaneWorktrees.get(oldLeafKey),
published.boundPtyIds,
retainedRuntimePtyId
)
if (
preserveLivePtysDuringReload &&
oldLeaf?.ptyId &&
(this.handleByPtyId.has(oldLeaf.ptyId) ||
(retainedIncarnation &&
retainedIncarnation.incarnationId ===
this.ptysById.get(oldLeaf.ptyId)?.incarnationId)) &&
(preservePublishedRuntimePane ||
(preserveLivePtysDuringReload &&
(this.handleByPtyId.has(oldLeaf.ptyId) ||
(retainedIncarnation &&
retainedIncarnation.incarnationId ===
this.ptysById.get(oldLeaf.ptyId)?.incarnationId)))) &&
!nextPtyIds.has(oldLeaf.ptyId)
) {
// Why: a CLI-created agent keeps using its exported handle even if
Expand Down
121 changes: 121 additions & 0 deletions src/main/runtime/runtime-owned-terminal-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type {
RuntimeMobileSessionTabsSnapshot,
RuntimeSyncWindowGraph
} from '../../shared/runtime-types'
import { isTerminalLeafId } from '../../shared/stable-pane-id'

/** The surface a retention decision is about; all three must match for the host to keep its PTY. */
export type RuntimePaneIdentity = {
paneKey: string
worktreeId: string
tabId: string
}

export function makeRuntimePaneIdentity(
leaf: { worktreeId: string; tabId: string },
paneKey: string
): RuntimePaneIdentity {
return { paneKey, worktreeId: leaf.worktreeId, tabId: leaf.tabId }
}

/** Resolves the host-owned PTY a pane may keep, or null when nothing may be retained. */
export type RuntimeOwnedPtyResolver = (
pane: RuntimePaneIdentity,
currentPtyId: string | null
) => string | null

export function collectRendererPublishedEmptyTerminalPanes(
graph: Pick<RuntimeSyncWindowGraph, 'mobileSessionTabs' | 'unchangedMobileSessionWorktrees'>,
snapshots: ReadonlyMap<string, RuntimeMobileSessionTabsSnapshot>,
acceptedSnapshots: ReadonlyMap<string, { rendererTabIdentityKeys: ReadonlySet<string> }>
): { emptyPaneWorktrees: Map<string, string>; boundPtyIds: Set<string> } {
const worktrees = new Set([
...(graph.mobileSessionTabs?.map((snapshot) => snapshot.worktree) ?? []),
...(graph.unchangedMobileSessionWorktrees ?? [])
])
const emptyPaneWorktrees = new Map<string, string>()
const boundPtyIds = new Set<string>()
for (const worktreeId of worktrees) {
const accepted = acceptedSnapshots.get(worktreeId)
for (const tab of snapshots.get(worktreeId)?.tabs ?? []) {
// Host-preserved tabs cannot stand in for a pane the renderer actually published.
if (
tab.type !== 'terminal' ||
!accepted?.rendererTabIdentityKeys.has(`${tab.parentTabId}::${tab.leafId}`)
) {
continue
}
if (tab.ptyId) {
boundPtyIds.add(tab.ptyId)
} else if (isTerminalLeafId(tab.leafId)) {
emptyPaneWorktrees.set(`${tab.parentTabId}::${tab.leafId}`, worktreeId)
}
}
}
return { emptyPaneWorktrees, boundPtyIds }
}

function indexRuntimeOwnedPanePtys(
ptys: Iterable<RuntimePtyWorktreeRecord>,
isExited: (ptyId: string) => boolean
): Map<string, RuntimePtyWorktreeRecord | null> {
const owners = new Map<string, RuntimePtyWorktreeRecord | null>()
for (const pty of ptys) {
if (!pty.runtimeSessionOwned || !pty.paneKey || isExited(pty.ptyId)) {
continue
}
// Ambiguous host ownership cannot authorize choosing either process.
owners.set(pty.paneKey, owners.has(pty.paneKey) ? null : pty)
}
return owners
}

export function createRuntimeOwnedPtyResolver(
ptys: Iterable<RuntimePtyWorktreeRecord>,
isExited: (ptyId: string) => boolean,
incomingPtyIds: ReadonlySet<string>
): RuntimeOwnedPtyResolver {
const hostOwnedPtys = indexRuntimeOwnedPanePtys(ptys, isExited)
return (pane, currentPtyId) => {
const pty = hostOwnedPtys.get(pane.paneKey)
// A pane claimed elsewhere in this graph, or already bound to a different PTY, is not retainable.
return pty?.worktreeId === pane.worktreeId &&
pty.tabId === pane.tabId &&
(!currentPtyId || currentPtyId === pty.ptyId) &&
!incomingPtyIds.has(pty.ptyId)
? pty.ptyId
: null
}
}

export function chooseProjectedPtyId(
incomingPtyId: string | null,
existingPtyId: string | null | undefined,
preserveReload: boolean,
pane: RuntimePaneIdentity,
resolver: RuntimeOwnedPtyResolver
): string | null {
if (incomingPtyId) {
return incomingPtyId
}
const owned = resolver(pane, null)
if (owned && (!existingPtyId || existingPtyId === owned)) {
return owned
}
return preserveReload ? (existingPtyId ?? null) : null
}

export function shouldPreservePublishedRuntimePane(
pane: RuntimePaneIdentity,
ptyId: string,
publishedWorktree: string | undefined,
publishedPtyIds: ReadonlySet<string>,
resolver: RuntimeOwnedPtyResolver
): boolean {
return (
publishedWorktree === pane.worktreeId &&
!publishedPtyIds.has(ptyId) &&
resolver(pane, ptyId) !== null
)
}
Loading
Loading