fix(session): adopt every workspace the host partition names, not only tabbed ones

Review caught that gating adoption on `host.tabsByWorktree[key].length > 0` traded
the #12721 deletion for a narrower one. The write path routes EVERY worktree-scoped
field to the owning partition, so an SSH workspace with open editor files or browser
tabs and no terminals had all of it dropped on every restart — and unlike terminal
state it cannot be recovered from the host snapshot, which carries terminal fields
only, so an unsaved `dirtyDraftContent` was destroyed outright.

The defect was not a missing field. It was a hand-maintained field list deciding what
the read recovers while the write used the ownership table, so the two could disagree.
Adoption now walks `WORKSPACE_SESSION_FIELD_OWNERSHIP` with an exhaustive switch, and
a new ownership kind is a compile-time decision rather than a silent omission.

Session keys are normalized through the shared `normalizeWorkspaceSessionKeyToWorkspaceId`
so host-qualified visit recency (`ssh:target|worktreeId`) reaches its workspace, and the
regression is pinned by feeding the shipping split's own output back through the real
boot read rather than a hand-built fixture.

Co-authored-by: Robert Nisipeanu <github@nisipeanu.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
Neil
2026-09-11 01:42:30 -07:00
co-authored by Robert Nisipeanu Jinwoo-H
parent d51df63555
commit 30329e2fe1
5 changed files with 392 additions and 134 deletions
+8 -7
View File
@@ -258,7 +258,7 @@
{
"id": "workspace-session.ssh-host-partition-round-trip",
"title": "An SSH workspace's tabs are never round-tripped to the host as an empty list",
"title": "An SSH workspace round-trips through its own partition without losing tabs, editor files or browser state",
"maturity": "experimental",
"protection": "partial",
"owner": "workspace-session-persistence",
@@ -278,8 +278,8 @@
"https://github.com/stablyai/orca/issues/18173",
"https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts"
],
"invariant": "A workspace whose tab list exists only in its `ssh:<targetId>` partition is hydrated at boot and published to the host with those tabs. An empty tab row is read as a gap, never as evidence that the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds tabs for is left untouched, and every workspace routes back to the one partition that owns it.",
"oracle": "Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:<targetId>` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately hydrate through the real boot read, export and re-import through the real projection, and merge through mergeDirectSshRemoteWorkspaceSession, asserting the tabs survive the publish and the next pull. Assert the reunited workspace routes to `ssh:<targetId>`, that a populated local row is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.",
"invariant": "Every workspace the `ssh:<targetId>` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it.",
"oracle": "Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:<targetId>` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:<targetId>`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts"
],
@@ -330,8 +330,8 @@
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts",
"result": "passed",
"durationSeconds": 2.94,
"summary": "35 tests passed across 4 files, including the real Store publish and the real export/import/merge round trip."
"durationSeconds": 2.45,
"summary": "43 tests passed across 4 files, including the real Store publish, the shipping write/read split round trip, and the older-client empty-publish skew direction."
}
],
"runtimeBudget": {
@@ -344,7 +344,7 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Against pristine main ea102a9eb89 the boot read returned no tabs for the worktree, the export published tabsByWorktreePath[path] = [], the round trip ended with the tabs deleted, and routing answered 'local' instead of ssh:target-1 - 8 of 9 new assertions failed. All pass after the fix. Each assertion was additionally mutation-checked against the specific regression it claims to catch; the load-bearing one, treating an empty tab row as an answer rather than a gap, fails 6."
"evidence": "Against pristine main 12f53da542d, 9 of the 11 assertions that target the original defect fail: the boot read returns no tabs, the export publishes an empty tabsByWorktreePath row, the round trip ends with the tabs deleted, routing answers local instead of ssh:target-1, and an older client's empty publish deletes the tabs on pull. The remaining assertions guard the fix itself rather than main's bug: review found that gating adoption on terminal tabs stranded editor-only and browser-only SSH workspaces, destroying unsaved hot-exit drafts no other channel can recover. The gate now discovers a workspace through an exhaustive switch over the field-ownership table, and reintroducing the tabs-only gate fails 6 assertions including the write/read round trip."
},
"performanceBudget": {
"required": false,
@@ -353,7 +353,8 @@
"knownGaps": [
"No live SSH host or relay is exercised; the multiplexer is faked at the request boundary.",
"Folder workspaces still persist to the local partition, so their half of the writer divergence in #12723 is not covered here.",
"Tabs stranded beside an already-populated local row are deliberately not recovered, and no assertion claims they are."
"Rows stranded beside a workspace the local partition already holds terminal tabs for are deliberately not recovered, and no assertion claims they are.",
"One-shot resurrection in the legacy-transition shape: where an older build left an empty local row while the runtime partition still holds that workspace's tabs, boot adopts them back once. Non-destructive and does not recur, because a workspace this build empties deliberately leaves an empty row in the owning partition, which is not adoptable."
],
"promotionCriteria": [
"Complete the CI soak requirement with no unexplained flakes.",
@@ -5,11 +5,7 @@ import {
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import { normalizeWorkspaceSessionKeyToWorkspaceId } from '../../../shared/workspace-scope'
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner'
import {
@@ -51,14 +47,9 @@ const WORKTREE_KEYED_FIELDS = (
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed')
/** Bare worktree id behind a session key, which may be a WorkspaceKey or a host-qualified identity. */
export function normalizeWorkspaceSessionKeyToWorktreeId(value: string): string {
if (isWorktreeHostIdentity(value)) {
return getWorktreeIdFromHostIdentity(value)
}
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
/** Bare worktree id behind a session key. Lives in shared because the partition adoption read needs
* the same normalization, and two implementations of it would drift. */
export const normalizeWorkspaceSessionKeyToWorktreeId = normalizeWorkspaceSessionKeyToWorkspaceId
function resolveClaimedHostId(
worktree: WorkspaceRuntimeOwnerProjection,
@@ -154,6 +154,166 @@ describe('ssh host partition hydration', () => {
})
})
describe('ssh host partition workspaces with no terminal tabs', () => {
/** An SSH workspace the user left with an editor open and every terminal closed. Orca does not
* auto-create a terminal while other tabs exist, so this is an ordinary state — and the whole
* workspace now persists to `ssh:<targetId>`, tabs or no tabs. */
function editorOnlyPartitions() {
return {
local: session({ tabsByWorktree: {} }),
[SSH_HOST_ID]: session({
tabsByWorktree: { [WORKTREE_ID]: [] },
openFilesByWorktree: {
[WORKTREE_ID]: [
{
filePath: '/remote/checkout/feature/src/main.ts',
relativePath: 'src/main.ts',
worktreeId: WORKTREE_ID,
language: 'typescript',
dirtyDraftContent: 'unsaved work'
}
]
},
activeFileIdByWorktree: { [WORKTREE_ID]: '/remote/checkout/feature/src/main.ts' },
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' },
browserTabsByWorktree: {
[WORKTREE_ID]: [{ id: 'browser-1', name: 'Docs', tabs: [], activeTabId: null }]
},
lastVisitedAtByWorktreeId: { [`${SSH_HOST_ID}|${WORKTREE_ID}`]: 4242 }
} as unknown as WorkspaceSessionState)
}
}
it('restores the open editor files', async () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(editorOnlyPartitions()),
repos
)
expect(
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => file.relativePath)
).toEqual(['src/main.ts'])
})
it('restores an unsaved hot-exit draft, which no other channel can recover', async () => {
// RemoteWorkspaceSession carries terminal fields only, so the SSH host snapshot cannot
// round-trip editor state. Losing it here loses user-authored content outright.
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(editorOnlyPartitions()),
repos
)
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
'unsaved work'
)
})
it('restores browser workspaces and the active tab type', async () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(editorOnlyPartitions()),
repos
)
expect(read.session.browserTabsByWorktree?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
'browser-1'
])
expect(read.session.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
})
it('restores a workspace the host partition names with no tabs row at all', async () => {
// Stricter than the fixtures above, which carry an empty `tabsByWorktree` key. A workspace that
// never had a terminal has no such key, so tab presence cannot be what discovers it.
const partitions = {
local: session({ tabsByWorktree: {} }),
[SSH_HOST_ID]: session({
tabsByWorktree: {},
openFilesByWorktree: {
[WORKTREE_ID]: [
{
filePath: '/remote/checkout/feature/README.md',
relativePath: 'README.md',
worktreeId: WORKTREE_ID,
language: 'markdown',
dirtyDraftContent: 'never saved'
}
]
}
} as unknown as WorkspaceSessionState)
}
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
'never saved'
)
})
it('restores host-qualified visit recency, which is keyed by host and not by bare id', async () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(editorOnlyPartitions()),
repos
)
expect(read.session.lastVisitedAtByWorktreeId?.[`${SSH_HOST_ID}|${WORKTREE_ID}`]).toBe(4242)
})
})
describe('ssh host partition write/read round trip', () => {
/** The two halves pinned together through the shipping write path. Testing the read against a
* hand-built partition is what let an editor-only workspace fall out: the fixture asserted the
* shape the read expected instead of the shape the write actually produces. */
async function roundTrip(payload: WorkspaceSessionState): Promise<WorkspaceSessionState> {
const { buildWorkspaceSessionHostSnapshots } =
await import('./workspace-session-host-persistence')
const snapshots = buildWorkspaceSessionHostSnapshots(payload, {
repos: [{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: null }],
worktreesByRepo: {}
})
const partitions: Record<string, WorkspaceSessionState> = {}
for (const snapshot of snapshots) {
partitions[snapshot.hostId ?? 'local'] = snapshot.state
}
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
partitionedApi(partitions as never),
repos
)
return read.session
}
it('sends an editor-only SSH workspace to its partition and reads it back', async () => {
const restored = await roundTrip(
session({
tabsByWorktree: {},
openFilesByWorktree: {
[WORKTREE_ID]: [
{
filePath: '/remote/checkout/feature/src/app.ts',
relativePath: 'src/app.ts',
worktreeId: WORKTREE_ID,
language: 'typescript',
dirtyDraftContent: 'work in progress'
}
]
},
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' }
} as unknown as WorkspaceSessionState)
)
expect(restored.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
'work in progress'
)
expect(restored.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
})
it('sends a terminal SSH workspace to its partition and reads it back', async () => {
const restored = await roundTrip(
session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-live')] } })
)
expect(restored.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live'])
})
})
describe('ssh host partition remote-workspace round trip', () => {
it('does not delete the worktree tabs across a publish and the next pull', async () => {
const partitions = strandedPartitions([tab('tab-runtime')])
+14
View File
@@ -1,4 +1,8 @@
import type { WorkspaceKey, WorkspaceScope } from './folder-workspace-types'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from './worktree/host-qualified-identity'
export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey {
return `worktree:${worktreeId}`
@@ -20,6 +24,16 @@ export function parseWorkspaceKey(value: string): WorkspaceScope | null {
return null
}
/** Bare workspace id behind a session key, which may be a WorkspaceKey, a host-qualified identity
* (`ssh:target|repo::path`, used by visit recency), or already a bare id. */
export function normalizeWorkspaceSessionKeyToWorkspaceId(value: string): string {
if (isWorktreeHostIdentity(value)) {
return getWorktreeIdFromHostIdentity(value)
}
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
export function isWorkspaceKey(value: string): value is WorkspaceKey {
return parseWorkspaceKey(value) !== null
}
@@ -1,6 +1,9 @@
import type { WorkspaceSessionState } from './workspace-session-state-types'
import type { TerminalTab } from './terminal-tab-types'
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership'
import {
WORKSPACE_SESSION_FIELD_OWNERSHIP,
type WorkspaceSessionFieldOwnership
} from './workspace-session-host-field-ownership'
import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope'
/**
* Fold rows a host partition holds alone back into the session the readers assemble.
@@ -10,66 +13,133 @@ import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-fiel
* now names a single owner, but both stores still hold real data, so every reader has to reunite
* them once before the write path returns the result to that owner.
*
* Strictly gap-filling. A workspace the base holds no tabs for takes the host partition's rows; a
* workspace the base does hold tabs for keeps them untouched, and the host partition adds nothing.
* **A workspace is adopted whenever the host partition names it at all — not only when it has
* terminal tabs.** The write path routes EVERY worktree-scoped field to the owning partition, so a
* workspace with open editor files, browser tabs or tab groups and no terminals lives there just as
* completely as one with terminals. Gating on tabs would strand exactly those, and unlike terminal
* state they cannot be recovered from the SSH host snapshot, which carries terminal fields only —
* an unsaved `dirtyDraftContent` would be destroyed outright.
*
* Why an EMPTY tab row counts as a gap and not as an answer: an empty list is not evidence that
* anything was closed. `mergeDirectSshRemoteWorkspaceSession` already argues this at length, and
* That is why the walk below switches exhaustively over `WORKSPACE_SESSION_FIELD_OWNERSHIP` instead
* of listing the fields it knows about: a hand-maintained list is what let editor and browser state
* fall out, and a new ownership kind must not be able to fall out the same way.
*
* The one thing the base keeps unconditionally is a workspace it holds **terminal tabs** for. That
* is the live copy the user is looking at, and merging a stale partition into it would re-add tabs
* they had closed on every launch. Leaving it alone keeps this a one-shot repair, at the cost of
* not recovering rows stranded beside a populated workspace — which are stranded on main today too,
* so it is never a new loss. An EMPTY tab row is not such a copy: an empty list is not evidence
* that anything was closed (`mergeDirectSshRemoteWorkspaceSession` argues this at length, and
* docs/reference/ssh-execution-boundary.md makes it general — "we could not see it" is
* `unverifiable`, never proof of absence. Treating that empty row as the truth is exactly what
* published an empty tab list and let `replace-session` delete the host's copy (#12721).
*
* Why nothing is merged INTO a populated workspace: the two lists would have to be unioned by tab
* id, and a stale row in the unread partition would then re-add tabs the user had closed, on every
* launch. Leaving a populated workspace alone keeps this a one-shot repair — afterwards the
* workspace lives in one partition — at the cost of not recovering tabs stranded beside a
* populated row. Those are stranded on main today too, so that is never a new loss.
* `unverifiable`, never proof of absence). Treating it as the truth is what published an empty tab
* list and let `replace-session` delete the host's copy (#12721).
*/
type KeyedRecord = Record<string, unknown>
const WORKSPACE_KEYED_FIELDS = (
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed')
/** Keyed by a tab id, or by a pane key that starts with one, so an adopted workspace's rows can be
* recognised by the tabs it brought. */
const TAB_SCOPED_FIELDS = (
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
).filter((field) => {
const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
return ownership === 'tabKeyed' || ownership === 'paneKeyed'
})
/** Keyed opaquely, but each record names the workspace it belongs to — the only routing left once
* the tab or pane it describes is gone, and the same one `splitWorkspaceSessionByHost` uses. */
const SELF_DESCRIBING_FIELDS = (
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
).filter((field) => {
const ownership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
return ownership === 'sleepingAgentKeyed' || ownership === 'surfaceTombstoneKeyed'
})
const WORKSPACE_ARRAY_FIELDS = (
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeArray')
const SESSION_FIELDS = Object.keys(
WORKSPACE_SESSION_FIELD_OWNERSHIP
) as (keyof WorkspaceSessionState)[]
function asRecord(value: unknown): KeyedRecord | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as KeyedRecord) : null
}
/** Workspaces the host partition is the only side holding tabs for. */
function strandedWorkspaceKeys(
function recordWorkspaceId(entry: unknown): string | null {
const worktreeId = asRecord(entry)?.worktreeId
return typeof worktreeId === 'string' ? worktreeId : null
}
/** A browser-workspace row is keyed by browser workspace id; its pages name the workspace. */
function browserPagesWorkspaceId(entry: unknown): string | null {
const first = Array.isArray(entry) ? (entry[0] as unknown) : null
return recordWorkspaceId(first)
}
/** Workspaces the base holds terminal tabs for: its live copies, which adoption never touches. */
function workspacesTheBaseOwns(base: WorkspaceSessionState): Set<string> {
const owned = new Set<string>()
for (const [key, tabs] of Object.entries(base.tabsByWorktree ?? {})) {
if (Array.isArray(tabs) && tabs.length > 0) {
owned.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
}
}
return owned
}
/** Every workspace the host partition names in any scoped field, minus the base's live copies. */
function adoptableWorkspaceIds(
base: WorkspaceSessionState,
host: WorkspaceSessionState
): Set<string> {
const stranded = new Set<string>()
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
if (Array.isArray(tabs) && tabs.length > 0 && (base.tabsByWorktree?.[key]?.length ?? 0) === 0) {
stranded.add(key)
const owned = workspacesTheBaseOwns(base)
const adoptable = new Set<string>()
const consider = (value: string | null | undefined): void => {
if (!value) {
return
}
const workspaceId = normalizeWorkspaceSessionKeyToWorkspaceId(value)
if (!owned.has(workspaceId)) {
adoptable.add(workspaceId)
}
}
return stranded
for (const field of SESSION_FIELDS) {
const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
const value = host[field]
switch (ownership) {
case 'global':
case 'hostPrivate':
case 'tabKeyed':
case 'paneKeyed':
case 'fileKeyed':
// Keyed by something the workspaces below already account for.
break
case 'worktreeKeyed':
for (const key of Object.keys(asRecord(value) ?? {})) {
consider(key)
}
break
case 'worktreeArray':
for (const id of Array.isArray(value) ? (value as string[]) : []) {
consider(id)
}
break
case 'sleepingAgentKeyed':
case 'surfaceTombstoneKeyed':
for (const entry of Object.values(asRecord(value) ?? {})) {
consider(recordWorkspaceId(entry))
}
break
case 'browserWorkspaceKeyed':
for (const entry of Object.values(asRecord(value) ?? {})) {
consider(browserPagesWorkspaceId(entry))
}
break
}
}
return adoptable
}
function adoptRecord(
next: WorkspaceSessionState,
host: WorkspaceSessionState,
field: keyof WorkspaceSessionState,
shouldAdopt: (key: string, entry: unknown) => boolean,
/** Adoptable workspaces are host-owned, so their rows replace the base's leftovers; everything
* else only fills a gap, so nothing the base already answered is overwritten. */
replace: boolean
): void {
const hostRecord = asRecord(host[field])
if (!hostRecord) {
return
}
const merged = { ...asRecord(next[field]) }
for (const [key, entry] of Object.entries(hostRecord)) {
if (shouldAdopt(key, entry) && (replace || !Object.hasOwn(merged, key))) {
merged[key] = entry
}
}
;(next as KeyedRecord)[field] = merged
}
export function adoptStrandedHostPartitionSession(
@@ -79,84 +149,106 @@ export function adoptStrandedHostPartitionSession(
if (!host) {
return base
}
const stranded = strandedWorkspaceKeys(base, host)
if (stranded.size === 0) {
const adoptable = adoptableWorkspaceIds(base, host)
if (adoptable.size === 0) {
return base
}
const tabsByWorktree: Record<string, TerminalTab[]> = { ...base.tabsByWorktree }
for (const key of stranded) {
tabsByWorktree[key] = host.tabsByWorktree[key] ?? []
const adopts = (key: string): boolean =>
adoptable.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))
const next: WorkspaceSessionState = { ...base, tabsByWorktree: { ...base.tabsByWorktree } }
const adoptedTabIds = new Set<string>()
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
if (!adopts(key) || !Array.isArray(tabs)) {
continue
}
next.tabsByWorktree[key] = tabs
for (const tab of tabs) {
adoptedTabIds.add(tab.id)
}
}
const next: WorkspaceSessionState = { ...base, tabsByWorktree }
for (const field of WORKSPACE_KEYED_FIELDS) {
if (field === 'tabsByWorktree') {
// Computed up front rather than as the walk passes `openFilesByWorktree`, so the file-keyed
// fields do not depend on the ownership table's declaration order.
const adoptedFileIds = new Set<string>()
for (const [key, files] of Object.entries(asRecord(host.openFilesByWorktree) ?? {})) {
if (!adopts(key)) {
continue
}
const hostRecord = asRecord(host[field])
if (!hostRecord) {
continue
}
// A stranded workspace's other rows describe the tabs just adopted, so they replace the base's
// leftovers rather than filling around them.
const merged = { ...asRecord(next[field]) }
for (const key of stranded) {
if (Object.hasOwn(hostRecord, key)) {
merged[key] = hostRecord[key]
for (const file of Array.isArray(files) ? files : []) {
const filePath = asRecord(file)?.filePath
if (typeof filePath === 'string') {
adoptedFileIds.add(filePath)
}
}
;(next as KeyedRecord)[field] = merged
}
const adoptedTabIds = new Set(
[...stranded].flatMap((key) => (host.tabsByWorktree[key] ?? []).map((tab) => tab.id))
)
for (const field of TAB_SCOPED_FIELDS) {
const hostRecord = asRecord(host[field])
if (!hostRecord) {
continue
}
const merged = { ...asRecord(next[field]) }
for (const [key, entry] of Object.entries(hostRecord)) {
// Scoped to the adopted tabs so a tab the base already answered for keeps its own rows.
if (!Object.hasOwn(merged, key) && adoptedTabIds.has(key.split(':', 1)[0] ?? '')) {
merged[key] = entry
for (const field of SESSION_FIELDS) {
const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
switch (ownership) {
case 'global':
case 'hostPrivate':
// 'local' owns the globals; hostPrivate is main's own per-partition fence.
break
case 'worktreeKeyed':
if (field !== 'tabsByWorktree') {
adoptRecord(next, host, field, (key) => adopts(key), true)
}
break
case 'worktreeArray': {
const hostIds = host[field]
const adopted = (Array.isArray(hostIds) ? (hostIds as string[]) : []).filter(adopts)
if (adopted.length > 0) {
const baseIds = next[field]
;(next as KeyedRecord)[field] = [
...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted])
]
}
break
}
case 'tabKeyed':
case 'paneKeyed':
// Keyed by a tab id, or by a pane key that starts with one. Tab ids are colon-free, so the
// first segment identifies the owning tab in both shapes.
adoptRecord(
next,
host,
field,
(key) => adoptedTabIds.has(key.split(':', 1)[0] ?? ''),
false
)
break
case 'sleepingAgentKeyed':
case 'surfaceTombstoneKeyed':
// Keyed opaquely, but each record names its own workspace — the only routing left once the
// tab or pane it describes is gone, and the same one `splitWorkspaceSessionByHost` uses.
adoptRecord(
next,
host,
field,
(_key, entry) => {
const workspaceId = recordWorkspaceId(entry)
return workspaceId !== null && adopts(workspaceId)
},
false
)
break
case 'browserWorkspaceKeyed':
adoptRecord(
next,
host,
field,
(_key, entry) => {
const workspaceId = browserPagesWorkspaceId(entry)
return workspaceId !== null && adopts(workspaceId)
},
false
)
break
case 'fileKeyed':
// Routed by the open file's workspace, so it follows the files adopted just above.
adoptRecord(next, host, field, (key) => adoptedFileIds.has(key), false)
break
}
;(next as KeyedRecord)[field] = merged
}
for (const field of SELF_DESCRIBING_FIELDS) {
const hostRecord = asRecord(host[field])
if (!hostRecord) {
continue
}
const merged = { ...asRecord(next[field]) }
for (const [key, entry] of Object.entries(hostRecord)) {
// Why these travel at all: a hibernated agent or a surface tombstone for a stranded workspace
// is only in the host partition, and the renderer's next full write replaces that partition —
// so a record the reunited session never carried would be dropped by the repair itself.
const worktreeId = asRecord(entry)?.worktreeId
if (
!Object.hasOwn(merged, key) &&
typeof worktreeId === 'string' &&
stranded.has(worktreeId)
) {
merged[key] = entry
}
}
;(next as KeyedRecord)[field] = merged
}
for (const field of WORKSPACE_ARRAY_FIELDS) {
const hostIds = host[field]
if (!Array.isArray(hostIds)) {
continue
}
const adopted = (hostIds as string[]).filter((id) => stranded.has(id))
if (adopted.length === 0) {
continue
}
const baseIds = next[field]
;(next as KeyedRecord)[field] = [
...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted])
]
}
return next
}