Files
orca/src/shared/workspace-session-stranded-partition-adoption.ts
T
Neil 8f83f6b076 feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d) (#28820)
* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d)

* fix(wsl-mover): gate again under the fence, swap journal phases atomically, keep sessions frozen through commit (D1b-5d)

* test(wsl-mover): the fence e2e walks the journal one phase at a time, as a move writes it (D1b-5d)
2026-10-14 13:07:50 -07:00

440 lines
20 KiB
TypeScript

import type { WorkspaceSessionState } from './workspace-session-state-types'
import {
WORKSPACE_SESSION_FIELD_OWNERSHIP,
type WorkspaceSessionFieldOwnership
} from './workspace-session-host-field-ownership'
import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope'
import { isWorktreeHostIdentity as isHostQualifiedSessionKey } from './worktree/host-qualified-identity'
import {
buildWorktreeIdByFileId,
buildWorktreeIdByTabId,
worktreeIdForPaneKey
} from './workspace-session-host-records'
import { keepUnsavedDrafts } from './workspace-session-draft-retention'
/**
* Fold rows a host partition holds alone back into the session the readers assemble.
*
* Shipping builds split one SSH workspace's session across two partitions: the renderer wrote
* `local`, the main-process runtime wrote `ssh:<targetId>` (#12723). `workspaceSessionPartitionHostId`
* 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.
*
* **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.
*
* 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.
*
* Adoption is told which session keys the read found **contested**. Every rule below rests on the
* premise that `local` and `ssh:<targetId>` are one workspace written twice — and a bare
* `repoId::path` id claimed by more than one host is exactly where that premise is false. The
* contention split cannot answer it, because ssh slices are deliberately kept out of its claimant
* set, so the caller reaches the verdict itself — from the repo catalog and from the other
* partitions it read — and passes it in: a contested key may still be gap-filled, never replaced.
* Without that, an SSH workspace's rows overwrote a different workspace's rows under the same id,
* and routing then wrote them into that workspace's own partition.
*
* `adoptedWorkspaceIds` reports which workspaces came out of `host` uncontested. The write path
* routes those back to that partition directly, so a row does not depend on a repo catalog naming
* the host before it can be returned to where it was read from.
*
* 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 (docs/reference/ssh-execution-boundary.md: "we could not see it" is
* `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). Nor is a copy of a workspace the
* repo catalog places on this host while the host holds its tabs: the base's rows there are residue
* that dropped the host's agent-resume records on the first save (#23390, #25616), so they are
* dropped before adoption — except unsaved drafts, which nothing else holds.
*/
type KeyedRecord = Record<string, unknown>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Object.keys over the ownership table yields exactly the session field names it is keyed by.
const SESSION_FIELDS = Object.keys(
WORKSPACE_SESSION_FIELD_OWNERSHIP
) as (keyof WorkspaceSessionState)[]
function isRecord(value: unknown): value is KeyedRecord {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function asRecord(value: unknown): KeyedRecord | null {
return isRecord(value) ? value : null
}
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: unknown = Array.isArray(entry) ? entry[0] : 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 a partition names in any scoped field.
*
* Exported because the caller has to ask the repo catalog about the same set before adoption runs:
* a `worktreeKeyed` sweep alone misses an id a partition names only through
* `browserPagesByWorkspace`, a sleeping-agent record or `activeWorktreeIdsOnShutdown`, and those
* are adopted just like the rest.
*/
export function workspaceIdsNamedByPartition(host: WorkspaceSessionState): Set<string> {
return collectWorkspaceIds(host, () => false)
}
/** 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 owned = workspacesTheBaseOwns(base)
return collectWorkspaceIds(host, (workspaceId) => owned.has(workspaceId))
}
function collectWorkspaceIds(
host: WorkspaceSessionState,
skip: (workspaceId: string) => boolean
): Set<string> {
const collected = new Set<string>()
const consider = (value: string | null | undefined): void => {
if (!value) {
return
}
const workspaceId = normalizeWorkspaceSessionKeyToWorkspaceId(value)
if (!skip(workspaceId)) {
collected.add(workspaceId)
}
}
// Tab-, pane- and file-keyed rows resolve to nothing here: the worktree-keyed rows they hang
// off already name their workspaces.
const none = new Map<string, string>()
for (const field of SESSION_FIELDS) {
const workspaceOf = entryWorkspaceResolver(field, none, none)
const value: unknown = host[field]
const entries = Array.isArray(value)
? value.map((id: unknown) => [id, id] as const)
: Object.entries(asRecord(value) ?? {})
for (const [key, entry] of entries) {
consider(typeof key === 'string' ? workspaceOf?.(key, entry) : null)
}
}
return collected
}
/**
* Whether the host has nothing to say about a key. `[]`, `{}` and null/undefined all mean the host
* holds no rows, which is never evidence that the base's rows are wrong — the same reading the base
* side already gives an empty tab row, and `docs/reference/ssh-execution-boundary.md` generalises.
* Without this an empty host `openFilesByWorktree` row replaced a populated base one and destroyed
* an unsaved `dirtyDraftContent`, which no other channel can recover. The symmetric cost is that a
* row the host really did empty stays visible for one more launch, and a resurrected editor tab is
* non-destructive where a destroyed draft is not.
*/
function hostHasNothingFor(entry: unknown): boolean {
if (entry === null || entry === undefined) {
return true
}
if (Array.isArray(entry)) {
return entry.length === 0
}
return isRecord(entry) && Object.keys(entry).length === 0
}
function adoptRecord(
next: WorkspaceSessionState,
host: WorkspaceSessionState,
field: keyof WorkspaceSessionState,
shouldAdopt: (key: string, entry: unknown) => boolean,
/** Whether this key's host row may replace the base's, rather than only fill a gap. An adoptable
* workspace is host-owned, so its populated rows supersede the base's leftovers — but only where
* the id names one workspace and the host actually holds something. */
mayReplace: boolean | ((key: string) => boolean) = false
): void {
const hostRecord = asRecord(host[field])
if (!hostRecord) {
return
}
const merged = { ...asRecord(next[field]) }
for (const [key, entry] of Object.entries(hostRecord)) {
const replaces =
(typeof mayReplace === 'function' ? mayReplace(key) : mayReplace) && !hostHasNothingFor(entry)
if (shouldAdopt(key, entry) && (replaces || !Object.hasOwn(merged, key))) {
merged[key] = entry
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the merged record is written back through a dynamic field key, which the session type cannot express.
;(next as KeyedRecord)[field] = merged
}
/**
* The worktree-keyed rows a partition holds for workspaces the write will not route back to it.
*
* Parked rather than dropped. A partition write replaces each field with exactly what the unified
* session routed there, so a row this read left out — declined as residue, withheld as contested,
* or skipped because the base already holds the live copy — is erased the moment any sibling
* workspace writes the same partition. `attachHostSessionShadow` puts these back into the slice
* first, which is the protection a contested runtime co-claimant already gets. Declining to show a
* row must never mean deleting it: docs/reference/ssh-execution-boundary.md makes leak, never kill,
* the safe direction, and a row no partition holds at all is unrecoverable.
*/
export function partitionRowsTheWriteWontReturn(
host: WorkspaceSessionState,
adoptedWorkspaceIds: ReadonlySet<string>
): WorkspaceSessionState | null {
let parked: KeyedRecord | null = null
for (const field of SESSION_FIELDS) {
if (WORKSPACE_SESSION_FIELD_OWNERSHIP[field] !== 'worktreeKeyed') {
continue
}
const record = asRecord(host[field])
if (!record) {
continue
}
let kept: KeyedRecord | null = null
for (const [key, entry] of Object.entries(record)) {
if (adoptedWorkspaceIds.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))) {
continue
}
kept ??= {}
kept[key] = entry
}
if (kept) {
parked ??= {}
parked[field] = kept
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every key written here is a session field name taken from the ownership table, and every value is that field's own record copied by reference.
return parked as WorkspaceSessionState | null
}
/** Which workspace an entry of a scoped field belongs to; null for global and host-private fields. */
function entryWorkspaceResolver(
field: keyof WorkspaceSessionState,
worktreeIdByTabId: Map<string, string>,
worktreeIdByFileId: Map<string, string>
): ((key: string, entry: unknown) => string | null | undefined) | null {
const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
switch (ownership) {
case 'global':
case 'hostPrivate':
return null
case 'worktreeKeyed':
case 'worktreeArray':
return (key) => key
case 'tabKeyed':
return (key) => worktreeIdByTabId.get(key)
case 'paneKeyed':
return (key) => worktreeIdForPaneKey(worktreeIdByTabId, key)
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.
return (_key, entry) => recordWorkspaceId(entry)
case 'browserWorkspaceKeyed':
return (_key, entry) => browserPagesWorkspaceId(entry)
case 'fileKeyed':
// Routed by the open file's workspace, through the same index the split routed it by.
return (key) => worktreeIdByFileId.get(key)
}
}
/** The base without any rows for these workspaces, routed by the same indexes the split uses. */
export function withoutWorkspaces(
base: WorkspaceSessionState,
workspaceIds: ReadonlySet<string>
): WorkspaceSessionState {
return filterWorkspaceRows(base, (workspaceId) => !workspaceIds.has(workspaceId))
}
/** The scoped rows of the workspaces kept, by the same routing; global fields stay as they are. */
export function filterWorkspaceRows(
base: WorkspaceSessionState,
keepsWorkspace: (workspaceId: string) => boolean
): WorkspaceSessionState {
const worktreeIdByTabId = buildWorktreeIdByTabId(base)
const worktreeIdByFileId = buildWorktreeIdByFileId(base)
const next: KeyedRecord = { ...base }
for (const field of SESSION_FIELDS) {
const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId)
const keeps = (key: string, entry: unknown): boolean =>
keepsWorkspace(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? ''))
const value: unknown = base[field]
if (!workspaceOf || !value) {
continue
}
next[field] = Array.isArray(value)
? value.filter((id: string) => keeps(id, id))
: Object.fromEntries(Object.entries(value).filter(([key, entry]) => keeps(key, entry)))
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is the base's own value or a filtered copy of it.
return next as WorkspaceSessionState
}
/** Owned, uncontested and holding tabs: the host's copy is the live one. No tabs is not a close. */
export function workspacesTheHostSupersedes(
host: WorkspaceSessionState,
ownedSessionKeys: ReadonlySet<string> | undefined,
contested: ReadonlySet<string>
): Set<string> {
const superseded = new Set<string>()
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
const id = normalizeWorkspaceSessionKeyToWorkspaceId(key)
if (!hostHasNothingFor(tabs) && !contested.has(id) && ownedSessionKeys?.has(id)) {
superseded.add(id)
}
}
return superseded
}
export type StrandedPartitionAdoptionOptions = {
/** Session keys the read found claimed by more than one partition. */
contestedSessionKeys?: ReadonlySet<string>
/**
* Keys the repo catalog positively attributes to a DIFFERENT host: residue this partition holds
* but does not own. Not adopted at all — gap-filling a stale row would put it in front of the
* live one and then route it into the live partition on the next write. Nothing is deleted; the
* rows stay where they are, which is the leak direction the boundary doc asks for.
*/
foreignSessionKeys?: ReadonlySet<string>
/** Keys the repo catalog attributes to this host. */
ownedSessionKeys?: ReadonlySet<string>
}
export type StrandedPartitionAdoption = {
session: WorkspaceSessionState
/** Bare workspace ids whose rows this partition owns outright, so the write returns them here. */
adoptedWorkspaceIds: ReadonlySet<string>
}
const NOTHING_ADOPTED: ReadonlySet<string> = new Set<string>()
export function adoptStrandedHostPartitionSession(
originalBase: WorkspaceSessionState,
host: WorkspaceSessionState | null | undefined,
options: StrandedPartitionAdoptionOptions = {}
): StrandedPartitionAdoption {
if (!host) {
return { session: originalBase, adoptedWorkspaceIds: NOTHING_ADOPTED }
}
const contested = new Set<string>()
for (const key of options.contestedSessionKeys ?? []) {
contested.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
}
const superseded = workspacesTheHostSupersedes(host, options.ownedSessionKeys, contested)
const base = superseded.size > 0 ? withoutWorkspaces(originalBase, superseded) : originalBase
const adoptable = adoptableWorkspaceIds(base, host)
for (const key of options.foreignSessionKeys ?? []) {
adoptable.delete(normalizeWorkspaceSessionKeyToWorkspaceId(key))
}
if (adoptable.size === 0) {
return { session: base, adoptedWorkspaceIds: NOTHING_ADOPTED }
}
const adopts = (key: string): boolean =>
adoptable.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))
const isContested = (key: string): boolean =>
contested.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))
const next: WorkspaceSessionState = { ...base, tabsByWorktree: { ...base.tabsByWorktree } }
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
if (!adopts(key) || !Array.isArray(tabs)) {
continue
}
// A contested id is not this workspace written twice, so the base's own row stays — but an
// EMPTY base row is not a row, it is the gap this repair exists to fill. Reading `hasOwn` as
// "the base has tabs here" is what let #12721's empty local list win over the host's real one
// whenever the id happened to be contested.
if (!isContested(key) || hostHasNothingFor(next.tabsByWorktree[key])) {
next.tabsByWorktree[key] = tabs
}
}
// Why the split's own indexes: they are what decided which partition each tab-, pane- and
// file-keyed row was written to, so reading them back through anything else lets the two walks
// disagree. `buildWorktreeIdByTabId` also covers unified-only tabs, whose layout and PTY records
// the split routes here and a `tabsByWorktree`-only walk never adopted back. Computed up front so
// the keyed fields do not depend on the ownership table's declaration order.
const worktreeIdByTabId = buildWorktreeIdByTabId(host)
const worktreeIdByFileId = buildWorktreeIdByFileId(host)
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') {
// Why a bare recency key only fills a gap: `lastVisitedAtByWorktreeId` is the one field in
// this kind whose key may carry a host (`<hostId>|<worktreeId>`), and the split has its
// own branch for that. A qualified key names its owner and cannot collide; a bare one is
// indistinguishable from another host's entry for the same id, and replacing moved a
// local workspace's Cmd+J position permanently.
adoptRecord(
next,
host,
field,
(key) => adopts(key),
(key) =>
!isContested(key) &&
(field !== 'lastVisitedAtByWorktreeId' || isHostQualifiedSessionKey(key))
)
}
break
case 'worktreeArray': {
const hostIds = host[field]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktreeArray fields hold worktree ids; the session type does not carry that through a dynamic key.
const adopted = (Array.isArray(hostIds) ? (hostIds as string[]) : []).filter(adopts)
if (adopted.length > 0) {
const baseIds = next[field]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the adopted union is written back through a dynamic field key, which the session type cannot express.
;(next as KeyedRecord)[field] = [
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktreeArray fields hold worktree ids; the session type does not carry that through a dynamic key.
...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted])
]
}
break
}
case 'tabKeyed':
case 'paneKeyed':
case 'sleepingAgentKeyed':
case 'surfaceTombstoneKeyed':
case 'browserWorkspaceKeyed':
case 'fileKeyed': {
const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId)
adoptRecord(next, host, field, (key, entry) => adopts(workspaceOf?.(key, entry) ?? ''))
break
}
}
}
keepUnsavedDrafts(next, originalBase)
// Why contested ids are withheld: the write path would route the whole bare id here, carrying the
// co-claimant's rows into this host's partition — the loss the gap-fill above exists to prevent.
const adoptedWorkspaceIds = new Set(
[...adoptable].filter((workspaceId) => !contested.has(workspaceId))
)
return { session: next, adoptedWorkspaceIds }
}