From 6a03522de14721f1ac06fa522b2f859262adb8de Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 19:34:02 -0700 Subject: [PATCH] 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. --- ...session-tabs-retirement-proof.unit.test.ts | 253 ++++++++++++++++++ ...n-worktree-identity-downgrade.unit.test.ts | 154 +++++++++++ 2 files changed, 407 insertions(+) create mode 100644 tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts diff --git a/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts new file mode 100644 index 00000000000..ad44f1203ce --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts @@ -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[] + retiredTerminalSurfaces?: Record[] +} + +type ProofLedger = { + appendRetiredTerminalSurfaceProofs: ( + existing: readonly Record[] | undefined, + retired: readonly Record[] + ) => Record[] + dropRetirementProofsForLiveSurfaces: ( + retired: readonly Record[], + tabs: readonly Record[] + ) => Record[] +} + +type HostProofPublisher = { + attachRetirementProofsToSnapshot?: ( + snapshot: Snapshot, + proofs: readonly Record[] + ) => Snapshot | null + retireTerminalSurfacesFromSnapshot: ( + args: Record + ) => { 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 { + return { + parentTabId: PARENT_TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + terminal: TERMINAL_HANDLE, + incarnationId: 'inc-1' + } +} + +async function loadBuild(ref: string | null): Promise { + 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[] { + 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() + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts new file mode 100644 index 00000000000..6bd21f1ee8c --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts @@ -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, oldId: string, newId: string) => boolean +type Row = { worktreeId?: string } + +function sessionWithRows(): Record { + 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 { + 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): Record { + const session = state.workspaceSession as Record + const record = (field: string, key: string): string | undefined => + (session[field] as Record | undefined)?.[key]?.worktreeId + return { + sleepingAgentSessionsByPaneKey: record('sleepingAgentSessionsByPaneKey', PANE_KEY), + terminalSurfaceTombstonesByPaneKey: record('terminalSurfaceTombstonesByPaneKey', PANE_KEY), + closedTerminalTabTombstonesByTabId: record('closedTerminalTabTombstonesByTabId', 'tab'), + clientHostedBrowserCloseIntentsByEnvironment: ( + session.clientHostedBrowserCloseIntentsByEnvironment as Record | 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 + session.someFutureFieldByKey = { k: { worktreeId: OLD_ID, fromANewerBuild: true } } + preStackMigrate(state, OLD_ID, NEW_ID) + expect((state.workspaceSession as Record).someFutureFieldByKey).toEqual({ + k: { worktreeId: OLD_ID, fromANewerBuild: true } + }) + }) +})