Files
orca/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
Neil 9d1826ae65 fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057)
* fix(session): keep a renamed worktree's rows from matching on the id it lost

Three persisted session fields survived a worktree re-key still naming the old
identity. Two of them are suppression records, so a stale id does not read as
residue -- it silently re-admits state the user removed:

- closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host
  tab when the tombstone's worktree equals the tab's, and no snapshot ever
  covers the old id, so the tombstone never retires either.
- clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's
  worktree, and an unresolvable selector answers selector_not_found -- which the
  replay reads as definitively gone and uses to DROP the intent.
- clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against
  the row's own workspaceId, so both halves have to move or the pages are never
  rehydrated.

Fixed on both sides of the rename: the main-process persisted migration and the
renderer's live store, which would otherwise write the stale values straight
back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND,
the census these three fell out of, with the shipping owner collector as its
oracle.

* docs(session): record why a re-key clobbering an existing target stays unfixed

Not a missing guard -- an unresolvable one. Keeping the target is correct when
it holds a real closed-last-terminal tombstone; keeping the source is correct
when the target row is a stub; nothing records which is newer. The recency map
is the only one that can settle it, because Math.max needs no such ordering.

* test(persistence): measure the downgrade direction for worktree identity

The stack widens migrateWorktreeIdentity to repoint worktreeId inside
session rows. That changes what lands on disk with no wire change, which
is Rule 3's shape applied to persistence, so it is measured against
v1.4.199 rather than reasoned about.

Result: new-build state does not break the old build. The old build
renames over it without throwing and loses no row; the two row kinds it
cannot repoint stay stale, which is exactly what its own renames already
produce.

The numbers are measured. A first draft asserted the old build repointed
no inner rows at all; it repoints two of four, and the probe is what
caught that.

* test(ci): run the worktree-identity downgrade lane instead of describing it

The cross-version job names its files explicitly, so a new one is inert until
it is listed; the sharded unit job excludes the whole directory and the E2E
router only takes `*.spec.ts`. Also pairs the forward-compat case against the
current build — the stack's own field-list walk is the guarantee that matters,
and only the frozen build was exercised.

* refactor(session): drop the type assertions the rename migration leaned on

`consistent-type-assertions` landed on main after this branch last built, and
three of the `as never` fixtures were hiding real contract drift: a browser
workspace row missing six required fields, a tab group naming three fields the
type does not have while omitting the two it requires, and a sleeping-agent row
whose `providerSession` had neither `key` nor `id` and whose `state` was not in
`AgentStatusState`.

Indexing the session by a computed field name is what forced the casts in the
migration, so the four row maps are now spelled out; the census test is what
keeps a fifth from joining silently. The renderer test builds its state from
the real slice instead of casting a four-field partial.

* refactor(test): name the module namespace the skew harness reads

`object` is too broad for the anti-slop gate, and the import helper already
declares what it hands back.

* docs(test): say which maps the harness actually supplies

The two under test live in slices this harness does not mount, so calling it
"the real slice's state" overclaimed.
2026-09-18 01:11:03 -07:00

200 lines
8.6 KiB
TypeScript

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'
/**
* Declared locally, NOT as today's `WorkspaceSessionState`: the blob crosses two builds, so typing
* it against either one would let the current contract rewrite what the other build sees.
*/
type Row = {
worktreeId: string
}
type CrossVersionSession = {
tabsByWorktree: Record<string, unknown[]>
sleepingAgentSessionsByPaneKey: Record<string, Row & { agent: string }>
terminalSurfaceTombstonesByPaneKey: Record<string, Row & { retiredAt: number }>
closedTerminalTabTombstonesByTabId: Record<string, Row & { closedAt: number }>
clientHostedBrowserCloseIntentsByEnvironment: Record<string, (Row & { url: string })[]>
/** A field neither build under test knows; the forward-compat cells plant it. */
someFutureFieldByKey?: Record<string, Row & { fromANewerBuild: boolean }>
}
type CrossVersionPersistedState = {
worktreeMeta: Record<string, { createdAt: number }>
worktreeLineageById: Record<string, never>
workspaceLineageByChildKey: Record<string, never>
workspaceSession: CrossVersionSession
workspaceSessionsByHostId: Record<string, never>
mobileClientTabSelectionsByDeviceId: Record<string, never>
ui: { showDotfilesByWorktree: Record<string, never> }
}
type Migrate = (state: CrossVersionPersistedState, oldId: string, newId: string) => boolean
function isMigrate(value: unknown): value is Migrate {
return typeof value === 'function'
}
/** What both sides of the skew hand back: a frozen build's namespace and the current one's. */
type MigrationModuleNamespace = Record<string, unknown>
/** Both builds' exports resolve the same way, so neither is typed against its own build's state. */
function migrateExportOf(module: MigrationModuleNamespace): Migrate {
const candidate = module.migrateWorktreeIdentity
if (!isMigrate(candidate)) {
throw new Error('module does not export migrateWorktreeIdentity')
}
return candidate
}
function sessionWithRows(): CrossVersionSession {
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(): CrossVersionPersistedState {
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: CrossVersionPersistedState): Record<string, string | undefined> {
const session = state.workspaceSession
return {
sleepingAgentSessionsByPaneKey: session.sleepingAgentSessionsByPaneKey[PANE_KEY]?.worktreeId,
terminalSurfaceTombstonesByPaneKey:
session.terminalSurfaceTombstonesByPaneKey[PANE_KEY]?.worktreeId,
closedTerminalTabTombstonesByTabId: session.closedTerminalTabTombstonesByTabId.tab?.worktreeId,
clientHostedBrowserCloseIntentsByEnvironment:
session.clientHostedBrowserCloseIntentsByEnvironment.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 = migrateExportOf(oldModule)
stackMigrate = migrateExportOf(newModule)
}, 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
})
})
// Both builds, because the load-bearing forward-compat guarantee is the CURRENT build's: the
// stack repoints rows by walking a fixed field list, and a field a later build adds must pass
// through untouched rather than be swept in by anything name-shaped.
it.each([
['the pre-stack build', (): Migrate => preStackMigrate],
['the stack', (): Migrate => stackMigrate]
])('%s drops no row shape it does not recognise', (_label, migrateOf) => {
const state = persistedStateAfterRename()
state.workspaceSession.someFutureFieldByKey = {
k: { worktreeId: OLD_ID, fromANewerBuild: true }
}
expect(() => migrateOf()(state, OLD_ID, NEW_ID)).not.toThrow()
expect(state.workspaceSession.someFutureFieldByKey).toEqual({
k: { worktreeId: OLD_ID, fromANewerBuild: true }
})
})
})