fix(runtime): publish a terminal retirement proof on the exit's own evidence

A paired client may drop a mirrored terminal on exactly two kinds of 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 publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.
This commit is contained in:
Neil
2026-09-11 01:19:25 -07:00
parent c7b82e1909
commit 6965053d5e
3 changed files with 315 additions and 28 deletions
@@ -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
}
}
@@ -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)
}
}
@@ -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)
})
})