fix: preserve terminal retirement proof across renderer publications (#19002)

* fix: preserve terminal retirement proof across renderer publications

* refactor: share the live-surface filter between retirement proof preservation and projection

The publication projection already dropped proofs whose surface is live;
reuse that as one helper instead of a second inline scan.

* fix: emit stored retirement proofs from host-authored snapshot writes

Three callers built a snapshot, stored it, then emitted the pre-store object. Storing grafts on the preserved proofs, so those frames carried the stored snapshotVersion without the proofs; subscribers dedupe on version and never saw them.

* fix: send terminal retirement proofs once per stream and fence them by occupant

Proofs are pinned per worktree for the host's lifetime, so every snapshot publication — including a 50ms title tick — re-shipped up to 64 proofs (~17 KB on realistic ids) to every paired client.

Negotiate session-tabs.retirement-proof-delta.v1: the host projects each session-tabs stream to send a proof only the first time that stream carries it, and a capable renderer keeps the union in a ledger keyed by (environment, worktree) with the same 64-entry bound and the same live-surface drop rule as the host, reset on removed frames and on a new connection generation. Legacy clients keep receiving the full list; CLI and mobile do not advertise the capability.

Also inherit worktreeInstanceId onto identity-less host writes so a host write between two renderer occupants can no longer launder one occupant's proofs into the next.

* fix: keep an empty proof delta distinguishable from a proof-less host

A negotiated stream now sends retiredTerminalSurfaces: [] when nothing is new instead of omitting the field. Absence is the host's "I hold no proofs" signal — which is also what a recreated worktree's fresh host entry publishes — so the client ledger forgets on absence and a successor occupant never inherits its predecessor's proofs, even when the removed frame was missed.

* test: pin ledger visibility against a legacy full-list host

An old host sends the full proof list whenever it holds any and omits the field when it holds none. Prove the new client ledger shows exactly what a legacy client would see across that sequence, so forgetting on absence is verified not to regress the mixed-version case.
This commit is contained in:
Neil
2026-09-07 19:28:17 -07:00
committed by GitHub
parent c056c6f9ac
commit da836faeef
19 changed files with 846 additions and 52 deletions
@@ -1,7 +1,95 @@
import { describe, expect, it } from 'vitest'
import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof'
import {
appendRetiredTerminalSurfaceProofs,
preserveTerminalRetirementProofs
} from './mobile-session-terminal-retirement-proof'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
const retired = {
parentTabId: 'tab',
leafId: 'leaf',
ptyId: 'pty',
terminal: 'term',
incarnationId: 'inc'
}
function snapshot(
overrides: Partial<RuntimeMobileSessionTabsSnapshot> = {}
): RuntimeMobileSessionTabsSnapshot {
return {
worktree: 'worktree',
worktreeInstanceId: 'instance',
publicationEpoch: 'epoch',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: [],
...overrides
}
}
describe('mobile session terminal retirement proofs', () => {
it.each([{ worktree: 'another-worktree' }, { worktreeInstanceId: 'successor-instance' }])(
'does not copy retirement proof into a different workspace: %j',
(identity) => {
const next = snapshot(identity)
expect(
preserveTerminalRetirementProofs(next, snapshot({ retiredTerminalSurfaces: [retired] }))
).toBe(next)
}
)
// Why: host-authored writes never carry worktreeInstanceId. Without inheritance the stored
// entry forgets the occupant and renderer(A) -> host write -> renderer(B) launders A's proofs.
it('does not launder proofs across occupants through an identity-less host write', () => {
const occupantA = snapshot({
worktreeInstanceId: 'instance-a',
retiredTerminalSurfaces: [retired]
})
const hostWrite = preserveTerminalRetirementProofs(
snapshot({ worktreeInstanceId: undefined, snapshotVersion: 2 }),
occupantA
)
expect(hostWrite.worktreeInstanceId).toBe('instance-a')
expect(hostWrite.retiredTerminalSurfaces).toEqual([retired])
const occupantB = snapshot({ worktreeInstanceId: 'instance-b', snapshotVersion: 3 })
expect(preserveTerminalRetirementProofs(occupantB, hostWrite)).toBe(occupantB)
})
it('keeps proofs for a host write that never learned any identity', () => {
const existing = snapshot({ worktreeInstanceId: undefined, retiredTerminalSurfaces: [retired] })
const next = preserveTerminalRetirementProofs(
snapshot({ worktreeInstanceId: undefined, snapshotVersion: 2 }),
existing
)
expect(next.worktreeInstanceId).toBeUndefined()
expect(next.retiredTerminalSurfaces).toEqual([retired])
})
it('drops an old proof when its surface is published again', () => {
const existing = snapshot({ retiredTerminalSurfaces: [retired] })
const revived = preserveTerminalRetirementProofs(
snapshot({
tabs: [
{
type: 'terminal',
id: 'tab::leaf',
parentTabId: 'tab',
leafId: 'leaf',
ptyId: 'successor-pty',
title: 'Successor',
isActive: false
}
]
}),
existing
)
expect(revived.retiredTerminalSurfaces).toEqual([])
expect(
preserveTerminalRetirementProofs(snapshot(), revived).retiredTerminalSurfaces
).toBeUndefined()
})
it('keeps the newest 64 exact identities', () => {
let proofs = appendRetiredTerminalSurfaceProofs(
undefined,
@@ -1,28 +1,50 @@
import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import {
appendRetiredTerminalSurfaceProofs,
dropRetirementProofsForLiveSurfaces
} from '../../shared/terminal-retirement-proof-ledger'
const MAX_RETIRED_TERMINAL_SURFACE_PROOFS = 64
export {
appendRetiredTerminalSurfaceProofs,
dropRetirementProofsForLiveSurfaces
} from '../../shared/terminal-retirement-proof-ledger'
export function appendRetiredTerminalSurfaceProofs(
existing: readonly RuntimeMobileSessionRetiredTerminalSurface[] | undefined,
retired: readonly RuntimeMobileSessionRetiredTerminalSurface[]
): RuntimeMobileSessionRetiredTerminalSurface[] {
const next = new Map(
(existing ?? []).map((surface) => [
`${surface.parentTabId}\0${surface.leafId}\0${surface.terminal}`,
surface
])
)
for (const evidence of retired) {
const key = `${evidence.parentTabId}\0${evidence.leafId}\0${evidence.terminal}`
next.delete(key)
next.set(key, evidence)
/**
* Renderer snapshots omit the host's durable close acknowledgements; carry them forward.
*
* Why the identity inheritance: host-authored writes never set `worktreeInstanceId`. Without it
* the stored entry forgets which occupant minted the proofs, and renderer(A) -> host write ->
* renderer(B) would launder A's proofs into B.
*/
export function preserveTerminalRetirementProofs(
snapshot: RuntimeMobileSessionTabsSnapshot,
existing: RuntimeMobileSessionTabsSnapshot | undefined
): RuntimeMobileSessionTabsSnapshot {
if (!existing || existing.worktree !== snapshot.worktree) {
return snapshot
}
while (next.size > MAX_RETIRED_TERMINAL_SURFACE_PROOFS) {
const oldest = next.keys().next().value
if (typeof oldest !== 'string') {
break
}
next.delete(oldest)
if (
existing.worktreeInstanceId !== undefined &&
snapshot.worktreeInstanceId !== undefined &&
existing.worktreeInstanceId !== snapshot.worktreeInstanceId
) {
return snapshot
}
const identified =
snapshot.worktreeInstanceId === undefined && existing.worktreeInstanceId !== undefined
? { ...snapshot, worktreeInstanceId: existing.worktreeInstanceId }
: snapshot
if (!existing.retiredTerminalSurfaces?.length) {
return identified
}
return {
...identified,
retiredTerminalSurfaces: dropRetirementProofsForLiveSurfaces(
appendRetiredTerminalSurfaceProofs(
existing.retiredTerminalSurfaces,
snapshot.retiredTerminalSurfaces ?? []
),
snapshot.tabs
)
}
return [...next.values()]
}
@@ -141,8 +141,10 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.storeMobileSessionSnapshot(worktreeId, next)
const result = this.toMobileSessionTabsResult(next)
// Why: emit the stored snapshot, not the pre-store one — storing grafts on retirement
// proofs, and subscribers dedupe on version so they would never see them otherwise.
const stored = this.storeMobileSessionSnapshot(worktreeId, next)
const result = this.toMobileSessionTabsResult(stored)
const changeSequence = ++this.mobileSessionTabsChangeSequence
for (const subscription of this.mobileSessionTabListeners) {
subscription.listener(
@@ -124,9 +124,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
),
tabs: existing.tabs.map((tab) => ({ ...tab, isActive: tab.id === id }))
}
this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
if (input.notify !== false) {
this.emitMobileSessionTabsSnapshot(snapshot)
this.emitMobileSessionTabsSnapshot(stored)
}
return
}
@@ -174,9 +174,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
const stored = this.storeMobileSessionSnapshot(input.workspaceId, snapshot)
if (input.notify !== false) {
this.emitMobileSessionTabsSnapshot(snapshot)
this.emitMobileSessionTabsSnapshot(stored)
}
}
@@ -1,5 +1,6 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { randomUUID } from 'node:crypto'
import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement'
import type { RuntimeStore } from './runtime-store-contract'
@@ -107,6 +108,7 @@ export class OrcaRuntimeWithRuntimeId {
snapshot = replaceConversationInSnapshot(snapshot, replacement)
}
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
snapshot = preserveTerminalRetirementProofs(snapshot, existing)
const snapshotVersion = existing
? Math.max(snapshot.snapshotVersion, existing.snapshotVersion + 1)
: snapshot.snapshotVersion
@@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-
import type { RpcContext } from '../core'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
type SessionTabsInventory = {
@@ -117,6 +118,7 @@ export async function subscribeSessionTabsInventory(
const deliveredChangeSequenceByWorktree = new Map<string, number>()
let censusChangeSequence: number | undefined
let censusInvalidated = false
const withProofDelta = createSessionTabsRetirementProofDelta(context.clientCapabilities)
const projectChange = (snapshot: SessionTabsChange): SessionTabsChange =>
projectSessionTabsForClient(
snapshot,
@@ -193,7 +195,7 @@ export async function subscribeSessionTabsInventory(
}
emit({
type: 'updated',
...projected
...withProofDelta(projected)
})
if (projected.removed === true) {
publishedSnapshotsByWorktree.delete(snapshot.worktree)
@@ -267,7 +269,7 @@ export async function subscribeSessionTabsInventory(
}
const { inventory, changeSequence } = collected
censusChangeSequence = changeSequence
emit({ type: 'snapshots', ...inventory })
emit({ type: 'snapshots', ...inventory, snapshots: inventory.snapshots.map(withProofDelta) })
for (const snapshot of inventory.snapshots) {
publishedSnapshotsByWorktree.set(snapshot.worktree, withoutNavigationIntent(snapshot))
}
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type {
RuntimeMobileSessionRetiredTerminalSurface,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import { RpcDispatcher } from '../dispatcher'
import { SESSION_TAB_METHODS } from './session-tabs'
import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta'
const WORKTREE = 'wt-proofs'
function proof(index: number): RuntimeMobileSessionRetiredTerminalSurface {
return {
parentTabId: `tab-${index}`,
leafId: `leaf-${index}`,
ptyId: `pty-${index}`,
terminal: `term_${index}`,
incarnationId: `inc-${index}`
}
}
function frame(
snapshotVersion: number,
retiredTerminalSurfaces?: RuntimeMobileSessionRetiredTerminalSurface[]
): RuntimeMobileSessionTabsResult {
return {
worktree: WORKTREE,
publicationEpoch: 'epoch',
snapshotVersion,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
...(retiredTerminalSurfaces ? { retiredTerminalSurfaces } : {}),
tabs: []
}
}
describe('session tabs retirement proof delta', () => {
it('passes every frame through untouched for a client that did not negotiate it', () => {
const project = createSessionTabsRetirementProofDelta(undefined)
const full = frame(1, [proof(1), proof(2)])
expect(project(full)).toBe(full)
expect(project(frame(2, [proof(1), proof(2)]))).toEqual(frame(2, [proof(1), proof(2)]))
})
// Why `[]` rather than omitting the field: absence is the host's "I hold no proofs" signal and
// tells the client to forget, so a delta with nothing new must stay distinguishable from it.
it('sends each proof once and an empty list when nothing is new', () => {
const project = createSessionTabsRetirementProofDelta([
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY
])
expect(project(frame(1, [proof(1)]))).toEqual(frame(1, [proof(1)]))
expect(project(frame(2, [proof(1)]))).toEqual(frame(2, []))
expect(project(frame(3, [proof(1), proof(2)]))).toEqual(frame(3, [proof(2)]))
expect(project(frame(4, [proof(1), proof(2)]))).toEqual(frame(4, []))
})
it('resends a proof that left the host list and came back', () => {
const project = createSessionTabsRetirementProofDelta([
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY
])
project(frame(1, [proof(1)]))
// Surface revived: the host dropped the proof and publishes an empty list.
expect(project(frame(2, []))).toEqual(frame(2, []))
expect(project(frame(3, [proof(1)]))).toEqual(frame(3, [proof(1)]))
})
it('starts over for a worktree after a removed frame or a proof-less host', () => {
const project = createSessionTabsRetirementProofDelta([
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY
])
project(frame(1, [proof(1)]))
project({ ...frame(2), removed: true } as RuntimeMobileSessionTabsResult)
expect(project(frame(3, [proof(1)]))).toEqual(frame(3, [proof(1)]))
project(frame(4))
expect(project(frame(5, [proof(1)]))).toEqual(frame(5, [proof(1)]))
})
})
// Why: the host pins up to 64 proofs per worktree for its lifetime, so this is the steady-state
// cost of every title tick on a churn-heavy worktree for a paired mobile/relay/SSH client.
describe('session.tabs.subscribe retirement proof payload', () => {
// Real identities are UUID-sized: tab/leaf/pty ids and `term_<uuid>` handles.
const uuid = (index: number): string =>
`${index.toString(16).padStart(8, '0')}-4a1b-4c2d-8e3f-000000000000`
const proofs = Array.from({ length: 64 }, (_, index) => ({
parentTabId: `terminal-${uuid(index)}`,
leafId: uuid(index + 1000),
ptyId: uuid(index + 2000),
terminal: `term_${uuid(index + 3000)}`,
incarnationId: uuid(index + 4000)
}))
async function subscribeAndTick(clientCapabilities: readonly string[] | undefined): Promise<{
initial: string
tick: string
}> {
let listener: ((snapshot: RuntimeMobileSessionTabsResult) => void) | undefined
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: () => ({}),
listMobileSessionTabs: vi.fn().mockResolvedValue(frame(1, proofs)),
registerSubscriptionCleanup: vi.fn(),
onMobileSessionTabsChanged: vi.fn(
(next: (snapshot: RuntimeMobileSessionTabsResult) => void) => {
listener = next
return () => {}
}
)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
await dispatcher.dispatchStreaming(
{
id: 'req-1',
authToken: 'tok',
method: 'session.tabs.subscribe',
params: { worktree: 'id:wt' }
},
(message) => messages.push(message),
{ clientKind: 'runtime', clientCapabilities }
)
// An OSC title change bumps the version and republishes the same 64 proofs.
listener!(frame(2, proofs))
return { initial: messages[0]!, tick: messages[1]! }
}
// Why: a reconnect is a new subscribe with fresh per-stream state, and the client's ledger
// resets on its new connection generation — so the first frame must carry the full set.
it('resends the full proof set on the first frame of a fresh stream', async () => {
const first = await subscribeAndTick([SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY])
const reconnected = await subscribeAndTick([
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY
])
expect(JSON.parse(first.initial).result.retiredTerminalSurfaces).toEqual(proofs)
expect(JSON.parse(reconnected.initial).result.retiredTerminalSurfaces).toEqual(proofs)
})
it('drops the repeated proof list from a title tick for a negotiated client', async () => {
const legacy = await subscribeAndTick(undefined)
const delta = await subscribeAndTick([SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY])
const legacyTick = JSON.parse(legacy.tick).result
const deltaTick = JSON.parse(delta.tick).result
expect(legacyTick.retiredTerminalSurfaces).toHaveLength(64)
expect(deltaTick.retiredTerminalSurfaces).toEqual([])
// Both clients still receive the full list on the initial snapshot.
expect(JSON.parse(legacy.initial).result.retiredTerminalSurfaces).toHaveLength(64)
expect(JSON.parse(delta.initial).result.retiredTerminalSurfaces).toHaveLength(64)
// The delta tick keeps a two-byte `[]` so the client can tell "nothing new" from "no proofs".
const proofBytes = Buffer.byteLength(JSON.stringify(proofs))
expect(proofBytes).toBeGreaterThan(8_000)
expect(Buffer.byteLength(legacy.tick) - Buffer.byteLength(delta.tick)).toBe(proofBytes - 2)
})
})
@@ -0,0 +1,45 @@
import {
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../../../shared/runtime-types'
import { retirementProofKey as proofKey } from '../../../../shared/terminal-retirement-proof-ledger'
type ProofCarrier = {
worktree: string
removed?: true
retiredTerminalSurfaces?: RuntimeMobileSessionRetiredTerminalSurface[]
}
export type SessionTabsRetirementProofDelta = <TFrame extends ProofCarrier>(frame: TFrame) => TFrame
/**
* Per-stream projection that sends each retirement proof once. The host pins up to 64 proofs per
* worktree for its process lifetime, so without this every title tick re-ships the whole list.
* A capable client retains what it was sent; a legacy client keeps receiving the full list.
*/
export function createSessionTabsRetirementProofDelta(
clientCapabilities: readonly RuntimeCapability[] | undefined
): SessionTabsRetirementProofDelta {
if (!clientCapabilities?.includes(SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY)) {
return (frame) => frame
}
const sentByWorktree = new Map<string, Set<string>>()
return <TFrame extends ProofCarrier>(frame: TFrame): TFrame => {
if (frame.removed === true || frame.retiredTerminalSurfaces === undefined) {
sentByWorktree.delete(frame.worktree)
return frame
}
const sent = sentByWorktree.get(frame.worktree)
const fresh = sent
? frame.retiredTerminalSurfaces.filter((proof) => !sent.has(proofKey(proof)))
: frame.retiredTerminalSurfaces
// Why: track exactly the current list, so a proof that leaves and returns is sent again.
sentByWorktree.set(frame.worktree, new Set(frame.retiredTerminalSurfaces.map(proofKey)))
// Why: an empty list is a real signal ("nothing new, keep yours"). Omitting the field would be
// indistinguishable from a host that holds no proofs, which is what tells the client to forget.
return fresh.length === frame.retiredTerminalSurfaces.length
? frame
: { ...frame, retiredTerminalSurfaces: fresh }
}
}
+16 -10
View File
@@ -14,6 +14,7 @@ import {
} from './session-tabs-inventory'
import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods'
import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods'
import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
@@ -115,13 +116,16 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
if (closed) {
return
}
const withProofDelta = createSessionTabsRetirementProofDelta(clientCapabilities)
emit({
type: 'snapshot',
...projectSessionTabsForClient(
initial,
clientKind,
clientCapabilities,
isStructuredNativeChatEnabled(runtime)
...withProofDelta(
projectSessionTabsForClient(
initial,
clientKind,
clientCapabilities,
isStructuredNativeChatEnabled(runtime)
)
)
})
initialized = true
@@ -133,11 +137,13 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
if (snapshot.worktree === subscribedWorktree) {
emit({
type: 'updated',
...projectSessionTabsForClient(
snapshot,
clientKind,
clientCapabilities,
isStructuredNativeChatEnabled(runtime)
...withProofDelta(
projectSessionTabsForClient(
snapshot,
clientKind,
clientCapabilities,
isStructuredNativeChatEnabled(runtime)
)
)
})
}
@@ -1,4 +1,5 @@
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import { dropRetirementProofsForLiveSurfaces } from './mobile-session-terminal-retirement-proof'
import type {
RuntimeMobileSessionProjectionHost,
RuntimeMobileSessionProjectionInput
@@ -41,14 +42,9 @@ export function finalizeRuntimeMobileSessionTabsResult(
...(snapshot.tabGroupLayout !== undefined ? { tabGroupLayout } : {}),
...(snapshot.retiredTerminalSurfaces
? {
retiredTerminalSurfaces: snapshot.retiredTerminalSurfaces.filter(
(retired) =>
!snapshot.tabs.some(
(tab) =>
tab.type === 'terminal' &&
tab.parentTabId === retired.parentTabId &&
tab.leafId === retired.leafId
)
retiredTerminalSurfaces: dropRetirementProofsForLiveSurfaces(
snapshot.retiredTerminalSurfaces,
snapshot.tabs
)
}
: {}),
@@ -0,0 +1,89 @@
import { expect, it, vi } from 'vitest'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../shared/runtime-types'
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
await import('./orca-runtime-test-lifecycle.spec')
const { store, TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec')
const retired = {
parentTabId: 'tab',
leafId: 'leaf',
ptyId: 'pty',
terminal: 'term_old',
incarnationId: 'inc'
}
type RuntimeInternals = {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
storeMobileSessionSnapshot: (
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot
) => RuntimeMobileSessionTabsSnapshot
}
function seedRuntimeWithStoredProof(): {
runtime: InstanceType<typeof OrcaRuntimeService>
internals: RuntimeInternals
} {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-runtime-fallback' }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(0, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'headless:active-generation',
snapshotVersion: 7,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
const internals = runtime as unknown as RuntimeInternals
const stored = internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)!
internals.storeMobileSessionSnapshot(TEST_WORKTREE_ID, {
...stored,
snapshotVersion: stored.snapshotVersion + 1,
retiredTerminalSurfaces: [retired]
})
return { runtime, internals }
}
// Why: subscribers dedupe on (epoch, version), so a frame emitted at the stored version but
// built from the pre-store object would strand the proofs until an unrelated later bump.
it('emits the stored retirement proofs on the frame a runtime-owned create publishes', async () => {
const { runtime, internals } = seedRuntimeWithStoredProof()
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged(
(frame) => events.push(frame),
'paired-client'
)
try {
await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false,
select: false,
navigation: 'caller',
clientNavigationId: 'paired-client'
})
const storedAfter = internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)!
const emitted = events.at(-1)!
expect(storedAfter.retiredTerminalSurfaces).toEqual([retired])
expect(emitted.snapshotVersion).toBe(storedAfter.snapshotVersion)
expect(emitted.retiredTerminalSurfaces).toEqual([retired])
} finally {
unsubscribe()
}
})
@@ -0,0 +1,31 @@
import { expect, it } from 'vitest'
import {
createStaleTabCloseHarness,
WORKTREE_ID
} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures'
it('keeps host retirement proof across a later renderer publication', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
await harness.runtime.closeTerminalTab(harness.terminal.handle)
const before = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(before.retiredTerminalSurfaces).toHaveLength(1)
harness.runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'renderer:close-continuity',
snapshotVersion: 100,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
const after = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(after.tabs).toEqual([])
expect(after.retiredTerminalSurfaces).toEqual(before.retiredTerminalSurfaces)
})
@@ -41,6 +41,10 @@ import {
} from './web-session-terminal-orphan-recovery-rpc-lane'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
import { buildWebTerminalOrphanTopologyProposal } from './web-session-terminal-orphan-topology'
import {
clearRetainedTerminalRetirementProofsForTests,
mergeRetainedTerminalRetirementProofs
} from './web-session-terminal-retirement-proof-ledger'
export type { TerminalOrphanRecoveryState } from './web-session-terminal-orphan-recovery-surface'
@@ -231,11 +235,14 @@ function normalizeOptions(
export function recoverWebSessionTerminalOrphansBeforeApply(
state: TerminalOrphanRecoveryState,
snapshot: RuntimeMobileSessionTabsResult,
frame: RuntimeMobileSessionTabsResult,
environmentId: string,
optionsOrCall?: TerminalOrphanRecoveryOptions | RuntimeCall
): Promise<RuntimeMobileSessionTabsResult | null> {
const options = normalizeOptions(optionsOrCall)
// Why: every host frame enters recovery here, so this is where a delta frame regains the proofs
// the host already sent this client (see the ledger for the negotiated contract).
const snapshot = mergeRetainedTerminalRetirementProofs(environmentId, frame)
const key = recoveryKey(
environmentId,
snapshot.worktree,
@@ -295,4 +302,5 @@ export function clearWebSessionTerminalOrphanRecoveryForTests(): void {
clearTerminalRecoveryQueues()
clearCachedSurfaceResolutions()
clearTerminalRecoveryRpcLaneForTests()
clearRetainedTerminalRetirementProofsForTests()
}
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { setRuntimeEnvironmentConnectionGenerationForTests } from '@/store/slices/runtime-status'
import {
ENVIRONMENT_ID,
makeSnapshot,
makeState,
pendingSurface
} from './__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures'
import {
clearWebSessionTerminalOrphanRecoveryForTests,
recoverWebSessionTerminalOrphansBeforeApply
} from './web-session-terminal-orphan-recovery'
import {
clearRetainedTerminalRetirementProofsForTests,
mergeRetainedTerminalRetirementProofs
} from './web-session-terminal-retirement-proof-ledger'
const TAB_ID = 'host-tab'
const LEAF_ID = 'leaf-1'
const HANDLE = 'term-retired'
const WORKTREE = 'repo::ledger'
const retired = {
parentTabId: TAB_ID,
leafId: LEAF_ID,
ptyId: 'pty-retired',
terminal: HANDLE,
incarnationId: 'inc-retired'
}
function frame(
snapshotVersion: number,
overrides: Partial<RuntimeMobileSessionTabsResult> = {}
): RuntimeMobileSessionTabsResult {
return { ...makeSnapshot(WORKTREE, 'epoch', []), snapshotVersion, ...overrides }
}
describe('web session terminal retirement proof ledger', () => {
beforeEach(() => clearRetainedTerminalRetirementProofsForTests())
// Why: a recreated worktree keeps the same environment and worktree id but its fresh host entry
// holds no proofs and omits the field. Absence must forget, so the successor occupant never
// inherits its predecessor's proofs even when the removed frame was missed. A delta host with
// nothing new sends `[]`, which keeps what was retained.
it('forgets on an absent field but keeps proofs on an empty delta', () => {
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(1, { retiredTerminalSurfaces: [retired] })
)
expect(
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(2, { retiredTerminalSurfaces: [] })
).retiredTerminalSurfaces
).toEqual([retired])
const successor = frame(3)
expect(mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, successor)).toBe(successor)
expect(
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(4, { retiredTerminalSurfaces: [] })
).retiredTerminalSurfaces
).toEqual([])
})
it('carries a proof sent once into later delta frames for the same worktree', () => {
expect(
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(1, { retiredTerminalSurfaces: [retired] })
).retiredTerminalSurfaces
).toEqual([retired])
expect(
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(2, { retiredTerminalSurfaces: [] })
).retiredTerminalSurfaces
).toEqual([retired])
expect(
mergeRetainedTerminalRetirementProofs(
'other-environment',
frame(3, { retiredTerminalSurfaces: [] })
).retiredTerminalSurfaces
).toEqual([])
})
it('forgets a proof once the host publishes its surface live again', () => {
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(1, { retiredTerminalSurfaces: [retired] })
)
const revived = mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(2, { tabs: [pendingSurface(TAB_ID, LEAF_ID, 'pty-new', 'term-new')] })
)
expect(revived.retiredTerminalSurfaces).toBeUndefined()
expect(
mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, frame(3)).retiredTerminalSurfaces
).toBeUndefined()
})
// Why: a restarted host has an empty proof map, so a proof retained from its predecessor must
// not survive to falsely match a handle the new host issues. The store advances the connection
// generation whenever `status.runtimeId` changes (runtime-status.test.ts pins that), and the
// ledger keys every entry on that generation.
it('forgets everything on a removed frame and on a new host connection', () => {
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(1, { retiredTerminalSurfaces: [retired] })
)
mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, {
...frame(2),
removed: true
} as RuntimeMobileSessionTabsResult)
expect(
mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, frame(3)).retiredTerminalSurfaces
).toBeUndefined()
mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(4, { retiredTerminalSurfaces: [retired] })
)
setRuntimeEnvironmentConnectionGenerationForTests(ENVIRONMENT_ID, 99)
expect(
mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, frame(5)).retiredTerminalSurfaces
).toBeUndefined()
})
// Why: an older host never negotiates the delta and repeats its full list on every frame. The
// ledger cannot tell a full list from a delta and does not need to: the merge is a union keyed
// by exact identity, so a repeated full list is a no-op and the ledger never outgrows the host.
it('treats a full list repeated by a legacy host as idempotent', () => {
const proofs = Array.from({ length: 64 }, (_, index) => ({
...retired,
leafId: `leaf-${index}`,
terminal: `term-${index}`
}))
for (let version = 1; version <= 3; version += 1) {
const full = frame(version, { retiredTerminalSurfaces: proofs })
const merged = mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, full)
expect(merged).toBe(full)
expect(merged.retiredTerminalSurfaces).toHaveLength(64)
}
// A host that rotated one identity past its cap: the client list stays at the cap too.
const rotated = [...proofs.slice(1), { ...retired, leafId: 'leaf-new', terminal: 'term-new' }]
const merged = mergeRetainedTerminalRetirementProofs(
ENVIRONMENT_ID,
frame(4, { retiredTerminalSurfaces: rotated })
)
expect(merged.retiredTerminalSurfaces).toHaveLength(64)
expect(merged.retiredTerminalSurfaces?.at(-1)?.leafId).toBe('leaf-new')
})
// Why: an old host never negotiates the delta — it sends the full list whenever it holds any
// proofs and omits the field whenever it holds none. Forgetting on absence loses nothing there,
// because every proof-bearing frame from such a host already carries the whole list.
it('matches legacy visibility against a full-list host that omits the field when empty', () => {
const proofs = Array.from({ length: 3 }, (_, index) => ({
...retired,
leafId: `leaf-${index}`,
terminal: `term-${index}`
}))
const legacyHostFrames = [
frame(1, { retiredTerminalSurfaces: proofs }),
frame(2),
frame(3, { retiredTerminalSurfaces: proofs })
]
const visible = legacyHostFrames.map(
(hostFrame) =>
mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, hostFrame).retiredTerminalSurfaces
)
// A legacy client sees exactly what the host sent, frame by frame.
expect(visible).toEqual(legacyHostFrames.map((hostFrame) => hostFrame.retiredTerminalSurfaces))
})
it('returns the same frame object when the ledger adds nothing', () => {
const untouched = frame(1)
expect(mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, untouched)).toBe(untouched)
const carried = frame(2, { retiredTerminalSurfaces: [retired] })
expect(mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, carried)).toBe(carried)
})
})
// Why: the end-to-end contract — a delta frame that omits the proof must still retire the pane
// the earlier frame proved dead, without any host round trip.
describe('orphan recovery over delta frames', () => {
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
it('retires a stale local pane from a proof delivered on an earlier frame', async () => {
const state = makeState(WORKTREE, [{ leafId: LEAF_ID, handle: HANDLE }])
const call = vi.fn()
const first = await recoverWebSessionTerminalOrphansBeforeApply(
state,
frame(1, { retiredTerminalSurfaces: [retired] }),
ENVIRONMENT_ID,
{ call: call as never }
)
expect(first?.tabs).toEqual([])
const second = await recoverWebSessionTerminalOrphansBeforeApply(
state,
frame(2, { retiredTerminalSurfaces: [] }),
ENVIRONMENT_ID,
{ call: call as never }
)
expect(second?.tabs).toEqual([])
expect(second?.retiredTerminalSurfaces).toEqual([retired])
expect(call).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,77 @@
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
appendRetiredTerminalSurfaceProofs,
dropRetirementProofsForLiveSurfaces
} from '../../../shared/terminal-retirement-proof-ledger'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { isRemovedSnapshot } from './web-session-terminal-orphan-recovery-surface-index'
type RetainedProofs = {
/** Why: a reconnect resubscribes and the host resends its full list, so older evidence is moot. */
connectionGeneration: number
proofs: NonNullable<RuntimeMobileSessionTabsResult['retiredTerminalSurfaces']>
}
/**
* Client half of `session-tabs.retirement-proof-delta.v1`: a host that negotiated it sends each
* retirement proof once per stream, so the client keeps the union itself. Bounded exactly like
* the host's list, and a proof leaves the moment its surface is published live again, so nothing
* here outlives the evidence the host still holds. A lost entry never proves anything; recovery
* just falls back to the slower host-attested inventory path.
*/
const retainedByKey = new Map<string, RetainedProofs>()
const MAX_LEDGER_WORKTREES = 512
const ledgerKey = (environmentId: string, worktreeId: string): string =>
`${environmentId}\0${worktreeId}`
/** Returns the frame with every proof the host has sent this client for the worktree. */
export function mergeRetainedTerminalRetirementProofs(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
): RuntimeMobileSessionTabsResult {
const key = ledgerKey(environmentId, snapshot.worktree)
if (isRemovedSnapshot(snapshot)) {
retainedByKey.delete(key)
return snapshot
}
// Why: a host that holds no proofs omits the field; a delta host with nothing new sends `[]`.
// Absence therefore means "forget" — which is also what a recreated worktree's fresh host entry
// publishes, so a new occupant never inherits its predecessor's proofs even if the removed
// frame was missed.
if (snapshot.retiredTerminalSurfaces === undefined) {
retainedByKey.delete(key)
return snapshot
}
const connectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
const cached = retainedByKey.get(key)
const retained = cached?.connectionGeneration === connectionGeneration ? cached.proofs : undefined
if (!retained && snapshot.retiredTerminalSurfaces.length === 0) {
retainedByKey.delete(key)
return snapshot
}
const merged = dropRetirementProofsForLiveSurfaces(
appendRetiredTerminalSurfaceProofs(retained, snapshot.retiredTerminalSurfaces),
snapshot.tabs
)
retainedByKey.delete(key)
if (merged.length > 0) {
retainedByKey.set(key, { connectionGeneration, proofs: merged })
while (retainedByKey.size > MAX_LEDGER_WORKTREES) {
const oldest = retainedByKey.keys().next().value
if (typeof oldest !== 'string') {
break
}
retainedByKey.delete(oldest)
}
}
const unchanged =
merged.length === (snapshot.retiredTerminalSurfaces?.length ?? 0) &&
merged.every((proof, index) => proof === snapshot.retiredTerminalSurfaces?.[index])
return unchanged ? snapshot : { ...snapshot, retiredTerminalSurfaces: merged }
}
export function clearRetainedTerminalRetirementProofsForTests(): void {
retainedByKey.clear()
}
@@ -14,6 +14,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
@@ -86,6 +87,7 @@ describe('WebRuntimeClient', () => {
deviceToken: 'token',
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
@@ -3,6 +3,7 @@ import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
@@ -58,6 +59,7 @@ export async function routeWebRuntimeConnectionFrame(
deviceToken: context.pairingToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
+8 -1
View File
@@ -122,6 +122,11 @@ export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const
export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY =
'session-tabs.authoritative-inventory.v1' as const
// Why: a client advertising this retains every terminal retirement proof it receives until the
// surface is published live again, so a session-tabs stream sends each proof once instead of
// repeating the host's whole bounded list on every title tick.
export const SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY =
'session-tabs.retirement-proof-delta.v1' as const
export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY =
'agent-session.session-boundary.v1' as const
export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
@@ -206,7 +211,9 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY,
// Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists.
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY
] as const
export const RUNTIME_CAPABILITIES = [
@@ -0,0 +1,47 @@
import type { RuntimeMobileSessionRetiredTerminalSurface } from './runtime-session-contracts'
/** Bound shared by the host's stored list and a client's retained copy of it. */
export const MAX_RETIRED_TERMINAL_SURFACE_PROOFS = 64
type SurfaceTab = { type: string; parentTabId?: string; leafId?: string }
const surfaceKey = (surface: { parentTabId: string; leafId: string }): string =>
`${surface.parentTabId}\0${surface.leafId}`
export const retirementProofKey = (proof: RuntimeMobileSessionRetiredTerminalSurface): string =>
`${proof.parentTabId}\0${proof.leafId}\0${proof.terminal}`
/** A surface published again is no longer retired, whatever handle now occupies it. */
export function dropRetirementProofsForLiveSurfaces(
retired: readonly RuntimeMobileSessionRetiredTerminalSurface[],
tabs: readonly SurfaceTab[]
): RuntimeMobileSessionRetiredTerminalSurface[] {
const live = new Set<string>()
for (const tab of tabs) {
if (tab.type === 'terminal' && tab.parentTabId !== undefined && tab.leafId !== undefined) {
live.add(surfaceKey({ parentTabId: tab.parentTabId, leafId: tab.leafId }))
}
}
return retired.filter((surface) => !live.has(surfaceKey(surface)))
}
/** Newest evidence wins per exact identity; the oldest identities fall off past the cap. */
export function appendRetiredTerminalSurfaceProofs(
existing: readonly RuntimeMobileSessionRetiredTerminalSurface[] | undefined,
retired: readonly RuntimeMobileSessionRetiredTerminalSurface[]
): RuntimeMobileSessionRetiredTerminalSurface[] {
const next = new Map((existing ?? []).map((surface) => [retirementProofKey(surface), surface]))
for (const evidence of retired) {
const key = retirementProofKey(evidence)
next.delete(key)
next.set(key, evidence)
}
while (next.size > MAX_RETIRED_TERMINAL_SURFACE_PROOFS) {
const oldest = next.keys().next().value
if (typeof oldest !== 'string') {
break
}
next.delete(oldest)
}
return [...next.values()]
}