diff --git a/src/main/runtime/mobile-session-terminal-retirement-proof.ts b/src/main/runtime/mobile-session-terminal-retirement-proof.ts index d5b6c620954..d702c120531 100644 --- a/src/main/runtime/mobile-session-terminal-retirement-proof.ts +++ b/src/main/runtime/mobile-session-terminal-retirement-proof.ts @@ -1,7 +1,11 @@ -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import type { + RuntimeMobileSessionRetiredTerminalSurface, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' import { appendRetiredTerminalSurfaceProofs, - dropRetirementProofsForLiveSurfaces + dropRetirementProofsForLiveSurfaces, + retirementProofKey } from '../../shared/terminal-retirement-proof-ledger' export { @@ -48,3 +52,46 @@ export function preserveTerminalRetirementProofs( ) } } + +/** + * Attaches durable retirement proofs to a stored snapshot, bumping its version so clients that + * gate on a strictly newer `snapshotVersion` accept the frame. Returns null when the snapshot + * already carries exactly these proofs, so a no-op cannot fan out. + * + * Separate from `retireTerminalSurfacesFromSnapshot`: that one only produces a proof as a + * byproduct of removing the surface, and by the time a close's durable half runs the surface may + * already be gone from the snapshot. The proof still has to ship — it is the only host evidence + * that rides the frame carrying the retraction. + */ +export function attachRetirementProofsToSnapshot( + snapshot: RuntimeMobileSessionTabsSnapshot, + proofs: readonly RuntimeMobileSessionRetiredTerminalSurface[] +): RuntimeMobileSessionTabsSnapshot | null { + if (proofs.length === 0) { + return null + } + const merged = appendRetiredTerminalSurfaceProofs(snapshot.retiredTerminalSurfaces, proofs) + const existing = snapshot.retiredTerminalSurfaces + // Why by value, not identity: the append rebuilds every re-supplied proof, so an identity + // check would call a re-delivered exit a change and fan out a version bump carrying nothing. + const unchanged = + existing !== undefined && + merged.length === existing.length && + merged.every((proof, index) => { + const prior = existing[index] + return ( + prior !== undefined && + retirementProofKey(proof) === retirementProofKey(prior) && + proof.ptyId === prior.ptyId && + proof.incarnationId === prior.incarnationId + ) + }) + if (unchanged) { + return null + } + return { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + retiredTerminalSurfaces: merged + } +} diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index 5c70a9e518a..04896f27b1b 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -2,10 +2,12 @@ import { OrcaRuntimeWithTouchMobileSessionTabsForWorktree } from './orca-runtime-touch-mobile-session-tabs-for-worktree' import type { RetiredTerminalSurface } from './mobile-session-terminal-retirement' import type { ExecutionHostId } from '../../shared/execution-host' +import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' +import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof' import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -145,37 +147,63 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim ) } // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. - const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] - if (publishableRetiredSurfaces.length === 0) { - return - } + const removableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const retired = retireTerminalSurfacesFromSnapshot({ - snapshot, - ptyId, - exactSurfaces: publishableRetiredSurfaces.filter( - (surface) => surface.worktreeId === worktreeId - ), - // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. - exactOnly: true, - ...(terminalHandle - ? { - retirementProofs: publishableRetiredSurfaces - .filter((surface) => surface.worktreeId === worktreeId) - .map((surface) => ({ - parentTabId: surface.parentTabId, - leafId: surface.leafId, - ptyId: surface.ptyId, - terminal: terminalHandle, - incarnationId - })) - } - : {}) - }) + // Why proofs are not gated on `removable`: the observed exit is itself the attestation that + // this surface is retired. Persistence gates *removal* — publishing absence before the + // membership fence is durable would let a crash resurrect the surface — but a surface the + // renderer's own close transaction already de-persisted and dropped leaves persistence with + // nothing to accept, and gating the proof on that acceptance withheld the one piece of host + // evidence a paired mirror can act on. Its only other route needs two authoritative + // inventories, and a quiet workspace publishes one frame, so the pane stayed forever. + const retirementProofs = terminalHandle + ? retiredSurfaces + .filter((surface) => surface.worktreeId === worktreeId) + .map((surface) => ({ + parentTabId: surface.parentTabId, + leafId: surface.leafId, + ptyId: surface.ptyId, + terminal: terminalHandle, + incarnationId + })) + : [] + const removableSurfaces = removableRetiredSurfaces.filter( + (surface) => surface.worktreeId === worktreeId + ) + const retired = + removableSurfaces.length > 0 + ? retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId, + exactSurfaces: removableSurfaces, + // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. + exactOnly: true, + ...(retirementProofs.length > 0 ? { retirementProofs } : {}) + }) + : null if (retired) { this.storeMobileSessionSnapshot(worktreeId, retired.snapshot) this.notifyMobileSessionTabsChanged(worktreeId) + continue } + this.publishRetiredTerminalSurfaceProofs(worktreeId, retirementProofs) } } + + /** Ships durable retirement proofs on their own frame when no surface removal carries them. */ + protected publishRetiredTerminalSurfaceProofs( + worktreeId: string, + proofs: readonly RuntimeMobileSessionRetiredTerminalSurface[] + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + if (!snapshot) { + return + } + const next = attachRetirementProofsToSnapshot(snapshot, proofs) + if (!next) { + return + } + this.storeMobileSessionSnapshot(worktreeId, next) + this.notifyMobileSessionTabsChanged(worktreeId) + } } diff --git a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts new file mode 100644 index 00000000000..3e95283cd47 --- /dev/null +++ b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrcaRuntimeService } from './orca-runtime' + +/** + * A paired client may only drop a mirrored terminal on host evidence: a `retiredTerminalSurfaces` + * proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The + * second needs two host publications, and a quiet workspace produces one — so the proof is the + * only evidence that rides the frame carrying the retraction, and it has to be published whichever + * order the close's two halves (renderer republication, PTY exit) land in. + */ + +const WORKTREE_ID = 'repo::/worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const LIVE_REPO = { + id: 'repo', + path: '/worktree', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 +} as const + +function makeSnapshot(): RuntimeMobileSessionTabsSnapshot { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab::${LEAF_ID}`, + parentTabId: 'tab', + leafId: LEAF_ID, + ptyId: 'pty-left', + title: 'Left', + parentLayout: { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-left' } + }, + isActive: true + } + ] + } +} + +function makePersistedSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab', + ptyId: 'pty-left', + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-left' } + } + } + } +} + +function createHost(): { + runtime: OrcaRuntimeService + handle: string + retirePersistedSurface: () => void +} { + let session = makePersistedSession() + const runtime = new OrcaRuntimeService({ + getRepos: () => [LIVE_REPO], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: vi.fn() + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: { type: 'leaf', leafId: LEAF_ID } + } + ], + leaves: [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-left' + } + ], + mobileSessionTabs: [makeSnapshot()] + }) + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: LEAF_ID, + incarnationId: 'incarnation-a' + }) + // The mirror binds panes by terminal handle, so the handle has to exist before the close. + const handle = runtime.preAllocateHandleForPty('pty-left') + runtime.registerPreAllocatedHandleForPty('pty-left', handle) + return { + runtime, + handle, + // The renderer's close transaction de-persists the tab and flushes before it republishes. + retirePersistedSurface: () => { + session = { ...session, tabsByWorktree: {}, terminalLayoutsByTabId: {} } + } + } +} + +/** What the host renderer publishes once it has retired the tab it was told to close. */ +function republishWithoutTheSurface(runtime: OrcaRuntimeService): void { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 5, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) +} + +describe('retirement proof publication vs. renderer republication order', () => { + it('publishes the proof when the exit lands before the renderer drops the surface', async () => { + const { runtime, handle } = createHost() + + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + republishWithoutTheSurface(runtime) + + const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(published.tabs).toEqual([]) + expect(published.retiredTerminalSurfaces).toEqual([ + expect.objectContaining({ parentTabId: 'tab', leafId: LEAF_ID, terminal: handle }) + ]) + }) + + it('publishes the proof when the renderer drops the surface before the exit lands', async () => { + const { runtime, handle, retirePersistedSurface } = createHost() + + retirePersistedSurface() + republishWithoutTheSurface(runtime) + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(published.tabs).toEqual([]) + expect(published.retiredTerminalSurfaces).toEqual([ + expect.objectContaining({ parentTabId: 'tab', leafId: LEAF_ID, terminal: handle }) + ]) + }) + + // Why a subscriber and not just the stored snapshot: a mirror only ever sees frames. A proof + // that lands in state without a frame to carry it is the same silence from the client's side. + it('fans the proof out to a paired subscriber, not just into stored state', () => { + const { runtime, handle, retirePersistedSurface } = createHost() + const frames: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged( + (frame) => frames.push(frame), + 'paired-client' + ) + + try { + retirePersistedSurface() + republishWithoutTheSurface(runtime) + runtime.onPtyExit('pty-left', 0, 'incarnation-a') + } finally { + unsubscribe() + } + + expect( + frames.some((frame) => + frame.retiredTerminalSurfaces?.some( + (proof) => + proof.terminal === handle && proof.parentTabId === 'tab' && proof.leafId === LEAF_ID + ) + ) + ).toBe(true) + }) +})