mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
Reference in New Issue
Block a user