Save-divergence detector: skip empty workspaces, see dropped tab-bar entries and kept pane trees (dark, layout-owner build only) (#30125)

* Save-divergence detector: skip empty workspaces, see dropped tab-bar entries and kept pane trees (dark, layout-owner build only)

- A workspace key that names nothing (or only places a stored closed-tab record) is no longer
  reported as "workspace added/removed" or "terminal row owner changed".
- A save that drops a terminal tab's tab-bar entry while keeping its row is reported, read from
  the stored session before the Loader rebuilds the entry.
- A pane tree main keeps over the window's (windowSaveForMain) is reported, so a window-side
  pane repair shows up.

* Detector: keep reporting empty-row flips, cover every overridden pane tree and one-sided closed tabs (dark, layout-owner build only)

* Detector: a save with no pane tree for a tab main keeps one for is reported too (dark, layout-owner build only)

* Detector: a closed tab record moved to another worktree in one save is reported (dark, layout-owner build only)
This commit is contained in:
Jinwoo Hong
2026-10-17 16:49:24 -07:00
committed by GitHub
parent 532cad2cb3
commit 45f7429600
5 changed files with 404 additions and 38 deletions
+2 -1
View File
@@ -68,7 +68,8 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
() => store.getWorkspaceSession(hostId),
hostId,
stage,
'shutdown-checkpoint'
'shutdown-checkpoint',
state.terminalLayoutsByTabId
)
} else {
stage()
+12 -4
View File
@@ -33,13 +33,19 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
// Switch point: main keeps its own pane trees over the window's, and reports any layout the
// save still changes.
const saveFromWindow = (hostId: string | null | undefined, write: () => void, source: string) =>
const saveFromWindow = (
hostId: string | null | undefined,
write: () => void,
source: string,
save: WorkspaceSessionState | WorkspaceSessionPatch
) =>
ORCA_LAYOUT_OWNER_IS_MAIN
? writeWindowSaveWatchingLayout(
() => store.getWorkspaceSession(hostId),
hostId,
write,
source
source,
save.terminalLayoutsByTabId
)
: write()
const forMain = <T extends WorkspaceSessionState | WorkspaceSessionPatch>(
@@ -55,7 +61,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
saveFromWindow(
hostId,
() => store.setWorkspaceSession(forMain(args, hostId), hostId),
'session:set'
'session:set',
args
)
}
})
@@ -65,7 +72,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
saveFromWindow(
hostId,
() => store.patchWorkspaceSession(forMain(args, hostId), hostId),
'session:patch'
'session:patch',
args
)
}
})
@@ -10,6 +10,7 @@ import {
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { writeWindowSaveWatchingLayout } from './window-save-divergence'
import { windowSaveForMain } from './window-save-pane-trees'
function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState {
return addWorkspace(emptySession(), GIT_KEY, [
@@ -185,16 +186,16 @@ describe('writeWindowSaveWatchingLayout', () => {
)
})
it('reports a default-tab record of a workspace with no tabs, and a terminal row owner', () => {
it('reports a default-tab record of a workspace with no tabs', () => {
const stored = emptySession()
const next = {
...emptySession(),
defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const },
tabsByWorktree: { 'repo-1::/Users/dev/empty': [] }
defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const }
}
save(stored, next)
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
@@ -203,16 +204,209 @@ describe('writeWindowSaveWatchingLayout', () => {
paths: ['defaultTabsAppliedByWorkspace changed']
})
)
})
it('stays quiet for a save that only names a workspace with nothing in it', () => {
const key = 'repo-1::/Users/dev/empty'
const next = { ...emptySession(), unifiedTabs: { [key]: [] }, tabGroups: { [key]: [] } }
save(emptySession(), next)
save(next, emptySession())
expect(warn).not.toHaveBeenCalled()
})
it('reports an empty terminal row a save adds or drops: a relaunch reads it there', () => {
const key = 'repo-1::/Users/dev/emptied'
const emptied = { ...emptySession(), tabsByWorktree: { [key]: [] } }
save(emptySession(), emptied)
save(emptied, emptySession())
expect(warn).toHaveBeenCalledTimes(2)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: key, paths: ['terminal row owner changed'] })
)
})
it('reports a closed tab record only when the save adds or drops it, not when a key places it', () => {
const key = 'repo-1::/Users/dev/empty'
const closed = {
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }
}
const stored = { ...emptySession(), ...closed }
const named = { ...stored, unifiedTabs: { [key]: [] } }
save(stored, named)
expect(warn).not.toHaveBeenCalled()
save(named, { ...named, closedTerminalTabTombstonesByTabId: {} })
save({ ...emptySession(), unifiedTabs: {} }, named)
for (const path of ['closed tab tab-x removed', 'closed tab tab-x added']) {
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: key, paths: [path] })
)
}
expect(warn).toHaveBeenCalledTimes(2)
})
it('reports a closed tab record the save moves into a workspace only it names', () => {
const from = 'repo-1::/Users/dev/old'
const to = 'repo-1::/Users/dev/new'
const closedIn = (key: string) => ({
...emptySession(),
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }
})
save(closedIn(from), { ...closedIn(to), unifiedTabs: { [to]: [] } })
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: to, paths: ['closed tab tab-x added'] })
)
})
it('still reports a workspace with tabs that the save adds or drops', () => {
save(emptySession(), session([{ id: 'tab-a' }]))
save(session([{ id: 'tab-a' }]), emptySession())
for (const path of ['workspace added', 'workspace removed']) {
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: GIT_KEY, paths: expect.arrayContaining([path]) })
)
}
})
it('reports a save that drops a terminal tab’s tab-bar entry but keeps its row', () => {
const stored = session([{ id: 'tab-a' }, { id: 'tab-b' }])
const next = structuredClone(stored)
next.unifiedTabs![GIT_KEY] = next.unifiedTabs![GIT_KEY]!.filter((tab) => tab.id !== 'tab-b')
save(stored, next)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: 'repo-1::/Users/dev/empty',
paths: expect.arrayContaining(['terminal row owner changed'])
workspace: GIT_KEY,
paths: expect.arrayContaining(['terminal tab-b tab-bar entry removed'])
})
)
})
it('stays quiet for a save that carries no tab bar for the workspace yet', () => {
const stored = session([{ id: 'tab-a' }])
const next = structuredClone(stored)
delete next.unifiedTabs![GIT_KEY]
save(stored, next)
expect(warn).not.toHaveBeenCalled()
})
it('reports a pane tree main keeps over the save’s, and still writes the save', () => {
const stored = session([{ id: 'tab-a' }])
const incoming = structuredClone(stored)
incoming.terminalLayoutsByTabId['tab-a'] = {
...incoming.terminalLayoutsByTabId['tab-a']!,
root: {
type: 'split',
direction: 'vertical',
ratio: 0.5,
first: { type: 'leaf', leafId: leaf(1) },
second: { type: 'leaf', leafId: leaf(2) }
}
}
let current = stored
writeWindowSaveWatchingLayout(
() => current,
'local',
() => {
current = windowSaveForMain(current, incoming, 'local')
},
'session:set',
incoming.terminalLayoutsByTabId
)
expect(current.terminalLayoutsByTabId['tab-a']?.root).toEqual(
stored.terminalLayoutsByTabId['tab-a']?.root
)
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: GIT_KEY,
paths: ['pane tree tab-a kept over the save’s']
})
)
})
it('reports a pane tree main keeps for a tab no workspace places', () => {
const stored = session([{ id: 'tab-a' }])
const layout = stored.terminalLayoutsByTabId['tab-a']!
stored.terminalLayoutsByTabId.orphan = { ...layout, root: { type: 'leaf', leafId: leaf(7) } }
const saved = { orphan: { ...layout, root: { type: 'leaf' as const, leafId: leaf(8) } } }
writeWindowSaveWatchingLayout(
() => stored,
'local',
() => {},
'session:set',
saved
)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: '(no workspace)',
paths: ['pane tree orphan kept over the save’s']
})
)
})
it('reports a save that sends no pane tree for a tab main keeps one for', () => {
const stored = session([{ id: 'tab-a' }])
const saved = { 'tab-a': { ...stored.terminalLayoutsByTabId['tab-a']!, root: null } }
writeWindowSaveWatchingLayout(
() => stored,
'local',
() => {},
'session:set',
saved
)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: GIT_KEY,
paths: ['pane tree tab-a kept over the save’s']
})
)
})
it('stays quiet for a save whose pane trees match main’s', () => {
const stored = session([{ id: 'tab-a' }])
const incoming = structuredClone(stored)
writeWindowSaveWatchingLayout(
() => stored,
'local',
() => {},
'session:set',
incoming.terminalLayoutsByTabId
)
expect(warn).not.toHaveBeenCalled()
})
it('leaves a paired server’s partition alone: its layout is the server’s', () => {
const next = session([{ id: 'tab-c' }])
expect(save(session([{ id: 'tab-a' }]), next, 'runtime:env-1')).toBe(next)
+81 -28
View File
@@ -18,6 +18,13 @@ import type {
} from '../../shared/workspace-layout/workspace-layout-model'
import type { TabGroupLayoutNode } from '../../shared/tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
closedTabsOfOneSidedWorkspaces,
droppedTabBarEntries,
overriddenPaneTrees,
terminalTabBarEntries,
type SavedPaneTrees
} from './window-save-raw-session-divergence'
/** Set by evidence runs: each divergence is appended there as one JSON line. */
const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG'
@@ -117,6 +124,30 @@ function reboundLeaves(
return { rebound, was, now }
}
/**
* Whether a workspace holds anything. The Loader makes one for every key a save names and places a
* stored closed-tab record by worktree id into it, so neither makes one by itself.
*/
function hasContent(workspace: WorkspaceLayout | undefined): workspace is WorkspaceLayout {
return Boolean(
workspace &&
(workspace.tabs.length > 0 ||
workspace.groups.length > 0 ||
workspace.groupLayout ||
[workspace.editorFiles, workspace.browserTabs, workspace.editorViews, workspace.leaves].some(
(items) => items && Object.keys(items).length > 0
))
)
}
function diffClosedTabs(before: WorkspaceLayout, after: WorkspaceLayout): string[] {
return diffKeyed(
'closed tab',
recordById(before.closedTerminalTabs),
recordById(after.closedTerminalTabs)
)
}
/** A lone group with no stored tree is that group's leaf, as main's own save writes it. */
function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null {
const [only, ...rest] = workspace.groups
@@ -130,11 +161,12 @@ export function diffWorkspaceLayouts(
before: WorkspaceLayout | undefined,
after: WorkspaceLayout | undefined
): WorkspaceDiff {
if (!before || !after) {
return {
paths: before === after ? [] : [after ? 'workspace added' : 'workspace removed'],
rebound: 0
if (!hasContent(before) || !hasContent(after)) {
if (hasContent(before) || hasContent(after)) {
return { paths: [hasContent(after) ? 'workspace added' : 'workspace removed'], rebound: 0 }
}
// Both hold no tab: only a closed-tab record of a workspace both copies name can differ.
return { paths: before && after ? diffClosedTabs(before, after) : [], rebound: 0 }
}
const leaves = reboundLeaves(before.leaves, after.leaves)
const paths = [
@@ -159,11 +191,7 @@ export function diffWorkspaceLayouts(
listById(before.browserTabs, (b) => b.id),
listById(after.browserTabs, (b) => b.id)
),
...diffKeyed(
'closed tab',
recordById(before.closedTerminalTabs),
recordById(after.closedTerminalTabs)
),
...diffClosedTabs(before, after),
...diffKeyed('leaf', leaves.was, leaves.now)
]
if (stableJson(groupTreeOf(before)) !== stableJson(groupTreeOf(after))) {
@@ -172,29 +200,33 @@ export function diffWorkspaceLayouts(
return { paths, rebound: leaves.rebound.size }
}
/** Each workspace whose part of main's model differs between two copies of one partition. */
/**
* Each workspace whose part of main's model differs between two copies of one partition, plus
* `rawPaths`: what only the stored session itself shows, by workspace.
*/
export function windowSaveDivergences(
was: WorkspaceLayoutModel | null,
now: WorkspaceLayoutModel | null
now: WorkspaceLayoutModel | null,
rawPaths: ReadonlyMap<string, string[]> = new Map()
): WindowSaveDivergence[] {
// Records count for a workspace with no tabs too.
const workspaces = new Set(
[was, now].flatMap((model) =>
const workspaces = new Set([
...[was, now].flatMap((model) =>
model
? [
...Object.keys(model.workspaces),
...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {})),
...Object.keys(model.legacy.terminalRowOwners)
...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {}))
]
: []
)
)
),
...rawPaths.keys()
])
const result: WindowSaveDivergence[] = []
for (const workspace of workspaces) {
const { paths, rebound } = diffWorkspaceLayouts(
was?.workspaces[workspace],
now?.workspaces[workspace]
)
const before = was?.workspaces[workspace]
const after = now?.workspaces[workspace]
const { paths: modelPaths, rebound } = diffWorkspaceLayouts(before, after)
const paths = [...modelPaths, ...(rawPaths.get(workspace) ?? [])]
for (const record of RECORDS) {
if (
stableJson(was?.records[record]?.[workspace] ?? null) !==
@@ -203,6 +235,7 @@ export function windowSaveDivergences(
paths.push(`${record} changed`)
}
}
// Counted for an empty workspace too: a relaunch reads it there to skip the first terminal.
if (
Boolean(was?.legacy.terminalRowOwners[workspace]) !==
Boolean(now?.legacy.terminalRowOwners[workspace])
@@ -238,15 +271,17 @@ function report(source: string, partition: ExecutionHostId, divergence: WindowSa
}
/**
* Runs the window's save `write` and reports what it changed in main's layout model. A paired
* server's partition is skipped: its layout is the server's, mirrored by the window. Nothing here
* can fail the save: the write's own errors propagate, the check's are only logged.
* Runs the window's save `write` and reports what it changed in main's layout model, and which of
* its `savedPaneTrees` (as the window sent them) main keeps its own tree over. A paired server's
* partition is skipped: its layout is the server's, mirrored by the window. Nothing here can fail
* the save: the write's own errors propagate, the check's are only logged.
*/
export function writeWindowSaveWatchingLayout(
read: () => WorkspaceSessionState | null | undefined,
hostId: string | null | undefined,
write: () => void,
source: string
source: string,
savedPaneTrees?: SavedPaneTrees
): void {
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
if (parseExecutionHostId(partition)?.kind === 'runtime') {
@@ -254,17 +289,35 @@ export function writeWindowSaveWatchingLayout(
return
}
let was: WorkspaceLayoutModel | null | undefined
let wasEntries: Map<string, Set<string>> | undefined
let wasClosed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId']
const rawPaths = new Map<string, string[]>()
try {
was = layoutModelOf(read(), partition)
const stored = read()
was = layoutModelOf(stored, partition)
wasEntries = stored ? terminalTabBarEntries(stored) : new Map()
wasClosed = stored?.closedTerminalTabTombstonesByTabId
if (savedPaneTrees) {
overriddenPaneTrees(stored, was, savedPaneTrees, rawPaths)
}
} catch (error) {
was = undefined
console.warn('[layout-owner] save divergence check failed:', error)
}
write()
if (was === undefined) {
if (was === undefined || wasEntries === undefined) {
return
}
try {
for (const divergence of windowSaveDivergences(was, layoutModelOf(read(), partition))) {
const now = read()
const nowModel = layoutModelOf(now, partition)
droppedTabBarEntries(wasEntries, now, rawPaths)
closedTabsOfOneSidedWorkspaces(
{ model: was, closed: wasClosed },
{ model: nowModel, closed: now?.closedTerminalTabTombstonesByTabId },
rawPaths
)
for (const divergence of windowSaveDivergences(was, nowModel, rawPaths)) {
report(source, partition, divergence)
}
} catch (error) {
@@ -0,0 +1,110 @@
// What a window's save changes that main's layout model cannot show, read from the stored session
// itself: the Loader rebuilds a dropped terminal tab-bar entry from its row, places closed-tab
// records into whatever workspace a key names, and main keeps its own pane trees over the save's.
// Each finding is added to `paths` under its workspace.
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model'
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
/** The report key of a pane tree whose tab no workspace names. */
const UNPLACED = '(no workspace)'
export type SavedPaneTrees = Readonly<Record<string, TerminalLayoutSnapshot>>
/** The terminal tabs (by entity id) each workspace's stored tab bar names. */
export function terminalTabBarEntries(session: WorkspaceSessionState): Map<string, Set<string>> {
return new Map(
Object.entries(session.unifiedTabs ?? {}).map(([workspace, entries]) => [
workspace,
new Set(
entries.flatMap((entry) => (entry.contentType === 'terminal' ? [entry.entityId] : []))
)
])
)
}
function addPath(paths: Map<string, string[]>, workspace: string, path: string): void {
paths.set(workspace, [...(paths.get(workspace) ?? []), path])
}
/**
* A tab-bar entry the save dropped from a tab bar it still writes, while the row stays: the Loader
* rebuilds that entry from the row, so main's model shows no change.
*/
export function droppedTabBarEntries(
wasEntries: ReadonlyMap<string, ReadonlySet<string>>,
now: WorkspaceSessionState | null | undefined,
paths: Map<string, string[]>
): void {
const nowEntries = now ? terminalTabBarEntries(now) : new Map<string, Set<string>>()
for (const [workspace, entities] of wasEntries) {
const kept = nowEntries.get(workspace)
// No tab bar at all: a save that predates main's first frame for the workspace.
if (!kept) {
continue
}
const rows = new Set((now?.tabsByWorktree?.[workspace] ?? []).map((row) => row.id))
for (const entityId of entities) {
if (!kept.has(entityId) && rows.has(entityId)) {
addPath(paths, workspace, `terminal ${entityId} tab-bar entry removed`)
}
}
}
}
/**
* The save's pane trees that differ from main's: main keeps its own for every tab it has one for
* (windowSaveForMain), so each is reported under the workspace whose tab or row names it.
*/
export function overriddenPaneTrees(
stored: WorkspaceSessionState | null | undefined,
model: WorkspaceLayoutModel | null,
saved: SavedPaneTrees,
paths: Map<string, string[]>
): void {
const homes = new Map<string, string>()
for (const [workspace, rows] of Object.entries(stored?.tabsByWorktree ?? {})) {
rows.forEach((row) => homes.set(row.id, workspace))
}
for (const [workspace, layout] of Object.entries(model?.workspaces ?? {})) {
layout.tabs.forEach((tab) => homes.set(tab.entityId, workspace))
}
for (const [tabId, layout] of Object.entries(saved)) {
const mains = stored?.terminalLayoutsByTabId?.[tabId]?.root
if (mains && stableJson(mains) !== stableJson(layout.root ?? null)) {
addPath(paths, homes.get(tabId) ?? UNPLACED, `pane tree ${tabId} kept over the save’s`)
}
}
}
type PartitionCopy = {
model: WorkspaceLayoutModel | null
closed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId']
}
/**
* Closed-tab records of a workspace only one copy has: the model diff cannot tell a record the
* Loader just placed there (already stored, unplaced) from one the save added or dropped.
*/
export function closedTabsOfOneSidedWorkspaces(
was: PartitionCopy,
now: PartitionCopy,
paths: Map<string, string[]>
): void {
const oneSided = (from: PartitionCopy, other: PartitionCopy, change: string) => {
for (const [workspace, layout] of Object.entries(from.model?.workspaces ?? {})) {
if (other.model?.workspaces[workspace]) {
continue
}
for (const tabId of Object.keys(layout.closedTerminalTabs ?? {})) {
if (other.closed?.[tabId]?.worktreeId !== layout.worktreeId) {
addPath(paths, workspace, `closed tab ${tabId} ${change}`)
}
}
}
}
oneSided(now, was, 'added')
oneSided(was, now, 'removed')
}