Files
orca/src/main/runtime/orca-runtime-sync-window-graph.ts
T
Neil 5fcff6052c fix(runtime): name a terminal whose pane the graph dropped
`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.
2026-09-15 02:29:56 -07:00

322 lines
14 KiB
TypeScript

/* eslint-disable unicorn/no-useless-spread */
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithAttachWindow } from './orca-runtime-attach-window'
import type {
RuntimeRendererSyncWindowGraph,
RuntimeSyncedTab,
RuntimeSyncWindowGraph,
RuntimeSyncWindowGraphResult
} from '../../shared/runtime-types'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
/** The runtime indexes graph tabs by bare id, so duplicate ids cannot be routed safely. */
function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void {
const seen = new Set<string>()
for (const tab of tabs) {
if (seen.has(tab.tabId)) {
throw new Error('duplicate_runtime_tab_id')
}
seen.add(tab.tabId)
}
}
export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow {
shouldRelayTerminalBrowserOpens(): boolean {
return this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID
}
syncWindowGraph(
windowId: number,
graph: RuntimeSyncWindowGraph | RuntimeRendererSyncWindowGraph
): RuntimeSyncWindowGraphResult {
// `tabs` and several downstream indexes are keyed only by tab id. Reject
// malformed persisted/mirrored graphs before authority or graph state is
// changed; choosing a winner would route PTYs to the wrong worktree.
assertUniqueRuntimeGraphTabIds(graph.tabs)
if (
windowId !== HEADLESS_RUNTIME_WINDOW_ID &&
this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID &&
this.headlessGraphFallbackAvailable
) {
if (windowId !== this.pendingHeadlessPromotionWindowId) {
throw new Error('Runtime graph publisher does not match the pending desktop promotion')
}
// Why: a renderer may publish after a failed promotion was restored to
// headless authority; accepting that late healthy graph is self-healing.
this.attachWindow(windowId)
}
if (this.authoritativeWindowId === null) {
this.authoritativeWindowId = windowId
}
if (windowId !== this.authoritativeWindowId) {
throw new Error('Runtime graph publisher does not match the authoritative window')
}
const rendererGeneration =
windowId === HEADLESS_RUNTIME_WINDOW_ID
? null
: 'rendererGeneration' in graph && typeof graph.rendererGeneration === 'string'
? graph.rendererGeneration
: undefined
if (
typeof rendererGeneration === 'string' &&
rendererGeneration === this.rendererGeneration &&
this.graphStatus !== 'ready'
) {
throw new Error('Runtime graph publisher belongs to a superseded renderer generation')
}
if (windowId === HEADLESS_RUNTIME_WINDOW_ID) {
this.headlessGraphFallbackAvailable = true
this.rendererGeneration = null
}
const graphWasReady = this.graphStatus === 'ready'
const previousTabs = this.tabs
const previousLeaves = this.leaves
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves)
const mobileSessionResyncWorktrees = new Set<string>()
const changedMobileWorktrees = this.syncMobileSessionTabs(
graph.mobileSessionTabs,
graph.unchangedMobileSessionWorktrees,
mobileSessionResyncWorktrees,
rendererGeneration
)
const nextLeaves = new Map<string, RuntimeLeafRecord>()
const graphSyncedAt = this.nextTitleObservationSequence()
// Bumped before the leaf loop so surfaces this statement records are stamped with it, and a
// surface recorded after it is immune until the next one (pty-recorded-surface-topology.ts).
this.graphSequence += 1
// 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'
for (const leaf of lifecycleLeaves) {
if (leaf.ptyId) {
if (leaf.parked) {
this.orchestrationMailboxPointerDelivery.markPtyColdParked(leaf.ptyId)
} else {
this.orchestrationMailboxPointerDelivery.clearPtyColdParked(leaf.ptyId)
}
}
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 ptyGeneration =
existing && existing.ptyId !== ptyId
? existing.ptyGeneration + 1
: (existing?.ptyGeneration ?? 0)
const existingPty = ptyId ? this.ptysById.get(ptyId) : undefined
const tailSource = existing?.ptyId === ptyId ? existing : existingPty
nextLeaves.set(leafKey, {
...leaf,
ptyId,
ptyGeneration,
connected: ptyId !== null,
writable: this.graphStatus === 'ready' && ptyId !== null,
lastOutputAt: tailSource?.lastOutputAt ?? null,
lastExitCode: tailSource?.lastExitCode ?? null,
lastExitCause: tailSource?.lastExitCause ?? null,
tailBuffer: tailSource?.tailBuffer ?? [],
tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [],
tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0,
tailPartialLine: tailSource?.tailPartialLine ?? '',
tailPendingAnsi: tailSource?.tailPendingAnsi ?? '',
tailRedrawCursor: tailSource?.tailRedrawCursor ?? null,
tailTruncated: tailSource?.tailTruncated ?? false,
tailLinesTotal: tailSource?.tailLinesTotal ?? 0,
preview: tailSource?.preview ?? '',
waitBlockedAt: tailSource?.waitBlockedAt ?? null,
lastAgentStatus: tailSource?.lastAgentStatus ?? null,
lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false,
lastOscTitle: tailSource?.lastOscTitle ?? null,
lastOscTitleAt: tailSource?.lastOscTitleAt ?? null,
paneTitleUpdatedAt:
existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle
? existing.paneTitleUpdatedAt
: graphSyncedAt
})
if (leaf.ptyId) {
this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, {
connected: true,
lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null,
preview: existing?.ptyId === leaf.ptyId ? existing.preview : '',
tabId: leaf.tabId,
paneKey: this.makeRuntimePaneKey(leaf)
})
}
if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) {
// Why: mobile can subscribe while the pane is waiting for its first PTY.
// Keep that handle usable after the recovery mount binds it.
const adoptedFirstPty =
existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration)
if (!adoptedFirstPty) {
this.invalidateLeafHandle(leafKey)
}
}
}
// Why: computed BEFORE preserving stale leaves so preservation can refuse a
// leaf whose PTY the incoming graph already rebound to a live leaf. Two
// leaves on one PTY resolve to the same handle (handles are ptyId-keyed) and
// crash paired clients with a duplicate React key.
const nextPtyIds = new Set(
[...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId)
)
for (const oldLeafKey of this.leaves.keys()) {
if (!nextLeaves.has(oldLeafKey)) {
const oldLeaf = this.leaves.get(oldLeafKey)
if (oldLeaf?.ptyId && !nextPtyIds.has(oldLeaf.ptyId)) {
// A cold-parked PTY remains alive without a graph leaf; hold its
// staged Enter until a live idle frame authorizes submission.
this.orchestrationMailboxPointerDelivery.markPtyColdParked(oldLeaf.ptyId)
}
const retainedIncarnation = oldLeaf?.ptyId
? this.handleByPtyIncarnation.get(oldLeaf.ptyId)
: undefined
if (
preserveLivePtysDuringReload &&
oldLeaf?.ptyId &&
(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
// the reloaded renderer has not rebound the pane yet.
nextLeaves.set(oldLeafKey, oldLeaf)
nextPtyIds.add(oldLeaf.ptyId)
} else if (oldLeaf?.ptyId && nextPtyIds.has(oldLeaf.ptyId)) {
// Why: the incoming graph already rebound this PTY to a live leaf (e.g.
// a woken agent re-keyed to a new leaf during renderer reload). Keeping
// the old leaf too would put two leaves on ONE PTY, which emit the same
// terminal handle and crash paired clients. Drop the stale leaf; if its
// handle is the shared ptyId-keyed one it belongs to the live leaf now,
// so release only this dead leaf key's alias. A leaf-unique handle has
// no next owner — invalidate it so in-flight CLI waiters fail fast
// instead of hanging on a dead leaf.
const oldHandle = this.handleByLeafKey.get(oldLeafKey)
const incarnationHandle = retainedIncarnation?.handle
if (
oldHandle !== undefined &&
(oldHandle === this.handleByPtyId.get(oldLeaf.ptyId) || oldHandle === incarnationHandle)
) {
this.handleByLeafKey.delete(oldLeafKey)
} else {
this.invalidateLeafHandle(oldLeafKey)
}
} else {
this.invalidateLeafHandle(oldLeafKey)
}
}
}
for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) {
if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) {
this.detachedPreAllocatedLeaves.delete(ptyId)
continue
}
nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf)
nextPtyIds.add(ptyId)
}
this.leaves = nextLeaves
this.rebuildLeafPtyIndex()
this.reconcilePtyIncarnationHandles()
// Why: the emitted client payload is a function of the stored snapshot AND
// the tab/leaf graph (handles/titles/connected resolve from leaf state), so
// a graph-only change — e.g. a restored leaf binding its ptyId while the
// snapshot pair is unchanged — must also fan out, or a paired client stays
// on pending-handle forever. Schedule the union on the same 50ms trailing
// edge as the OSC-title path; the coalescer emit reads the latest state at
// fire time so no final version is ever lost.
for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees(
previousTabs,
previousLeaves
)) {
if (changedMobileWorktrees.has(worktreeId)) {
continue
}
const stored = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!stored) {
continue
}
// Why: web clients drop same-epoch frames whose version isn't strictly
// newer, so a graph-only change must mint a fresh stored version (like
// the PTY touch path does) or the re-emitted payload — e.g. the
// pending-handle → ready flip — is discarded and the client stays stale.
// The accepted-renderer tracking is untouched: this is a main-local bump.
this.storeMobileSessionSnapshot(worktreeId, {
...stored,
snapshotVersion: stored.snapshotVersion + 1
})
changedMobileWorktrees.add(worktreeId)
}
for (const worktreeId of changedMobileWorktrees) {
if (this.mobileSessionTabsByWorktree.has(worktreeId)) {
this.scheduleMobileSessionTabsChanged(worktreeId)
}
}
const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId
this.markGraphReady(windowId)
if (
isAuthoritativeGraphPublisher &&
(windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined)
) {
if (mobileSessionResyncWorktrees.size === 0) {
this.markSessionTabsInventoryPublished()
} else {
this.sessionTabsInventoryPublicationEpoch = null
}
}
if (rendererGeneration !== undefined) {
this.rendererGeneration = rendererGeneration
}
for (const leaf of this.leaves.values()) {
this.adoptPreAllocatedHandle(leaf)
const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (
this._orchestrationDb &&
leaf.lastAgentStatus === 'idle' &&
leaf.lastAgentStatusObservedLive &&
this.checkDeliverySettledAndArmRecheck(leaf) &&
leaf.writable &&
(!graphWasReady ||
previousLeaf?.ptyId !== leaf.ptyId ||
!previousLeaf.writable ||
previousLeaf.lastAgentStatus !== 'idle' ||
!previousLeaf.lastAgentStatusObservedLive)
) {
this.deliverPendingMessagesForLeaf(leaf)
}
}
// Why: createTerminal waits for the renderer's graph sync to populate the
// new leaf so it can return a handle. Drain callbacks after leaves update.
for (const cb of [...this.graphSyncCallbacks]) {
cb()
}
const agentOrchestrationByPaneKey = this.agentOrchestrationProjection.buildByPaneKey()
const nativeChatLaunchDraftResolutions =
this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map(
({ tabId, text, createdAt }) => ({ tabId, text, createdAt })
)
return {
...this.getStatus(),
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}),
...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}),
...(mobileSessionResyncWorktrees.size > 0
? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] }
: {})
}
}
}