test(wire): pair session-tabs retirement proof and worktree identity across builds

Two cross-version surfaces with no prior coverage: the terminal suite covers
the binary stream and the agent-session suite covers agentSession.*, neither
reaches session-tabs or persisted worktree identity.

Both pin v1.4.199 as the pre-stack build and carry an anti-vacuous-pass
oracle asserting the two builds resolved to distinct module instances, so a
pairing cannot pass by being same-version in disguise.
This commit is contained in:
Neil
2026-09-10 19:34:02 -07:00
parent 66bd8dd450
commit 6a03522de1
2 changed files with 407 additions and 0 deletions
@@ -0,0 +1,253 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
/**
* The session-tabs retirement-proof surface, paired across two builds.
*
* `cross-version-terminal-wire` covers the terminal binary stream and
* `cross-version-agent-session-wire` covers `agentSession.*`; neither reaches the
* session-tabs frame, which is where a paired client learns that a mirrored terminal
* is gone. This pairs the two halves of that surface across versions:
*
* - the HOST half changed — a host now ships a retirement proof on its own frame when
* no surface removal carries one (`attachRetirementProofsToSnapshot`);
* - the CLIENT half did not change, which this asserts by running both builds' ledger
* over the same frames rather than by reading the diff.
*
* The claim under test is the one written into the change: that this is Rule 1, because
* `retiredTerminalSurfaces` is an existing optional field on an existing path. Rule 3's
* fourth bullet says "a frame the host ... starts sending, on an existing path" is a wire
* change even with no codec movement, so the claim is checked against an actual old
* build rather than accepted.
*
* The pre-stack ref is pinned rather than derived: this contract needs a release from
* before the proof-only frame existed, which is the fallback
* docs/reference/remote-wire-compatibility.md sanctions for exactly this case.
*/
const PRE_STACK_REF = 'v1.4.199'
const SUITE_TIMEOUT_MS = 180_000
const WORKTREE_ID = 'repo::/worktree'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PARENT_TAB_ID = 'tab'
const PTY_ID = 'pty-left'
const TERMINAL_HANDLE = 'remote:terminal-handle-1'
type Snapshot = {
worktree: string
publicationEpoch: string
snapshotVersion: number
activeGroupId: null
activeTabId: string | null
activeTabType: string | null
tabs: Record<string, unknown>[]
retiredTerminalSurfaces?: Record<string, unknown>[]
}
type ProofLedger = {
appendRetiredTerminalSurfaceProofs: (
existing: readonly Record<string, unknown>[] | undefined,
retired: readonly Record<string, unknown>[]
) => Record<string, unknown>[]
dropRetirementProofsForLiveSurfaces: (
retired: readonly Record<string, unknown>[],
tabs: readonly Record<string, unknown>[]
) => Record<string, unknown>[]
}
type HostProofPublisher = {
attachRetirementProofsToSnapshot?: (
snapshot: Snapshot,
proofs: readonly Record<string, unknown>[]
) => Snapshot | null
retireTerminalSurfacesFromSnapshot: (
args: Record<string, unknown>
) => { snapshot: Snapshot } | null
}
type Build = {
label: string
ledger: ProofLedger
host: HostProofPublisher
}
/** The surface as the host still holds it, before the close's two halves land. */
function liveSnapshot(): Snapshot {
return {
worktree: WORKTREE_ID,
publicationEpoch: 'renderer',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: `tab::${LEAF_ID}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `tab::${LEAF_ID}`,
parentTabId: PARENT_TAB_ID,
leafId: LEAF_ID,
ptyId: PTY_ID,
title: 'Left',
isActive: true
}
]
}
}
/**
* The renderer-first ordering, which is the one users hit: the close transaction already
* de-persisted the surface and republished without it, so the PTY exit that follows finds
* nothing left for persistence to accept.
*/
function snapshotAfterRendererRepublished(): Snapshot {
return { ...liveSnapshot(), snapshotVersion: 2, tabs: [], activeTabId: null, activeTabType: null }
}
function exitProof(): Record<string, unknown> {
return {
parentTabId: PARENT_TAB_ID,
leafId: LEAF_ID,
ptyId: PTY_ID,
terminal: TERMINAL_HANDLE,
incarnationId: 'inc-1'
}
}
async function loadBuild(ref: string | null): Promise<Build> {
if (ref === null) {
const [ledger, proof, retirement] = await Promise.all([
import('../../../src/shared/terminal-retirement-proof-ledger'),
import('../../../src/main/runtime/mobile-session-terminal-retirement-proof'),
import('../../../src/main/runtime/mobile-session-terminal-retirement')
])
return {
label: 'stack',
ledger: ledger as unknown as ProofLedger,
host: { ...proof, ...retirement } as unknown as HostProofPublisher
}
}
const checkout = await materializeReleaseCheckout(ref)
const [ledger, proof, retirement] = await Promise.all([
importReleaseCheckoutModule(checkout, 'src/shared/terminal-retirement-proof-ledger.ts'),
importReleaseCheckoutModule(
checkout,
'src/main/runtime/mobile-session-terminal-retirement-proof.ts'
),
importReleaseCheckoutModule(checkout, 'src/main/runtime/mobile-session-terminal-retirement.ts')
])
return {
label: ref,
ledger: ledger as unknown as ProofLedger,
host: { ...proof, ...retirement } as unknown as HostProofPublisher
}
}
/**
* What a host of this build publishes when the PTY exit lands after the renderer already
* dropped the surface. `null` means it publishes nothing, which is the stuck-pane defect.
*/
function hostPublishesOnExit(build: Build, snapshot: Snapshot): Snapshot | null {
const attach = build.host.attachRetirementProofsToSnapshot
if (typeof attach !== 'function') {
// Derived, not written down: this build's only route to a proof is the removal helper,
// and with nothing left to remove it declines to produce a frame.
return (
build.host.retireTerminalSurfacesFromSnapshot({
snapshot,
ptyId: PTY_ID,
exactSurfaces: [],
exactOnly: true,
retirementProofs: [exitProof()]
})?.snapshot ?? null
)
}
return attach(snapshot, [exitProof()])
}
/** What this build's client retains after the host frame, i.e. the evidence it can act on. */
function clientRetains(build: Build, frame: Snapshot | null): Record<string, unknown>[] {
if (frame === null) {
return []
}
return build.ledger.dropRetirementProofsForLiveSurfaces(
build.ledger.appendRetiredTerminalSurfaceProofs(undefined, frame.retiredTerminalSurfaces ?? []),
frame.tabs
)
}
let preStack: Build
let stack: Build
beforeAll(async () => {
;[preStack, stack] = await Promise.all([loadBuild(PRE_STACK_REF), loadBuild(null)])
}, SUITE_TIMEOUT_MS)
describe('cross-version session-tabs retirement proof', () => {
it('pairs the stack against a real pre-stack release', () => {
expect(preStack.label).toBe(PRE_STACK_REF)
expect(typeof preStack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function')
expect(typeof stack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function')
// The anti-vacuous-pass oracle. Two builds that resolved to one module would make every
// pairing below a same-version run wearing a skew label, and all of them would pass.
expect(preStack.ledger).not.toBe(stack.ledger)
expect(preStack.ledger.dropRetirementProofsForLiveSurfaces).not.toBe(
stack.ledger.dropRetirementProofsForLiveSurfaces
)
// Load-bearing for reading the old-host cells: they mean "this release cannot publish a
// proof-only frame", not "the helper happened to decline". Safe to state against a pinned
// legacy ref, which is what PRE_STACK_REF is.
expect(preStack.host.attachRetirementProofsToSnapshot).toBeUndefined()
expect(typeof stack.host.attachRetirementProofsToSnapshot).toBe('function')
})
it('old host against old client publishes no proof on the renderer-first close (the defect)', () => {
const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished())
expect(frame).toBeNull()
expect(clientRetains(preStack, frame)).toEqual([])
})
it('new host against new client publishes a proof the client retains (the fix)', () => {
const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished())
expect(frame).not.toBeNull()
expect(clientRetains(stack, frame)).toEqual([exitProof()])
})
it('new host against OLD client: the old client acts on the proof-only frame', () => {
const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished())
expect(frame).not.toBeNull()
// The claim under test. An old client that cannot act on this frame would leave the
// dead pane in its tab bar exactly as before the fix.
expect(clientRetains(preStack, frame)).toEqual([exitProof()])
})
it('new host bumps snapshotVersion so a version-gating old client accepts the frame', () => {
const before = snapshotAfterRendererRepublished()
const frame = hostPublishesOnExit(stack, before)
// A client that drops a frame whose version did not advance would silently ignore the
// proof; this is what makes the proof-only frame reachable at all.
expect(frame?.snapshotVersion).toBeGreaterThan(before.snapshotVersion)
})
it('old host against NEW client degrades to the two-inventory route, with no crash', () => {
const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished())
expect(frame).toBeNull()
expect(clientRetains(stack, frame)).toEqual([])
})
it('both builds drop a proof whose surface is published live again, identically', () => {
const stillLive = liveSnapshot()
const proofs = [exitProof()]
// Rule 3 hazard: a proof naming a surface the host is still publishing must not retire
// it. Both builds must agree, or a skewed pairing retires a live pane.
expect(preStack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([])
expect(stack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([])
})
it('re-delivering the same exit does not fan out a second frame', () => {
const first = hostPublishesOnExit(stack, snapshotAfterRendererRepublished())
expect(first).not.toBeNull()
// A version bump carrying nothing new would wake every paired client for no reason.
expect(hostPublishesOnExit(stack, first as Snapshot)).toBeNull()
})
})
@@ -0,0 +1,154 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
/**
* The downgrade direction for persisted worktree identity.
*
* Upgrade is the easy direction. The risk PR #19955 records is the other one: a user runs a new
* build, it writes durable state, then they roll back. State the new build wrote must stay
* readable by the old one.
*
* The stack widens `migrateWorktreeIdentity` to repoint the `worktreeId` INSIDE session rows the
* pre-stack build leaves pointing at the old id. A renamed worktree therefore leaves different
* bytes on disk depending on which build did the rename, with no wire change anywhere — Rule 3's
* shape applied to persistence, which is why it is measured here rather than reasoned about.
*/
const PRE_STACK_REF = 'v1.4.199'
const SUITE_TIMEOUT_MS = 180_000
const OLD_ID = 'repo::/worktrees/before'
const NEW_ID = 'repo::/worktrees/after'
const THIRD_ID = 'repo::/worktrees/third'
const PANE_KEY = 'pane-1'
type Migrate = (state: Record<string, unknown>, oldId: string, newId: string) => boolean
type Row = { worktreeId?: string }
function sessionWithRows(): Record<string, unknown> {
return {
tabsByWorktree: { [OLD_ID]: [] },
sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, agent: 'claude' } },
terminalSurfaceTombstonesByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, retiredAt: 1 } },
closedTerminalTabTombstonesByTabId: { tab: { worktreeId: OLD_ID, closedAt: 1 } },
clientHostedBrowserCloseIntentsByEnvironment: {
env: [{ worktreeId: OLD_ID, url: 'https://example.test' }]
}
}
}
function persistedStateAfterRename(): Record<string, unknown> {
return {
worktreeMeta: { [OLD_ID]: { createdAt: 1 } },
worktreeLineageById: {},
workspaceLineageByChildKey: {},
workspaceSession: sessionWithRows(),
workspaceSessionsByHostId: {},
mobileClientTabSelectionsByDeviceId: {},
ui: { showDotfilesByWorktree: {} }
}
}
/** The `worktreeId` each row kind names after a migration, which is what downgrade turns on. */
function rowsById(state: Record<string, unknown>): Record<string, string | undefined> {
const session = state.workspaceSession as Record<string, unknown>
const record = (field: string, key: string): string | undefined =>
(session[field] as Record<string, Row> | undefined)?.[key]?.worktreeId
return {
sleepingAgentSessionsByPaneKey: record('sleepingAgentSessionsByPaneKey', PANE_KEY),
terminalSurfaceTombstonesByPaneKey: record('terminalSurfaceTombstonesByPaneKey', PANE_KEY),
closedTerminalTabTombstonesByTabId: record('closedTerminalTabTombstonesByTabId', 'tab'),
clientHostedBrowserCloseIntentsByEnvironment: (
session.clientHostedBrowserCloseIntentsByEnvironment as Record<string, Row[]> | undefined
)?.env?.[0]?.worktreeId
}
}
let preStackMigrate: Migrate
let stackMigrate: Migrate
beforeAll(async () => {
const checkout = await materializeReleaseCheckout(PRE_STACK_REF)
const [oldModule, newModule] = await Promise.all([
importReleaseCheckoutModule(
checkout,
'src/main/persistence/tracking-repos/worktree-identity-migration.ts'
),
import('../../../src/main/persistence/tracking-repos/worktree-identity-migration')
])
preStackMigrate = oldModule.migrateWorktreeIdentity as Migrate
stackMigrate = newModule.migrateWorktreeIdentity as Migrate
}, SUITE_TIMEOUT_MS)
describe('cross-version worktree identity downgrade', () => {
it('pairs two real builds', () => {
expect(typeof preStackMigrate).toBe('function')
expect(typeof stackMigrate).toBe('function')
// Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version.
expect(preStackMigrate).not.toBe(stackMigrate)
})
it('the pre-stack build repoints two of the four row kinds, and strands two', () => {
const state = persistedStateAfterRename()
expect(preStackMigrate(state, OLD_ID, NEW_ID)).toBe(true)
// Measured, not assumed: an earlier draft of this suite asserted the old build repointed
// nothing at all, and the probe that produced these four values is what corrected it.
expect(rowsById(state)).toEqual({
sleepingAgentSessionsByPaneKey: NEW_ID,
terminalSurfaceTombstonesByPaneKey: NEW_ID,
closedTerminalTabTombstonesByTabId: OLD_ID,
clientHostedBrowserCloseIntentsByEnvironment: OLD_ID
})
})
it('the stack repoints all four', () => {
const state = persistedStateAfterRename()
expect(stackMigrate(state, OLD_ID, NEW_ID)).toBe(true)
expect(rowsById(state)).toEqual({
sleepingAgentSessionsByPaneKey: NEW_ID,
terminalSurfaceTombstonesByPaneKey: NEW_ID,
closedTerminalTabTombstonesByTabId: NEW_ID,
clientHostedBrowserCloseIntentsByEnvironment: NEW_ID
})
})
it('DOWNGRADE: the old build reads new-build state without loss or throw', () => {
const state = persistedStateAfterRename()
stackMigrate(state, OLD_ID, NEW_ID)
// The rolled-back build renames again over state the new build wrote. Nothing it does not
// understand may throw, and no row may vanish.
expect(() => preStackMigrate(state, NEW_ID, THIRD_ID)).not.toThrow()
expect(rowsById(state)).toEqual({
sleepingAgentSessionsByPaneKey: THIRD_ID,
terminalSurfaceTombstonesByPaneKey: THIRD_ID,
// The two this build cannot repoint stay where the NEW build put them — stale, but present,
// and no worse than this build's own renames already leave them. That is the #19955 check:
// new-build state does not break the old build.
closedTerminalTabTombstonesByTabId: NEW_ID,
clientHostedBrowserCloseIntentsByEnvironment: NEW_ID
})
})
it('UPGRADE: the stack inherits, and does not resurrect, rows an old build stranded', () => {
const state = persistedStateAfterRename()
preStackMigrate(state, OLD_ID, NEW_ID)
stackMigrate(state, NEW_ID, THIRD_ID)
expect(rowsById(state)).toEqual({
sleepingAgentSessionsByPaneKey: THIRD_ID,
terminalSurfaceTombstonesByPaneKey: THIRD_ID,
// Still on the id the old build stranded them under: the stack repoints from the id it is
// renaming, and these never reached it. It fixes new renames, not damage already on disk.
closedTerminalTabTombstonesByTabId: OLD_ID,
clientHostedBrowserCloseIntentsByEnvironment: OLD_ID
})
})
it('neither build drops a row shape it does not recognise', () => {
const state = persistedStateAfterRename()
const session = state.workspaceSession as Record<string, unknown>
session.someFutureFieldByKey = { k: { worktreeId: OLD_ID, fromANewerBuild: true } }
preStackMigrate(state, OLD_ID, NEW_ID)
expect((state.workspaceSession as Record<string, unknown>).someFutureFieldByKey).toEqual({
k: { worktreeId: OLD_ID, fromANewerBuild: true }
})
})
})