From da836faeef73fe528b23dccef686ae8e9bd0166f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:28:17 -0700 Subject: [PATCH] fix: preserve terminal retirement proof across renderer publications (#19002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- ...-session-terminal-retirement-proof.test.ts | 90 +++++++- ...obile-session-terminal-retirement-proof.ts | 68 ++++-- ...e-runtime-owned-mobile-session-terminal.ts | 6 +- ...tore-structured-agent-session-tabs-once.ts | 8 +- src/main/runtime/orca-runtime-runtime-id.ts | 2 + .../rpc/methods/session-tabs-inventory.ts | 6 +- ...ession-tabs-retirement-proof-delta.test.ts | 158 +++++++++++++ .../session-tabs-retirement-proof-delta.ts | 45 ++++ src/main/runtime/rpc/methods/session-tabs.ts | 26 ++- ...time-mobile-session-result-finalization.ts | 12 +- ...nal-retirement-proof-emitted-frame.test.ts | 89 ++++++++ ...minal-retirement-proof-publication.test.ts | 31 +++ .../web-session-terminal-orphan-recovery.ts | 10 +- ...n-terminal-retirement-proof-ledger.test.ts | 210 ++++++++++++++++++ ...ession-terminal-retirement-proof-ledger.ts | 77 +++++++ .../src/web/web-runtime-client.test.ts | 2 + .../web-runtime-connection-frame-router.ts | 2 + src/shared/protocol-version.ts | 9 +- .../terminal-retirement-proof-ledger.ts | 47 ++++ 19 files changed, 846 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts create mode 100644 src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts create mode 100644 src/main/runtime/terminal-retirement-proof-publication.test.ts create mode 100644 src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts create mode 100644 src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts create mode 100644 src/shared/terminal-retirement-proof-ledger.ts diff --git a/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts b/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts index 8b5f4161a63..79aa4b27f75 100644 --- a/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts +++ b/src/main/runtime/mobile-session-terminal-retirement-proof.test.ts @@ -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 { + 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, diff --git a/src/main/runtime/mobile-session-terminal-retirement-proof.ts b/src/main/runtime/mobile-session-terminal-retirement-proof.ts index b1ce97e3f91..d5b6c620954 100644 --- a/src/main/runtime/mobile-session-terminal-retirement-proof.ts +++ b/src/main/runtime/mobile-session-terminal-retirement-proof.ts @@ -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()] } diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index 31fb8a90ab0..1bb45f838e0 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -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( diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index e912cc6b665..f138970d5ad 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -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) } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 87dda7ebde0..caf23eee00b 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index aa3777ca12a..24a0af087ce 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -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() 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)) } diff --git a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts new file mode 100644 index 00000000000..021b6f20622 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts @@ -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_` 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) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts new file mode 100644 index 00000000000..059af124a78 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts @@ -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 = (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>() + return (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 } + } +} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 6296c462a39..aa0e0b24939 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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) + ) ) }) } diff --git a/src/main/runtime/runtime-mobile-session-result-finalization.ts b/src/main/runtime/runtime-mobile-session-result-finalization.ts index 2c3af07b6f9..1848ae56793 100644 --- a/src/main/runtime/runtime-mobile-session-result-finalization.ts +++ b/src/main/runtime/runtime-mobile-session-result-finalization.ts @@ -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 ) } : {}), diff --git a/src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts b/src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts new file mode 100644 index 00000000000..c97d5429d18 --- /dev/null +++ b/src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts @@ -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 + storeMobileSessionSnapshot: ( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot + ) => RuntimeMobileSessionTabsSnapshot +} + +function seedRuntimeWithStoredProof(): { + runtime: InstanceType + 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() + } +}) diff --git a/src/main/runtime/terminal-retirement-proof-publication.test.ts b/src/main/runtime/terminal-retirement-proof-publication.test.ts new file mode 100644 index 00000000000..2ddb35a7e09 --- /dev/null +++ b/src/main/runtime/terminal-retirement-proof-publication.test.ts @@ -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) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts index 3671817ff1c..57ae30792f3 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts @@ -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 { 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() } diff --git a/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts new file mode 100644 index 00000000000..c7aa29b7336 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts new file mode 100644 index 00000000000..f33b12769c9 --- /dev/null +++ b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts @@ -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 +} + +/** + * 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() + +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() +} diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 7373ede6b8c..171d48455f2 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -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, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index b9111391056..fe089be80ef 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -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, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e1cf7594034..3aaeeb747f4 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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 = [ diff --git a/src/shared/terminal-retirement-proof-ledger.ts b/src/shared/terminal-retirement-proof-ledger.ts new file mode 100644 index 00000000000..4d570497f67 --- /dev/null +++ b/src/shared/terminal-retirement-proof-ledger.ts @@ -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() + 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()] +}