mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* 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.
200 lines
8.6 KiB
TypeScript
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 }
|
|
})
|
|
})
|
|
})
|