fix(worktrees): a host-scoped purge leaves a surviving twin's tabs and edits alone (#30030)

This commit is contained in:
Neil
2026-10-17 16:29:17 -07:00
committed by GitHub
parent 27cfee6f7e
commit 299dcb86fc
21 changed files with 1198 additions and 210 deletions
+2 -5
View File
@@ -459,21 +459,18 @@
1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts
3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts
3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts
7 src/renderer/src/store/slices/worktrees.ts
5 src/renderer/src/store/slices/worktrees.ts
1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts
1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts
2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts
2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts
1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts
2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts
2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts
3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts
1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts
1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts
1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts
1 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts
2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts
1 src/renderer/src/store/terminals/terminal-shutdown.ts
@@ -14,6 +14,7 @@ import {
import { replaceRuntimeEnvironmentRevisions } from '../../runtime/runtime-environment-revision'
import { advanceRuntimeEnvironmentConnectionGeneration } from '../../store/slices/runtime-status-connection-generation'
import { createWorktreeEventRuntime } from './worktree-event-runtime'
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import {
beginWorktreeLineageRefresh,
invalidateRemoteWorktreeLineage,
@@ -219,7 +220,7 @@ describe('remote worktree event lineage refresh', () => {
expect(store.getState().fetchWorktreeLineage).toHaveBeenCalledTimes(2)
expect(store.getState().setActiveWorktree).toHaveBeenCalledWith('repo-rename::/new')
expect(store.getState().purgeWorktreeTerminalState).toHaveBeenCalledWith([
{ id: deleted.id, hostId: 'runtime:env-1' }
hostWorktreeTarget({ id: deleted.id, hostId: 'runtime:env-1' })
])
expect(store.getState().removeWorkspaceSpaceWorktrees).toHaveBeenCalledWith([deleted.id])
})
@@ -6,6 +6,7 @@ import {
} from '../../../../shared/execution-host'
import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events'
import type { AppState } from '../../store/types'
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import type { WorktreeSortOrderUpdate } from '../../../../shared/worktree/sort-order-update'
import {
hasHostRowsForSortOrders,
@@ -189,7 +190,7 @@ export function createWorktreeEventRuntime(
// A forced-local refresh owns only the local host's panes.
(options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined)
afterState.purgeWorktreeTerminalState(
purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed
purgeHostId ? removed.map((id) => hostWorktreeTarget({ id, hostId: purgeHostId })) : removed
)
afterState.removeWorkspaceSpaceWorktrees(removed)
}
@@ -9,6 +9,7 @@ import {
type ExecutionHostId
} from '../../../../shared/execution-host'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing'
import type { FolderWorkspaceUpdateTicket } from '../slices/folder-workspace-update-coordinator'
import { callRuntimeRpc } from '../../runtime/runtime-rpc-client'
@@ -267,7 +268,12 @@ export async function reconcileFailedFolderWorkspaceUpdate(args: {
: {})
}))
if (!refreshed) {
args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)])
args.get().purgeWorktreeTerminalState([
hostWorktreeTarget({
id: folderWorkspaceKey(args.folderWorkspaceId),
hostId: args.ownerHostId
})
])
}
} catch (err) {
console.warn('Failed to reconcile folder workspace after update failure:', err)
@@ -6,6 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
@@ -149,7 +150,9 @@ const TWIN_ACTIONS: readonly TwinAction[] = [
run: async (f, target) =>
f.store
.getState()
.purgeWorktreeTerminalState([{ id: f.workspaceKey, hostId: target.hostId }]),
.purgeWorktreeTerminalState([
hostWorktreeTarget({ id: f.workspaceKey, hostId: target.hostId })
]),
applied: (f, target) =>
expect(f.store.getState().ptyIdsByTabId[target.terminalTabId]).toBeUndefined()
},
@@ -202,10 +205,6 @@ function everyCell(actionId: string, reason: string, kinds = TWIN_KINDS): [strin
/** `<action> | <kind> | <target>` → why the survivor is touched today (tracker Stage 1 worklist). */
const KNOWN_TWIN_LEAKS: Readonly<Record<string, string>> = Object.fromEntries([
...everyCell(
'purge terminal state',
'the purge drops every bucket under the raw id; the target host only scopes visit recency'
),
...everyCell('kill terminals', 'takes a raw worktree id, so it stops both twins’ PTYs'),
...everyCell('sleep', 'takes a raw worktree id, so it sleeps both twins'),
[
@@ -0,0 +1,374 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity'
import { getWorktreeVisitKey } from '@/lib/worktree-visit-recency'
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { resetHostTwinModuleStores } from './host-twin-state-projection.test-fixture'
import {
createHostTwinFixture,
TWIN_RUNTIME_ENV,
type HostTwinFixture
} from './host-twin.test-fixture'
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
let current: HostTwinFixture | null = null
beforeEach(() => {
vi.stubGlobal('window', { api: { pty: { kill: vi.fn() } } })
})
afterEach(() => {
if (current) {
resetHostTwinModuleStores(current)
}
current = null
vi.unstubAllGlobals()
})
function twin(kind: Parameters<typeof createHostTwinFixture>[0] = 'local+ssh'): HostTwinFixture {
const fixture = createHostTwinFixture(kind)
current = fixture
const key = fixture.workspaceKey
fixture.store.setState({
gitStatusByWorktree: { [key]: [] },
activeWorktreeId: key,
activeWorkspaceExecutionHostId: fixture.remote.hostId,
lastVisitedAtByWorktreeId: {
[getWorktreeVisitKey(key, fixture.local.hostId)]: 1,
[getWorktreeVisitKey(key, fixture.remote.hostId)]: 2
}
})
return fixture
}
function keepOnlyRemoteRow(fixture: HostTwinFixture): void {
fixture.store.setState((s) => ({
worktreesByRepo: {
[fixture.projectId]: (s.worktreesByRepo[fixture.projectId] ?? []).filter(
(row) => row.hostId === fixture.remote.hostId
)
}
}))
}
describe('purgeWorktreeTerminalState with a surviving twin', () => {
it("purges only the target host's attributable state", () => {
const f = twin()
const key = f.workspaceKey
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
const s = f.store.getState()
expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
f.local.terminalTabId,
f.dormantTabId
])
expect(s.ptyIdsByTabId[f.remote.terminalTabId]).toBeUndefined()
expect(s.terminalLayoutsByTabId[f.remote.terminalTabId]).toBeUndefined()
expect(s.ptyIdsByTabId[f.local.terminalTabId]).toEqual([f.local.ptyId])
expect(s.unifiedTabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
f.local.terminalTabId,
f.local.fileId,
f.dormantTabId
])
expect(s.groupsByWorktree[key]?.[0]?.tabOrder).toEqual([
f.local.terminalTabId,
f.local.fileId,
f.dormantTabId
])
expect(s.openFiles.map((file) => file.id)).toEqual([f.local.fileId])
expect(Object.keys(s.editorDrafts)).toEqual([f.local.fileId])
expect(Object.keys(s.sleepingAgentSessionsByPaneKey)).toEqual([f.local.paneKey])
// Shared buckets and raw-id module stores cannot name a host, so the twin keeps them.
expect(s.gitStatusByWorktree[key]).toEqual([])
expect(hasWorktreeSleepIntent(key)).toBe(true)
expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([
getWorktreeVisitKey(key, f.local.hostId)
])
expect(s.activeWorktreeId).toBe(key)
expect(s.activeWorkspaceExecutionHostId).toBe(f.local.hostId)
})
it('keeps PTY-keyed state for a purged PTY that a kept tab still binds', () => {
const f = twin()
const shared = f.remote.ptyId
// A mixed-host split: the kept local tab also binds the purged copy's PTY.
f.store.setState((s) => ({
ptyIdsByTabId: { ...s.ptyIdsByTabId, [f.local.terminalTabId]: [f.local.ptyId, shared] },
suppressedPtyExitIds: { [shared]: true },
pendingPaneAccountRestarts: { [shared]: 'codex' }
}))
f.store
.getState()
.purgeWorktreeTerminalState([hostWorktreeTarget({ id: f.workspaceKey, ...f.remote })])
const s = f.store.getState()
expect(s.ptyIdsByTabId[f.local.terminalTabId]).toEqual([f.local.ptyId, shared])
expect(s.suppressedPtyExitIds).toEqual({ [shared]: true })
expect(s.pendingPaneAccountRestarts).toEqual({ [shared]: 'codex' })
})
it('collapses a split the purged copy emptied, handing selection to the survivor', () => {
const f = twin()
const key = f.workspaceKey
f.store.setState((s) => {
const [group] = s.groupsByWorktree[key] ?? []
if (!group) {
throw new Error('fixture has no tab group')
}
const remoteIds = [f.remote.terminalTabId, f.remote.fileId]
return {
groupsByWorktree: {
[key]: [
{ ...group, tabOrder: group.tabOrder.filter((id) => !remoteIds.includes(id)) },
{ ...group, id: 'remote-group', tabOrder: remoteIds, activeTabId: remoteIds[0] }
]
},
unifiedTabsByWorktree: {
[key]: (s.unifiedTabsByWorktree[key] ?? []).map((tab) =>
remoteIds.includes(tab.id) ? { ...tab, groupId: 'remote-group' } : tab
)
},
layoutByWorktree: {
[key]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: group.id },
second: { type: 'leaf', groupId: 'remote-group' }
}
},
activeGroupIdByWorktree: { [key]: 'remote-group' }
}
})
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
const s = f.store.getState()
const survivingGroupIds = (s.groupsByWorktree[key] ?? []).map((group) => group.id)
expect(survivingGroupIds).not.toContain('remote-group')
expect(survivingGroupIds).toHaveLength(1)
expect(s.layoutByWorktree[key]).not.toEqual(expect.objectContaining({ type: 'split' }))
expect(s.activeGroupIdByWorktree[key]).toBe(survivingGroupIds[0])
})
it('keeps an untouched active group when it collapses a split the purge emptied', () => {
const f = twin()
const key = f.workspaceKey
const remoteIds = [f.remote.terminalTabId, f.remote.fileId]
f.store.setState((s) => {
const [group] = s.groupsByWorktree[key] ?? []
if (!group) {
throw new Error('fixture has no tab group')
}
const localIds = group.tabOrder.filter((id) => !remoteIds.includes(id))
return {
groupsByWorktree: {
[key]: [
{ ...group, id: 'a', tabOrder: localIds.slice(0, 1), activeTabId: localIds[0] },
{ ...group, id: 'b', tabOrder: localIds.slice(1), activeTabId: localIds[1] },
{ ...group, id: 'c', tabOrder: remoteIds, activeTabId: remoteIds[0] }
]
},
layoutByWorktree: {
[key]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'a' },
second: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', groupId: 'b' },
second: { type: 'leaf', groupId: 'c' }
}
}
},
activeGroupIdByWorktree: { [key]: 'a' }
}
})
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
const s = f.store.getState()
expect((s.groupsByWorktree[key] ?? []).map((group) => group.id)).toEqual(['a', 'b'])
expect(s.activeGroupIdByWorktree[key]).toBe('a')
})
it('keeps the runtime twin when the local copy is purged', () => {
const f = twin('local+runtime')
const key = f.workspaceKey
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.local })])
const s = f.store.getState()
expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
f.remote.terminalTabId,
f.dormantTabId
])
expect(s.openFiles.map((file) => file.id)).toEqual([f.remote.fileId])
})
it('purges everything when no other host publishes the id', () => {
const f = twin()
const key = f.workspaceKey
keepOnlyRemoteRow(f)
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
const s = f.store.getState()
expect(s.tabsByWorktree[key]).toBeUndefined()
expect(s.openFiles).toEqual([])
expect(s.gitStatusByWorktree[key]).toBeUndefined()
expect(hasWorktreeSleepIntent(key)).toBe(false)
// Recency stays host-scoped, as before.
expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([
getWorktreeVisitKey(key, f.local.hostId)
])
})
it('purges everything when every publishing host is targeted', () => {
const f = twin()
const key = f.workspaceKey
f.store
.getState()
.purgeWorktreeTerminalState([
hostWorktreeTarget({ id: key, ...f.remote }),
hostWorktreeTarget({ id: key, ...f.local })
])
expect(f.store.getState().tabsByWorktree[key]).toBeUndefined()
expect(f.store.getState().gitStatusByWorktree[key]).toBeUndefined()
})
it('keeps the raw-id purge reaching both twins', () => {
const f = twin()
const key = f.workspaceKey
f.store.getState().purgeWorktreeTerminalState([key])
const s = f.store.getState()
expect(s.tabsByWorktree[key]).toBeUndefined()
expect(s.openFiles).toEqual([])
expect(s.lastVisitedAtByWorktreeId).toEqual({})
})
it("drops a tabless sleeping record that names the purged SSH host, not the twin's", () => {
const f = twin()
const key = f.workspaceKey
const record = f.store.getState().sleepingAgentSessionsByPaneKey[f.remote.paneKey]
f.store.setState((s) => ({
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
'gone-ssh:leaf': { ...record, paneKey: 'gone-ssh:leaf' },
'gone-local:leaf': { ...record, paneKey: 'gone-local:leaf', connectionId: null }
}
}))
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
expect(Object.keys(f.store.getState().sleepingAgentSessionsByPaneKey).sort()).toEqual(
['gone-local:leaf', f.local.paneKey].sort()
)
})
it('selects a surviving tab when the purged terminal was active', () => {
const f = twin()
const key = f.workspaceKey
f.store.setState((s) => ({
activeTabId: f.remote.terminalTabId,
activeTabType: 'terminal',
groupsByWorktree: {
[key]: (s.groupsByWorktree[key] ?? []).map((group) => ({
...group,
activeTabId: f.remote.terminalTabId,
recentTabIds: [f.local.fileId, f.remote.terminalTabId]
}))
}
}))
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
const s = f.store.getState()
expect(s.groupsByWorktree[key]?.[0]?.activeTabId).toBe(f.local.fileId)
expect(s.activeTabType).toBe('editor')
expect(s.activeFileId).toBe(f.local.fileId)
})
it("attributes a paired runtime's purge to the SSH copy it reaches", () => {
const f = twin()
const key = f.workspaceKey
const runtimeHostId = `runtime:${TWIN_RUNTIME_ENV}` as const
f.store.setState((s) => ({
worktreesByRepo: {
[f.projectId]: (s.worktreesByRepo[f.projectId] ?? []).map((row) =>
row.hostId === f.remote.hostId
? { ...row, runtimeOwnerEnvironmentId: TWIN_RUNTIME_ENV }
: row
)
},
lastVisitedAtByWorktreeId: {
...s.lastVisitedAtByWorktreeId,
[getWorktreeVisitKey(key, runtimeHostId)]: 3
}
}))
f.store
.getState()
.purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, hostId: runtimeHostId })])
const s = f.store.getState()
expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([
f.local.terminalTabId,
f.dormantTabId
])
expect(s.openFiles.map((file) => file.id)).toEqual([f.local.fileId])
// Recency written under either identity of the purged copy goes; the twin's stays.
expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([
getWorktreeVisitKey(key, f.local.hostId)
])
})
it('keeps an untouched legacy selection when another host is purged', () => {
const f = twin()
const key = f.workspaceKey
f.store.setState({
activeWorkspaceExecutionHostId: f.local.hostId,
activeTabId: f.local.terminalTabId,
activeTabType: 'terminal',
activeFileIdByWorktree: { [key]: f.local.fileId },
// A legacy workspace: no tab groups, so derivation prefers a remembered editor.
groupsByWorktree: {}
})
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
expect(f.store.getState().activeTabId).toBe(f.local.terminalTabId)
expect(f.store.getState().activeTabType).toBe('terminal')
})
it.each([
['drops', 'remote-source.md', 'local-source.md', false],
['keeps', 'shared-source.md', 'shared-source.md', true]
] as const)(
"%s a purged preview's source state by whether a surviving file still uses it",
(_verb, remoteSource, localSource, kept) => {
const f = twin()
const key = f.workspaceKey
f.store.setState((s) => ({
openFiles: s.openFiles.map((file) => ({
...file,
markdownPreviewSourceFileId: file.id === f.remote.fileId ? remoteSource : localSource
})),
editorDrafts: { ...s.editorDrafts, [remoteSource]: 'source draft' }
}))
f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })])
expect(remoteSource in f.store.getState().editorDrafts).toBe(kept)
expect(f.store.getState().editorDrafts[f.local.fileId]).toBeDefined()
}
)
})
@@ -6,7 +6,7 @@ import { buildTerminalTabRetirementPlans } from '../slices/terminal-tab-retireme
import { omitSleepingAgentSessionsForWorktrees } from '../slices/worktrees/teardown/sleeping-agent-session-omission'
import type { RecentlyClosedTabKind } from '../slices/recently-closed-tabs'
import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission'
import { cancelStructuredLaunchesForWorktrees } from '../slices/worktrees/teardown/worktree-purge-state'
import { cancelStructuredLaunchesForWorktrees } from '../slices/worktrees/teardown/purged-structured-launch-retirement'
import {
belongsToRemovedHost,
collectRemovedHostTwinState,
@@ -0,0 +1,127 @@
import type { AppState } from '../../types'
import { toVisibleTabType, type Tab, type TabGroup } from '../../../../../shared/tab-types'
import { sanitizeRecentTabIds } from '../tab-group-state'
/** Surfaces leaving workspaces that stay, such as one host's tabs under an id its twin publishes. */
export type KeptWorktreeSurfaceRemoval = {
worktreeIds: ReadonlySet<string>
terminalTabIds: ReadonlySet<string>
fileIds: ReadonlySet<string>
browserTabIds: ReadonlySet<string>
/** Unified tab ids of non-file surfaces such as chats. */
unifiedTabIds: ReadonlySet<string>
}
function isRemovedUnifiedTab(tab: Tab, removed: KeptWorktreeSurfaceRemoval): boolean {
if (removed.unifiedTabIds.has(tab.id)) {
return true
}
if (tab.contentType === 'terminal') {
return removed.terminalTabIds.has(tab.entityId)
}
if (tab.contentType === 'browser') {
return removed.browserTabIds.has(tab.entityId)
}
return toVisibleTabType(tab.contentType) === 'editor' && removed.fileIds.has(tab.entityId)
}
function pruneGroup(group: TabGroup, removedIds: ReadonlySet<string>): TabGroup {
if (!group.tabOrder.some((id) => removedIds.has(id))) {
return group
}
const tabOrder = group.tabOrder.filter((id) => !removedIds.has(id))
const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder)
// Why MRU first: closing the active tab lands on the previously active one, as closeUnifiedTab does.
const activeTabId =
group.activeTabId && removedIds.has(group.activeTabId)
? (recentTabIds.at(-1) ?? tabOrder[0] ?? null)
: group.activeTabId
return {
...group,
tabOrder,
activeTabId,
...(group.recentTabIds ? { recentTabIds } : {})
}
}
/** Rewrites only the kept workspaces' entries, keeping the map's identity when none changes. */
function pruneEntries<T>(
map: Record<string, T>,
worktreeIds: ReadonlySet<string>,
prune: (value: T) => T | undefined
): Record<string, T> {
let next: Record<string, T> | null = null
for (const worktreeId of worktreeIds) {
if (!(worktreeId in map)) {
continue
}
const value = prune(map[worktreeId])
if (value === map[worktreeId]) {
continue
}
next ??= { ...map }
if (value === undefined) {
delete next[worktreeId]
} else {
next[worktreeId] = value
}
}
return next ?? map
}
function withoutIds<T extends { id: string }>(items: T[], ids: ReadonlySet<string>): T[] {
return items.some((item) => ids.has(item.id)) ? items.filter((item) => !ids.has(item.id)) : items
}
/** Per-field pruners for workspaces that stay while some of their surfaces leave. */
export function createKeptWorktreeSurfacePruners(
s: Pick<AppState, 'unifiedTabsByWorktree'>,
removed: KeptWorktreeSurfaceRemoval
) {
const { worktreeIds } = removed
const removedUnifiedIds = new Set(
[...worktreeIds].flatMap((worktreeId) =>
(s.unifiedTabsByWorktree[worktreeId] ?? [])
.filter((tab) => isRemovedUnifiedTab(tab, removed))
.map((tab) => tab.id)
)
)
const removedIds = new Set([
...removedUnifiedIds,
...removed.terminalTabIds,
...removed.fileIds,
...removed.browserTabIds
])
// Why dropped, not repointed: selection derivation falls back to a surviving surface.
const dropSelectionOf =
<T>(ids: ReadonlySet<string>) =>
(map: Record<string, T>): Record<string, T> =>
pruneEntries(map, worktreeIds, (value) =>
typeof value === 'string' && ids.has(value) ? undefined : value
)
return {
tabs: (map: AppState['tabsByWorktree']) =>
pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removed.terminalTabIds)),
unifiedTabs: (map: AppState['unifiedTabsByWorktree']) =>
pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removedUnifiedIds)),
groups: (map: AppState['groupsByWorktree']) =>
pruneEntries(map, worktreeIds, (groups) => {
const next = groups.map((group) => pruneGroup(group, removedIds))
return next.every((group, index) => group === groups[index]) ? groups : next
}),
tabBarOrder: (map: AppState['tabBarOrderByWorktree']) =>
pruneEntries(map, worktreeIds, (order) =>
order.some((id) => removedIds.has(id)) ? order.filter((id) => !removedIds.has(id)) : order
),
browserTabs: (map: AppState['browserTabsByWorktree']) =>
pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removed.browserTabIds)),
files: (files: AppState['openFiles']) => withoutIds(files, removed.fileIds),
activeTabIds: dropSelectionOf<AppState['activeTabIdByWorktree'][string]>(
removed.terminalTabIds
),
activeFileIds: dropSelectionOf<AppState['activeFileIdByWorktree'][string]>(removed.fileIds),
activeBrowserTabIds: dropSelectionOf<AppState['activeBrowserTabIdByWorktree'][string]>(
removed.browserTabIds
)
}
}
@@ -3,6 +3,11 @@ import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-t
import type { TerminalTab } from '../../../../../shared/terminal-tab-types'
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
import { getTerminalActivationSpawnSuppression } from '../terminal-activation-spawn-suppression'
import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree'
import {
createKeptWorktreeSurfacePruners,
type KeptWorktreeSurfaceRemoval
} from './kept-worktree-surface-pruning'
// Layout transitions that worktree, repo and SSH lifecycle code folds into its own store update.
// Pure patches, not actions: each caller's update is one atomic set that also clears non-layout state.
@@ -29,39 +34,108 @@ type WorktreeLayoutPatch = Pick<
| 'activeTabType'
>
/** Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into them. */
/**
* Splits a kept workspace's purge emptied, collapsed the way closing their last tab collapses
* them, so the surviving twin's group takes the space and the selection.
*/
function collapseEmptiedKeptGroups(
s: Pick<AppState, 'groupsByWorktree' | 'layoutByWorktree' | 'activeGroupIdByWorktree'>,
groupsByWorktree: AppState['groupsByWorktree'],
worktreeIds: ReadonlySet<string>
): {
groups: AppState['groupsByWorktree']
layout: AppState['layoutByWorktree']
activeGroups: AppState['activeGroupIdByWorktree']
} {
let groupsOut = groupsByWorktree
let layoutOut = s.layoutByWorktree
let activeOut = s.activeGroupIdByWorktree
for (const worktreeId of worktreeIds) {
const before = s.groupsByWorktree[worktreeId] ?? []
let groups = groupsOut[worktreeId] ?? []
const emptied = groups.filter(
(group) =>
group.tabOrder.length === 0 &&
(before.find((prior) => prior.id === group.id)?.tabOrder.length ?? 0) > 0
)
for (const group of emptied) {
if (groups.length <= 1 || !layoutOut[worktreeId]) {
break
}
groups = groups.filter((candidate) => candidate.id !== group.id)
const collapsed = collapseGroupLayout(
layoutOut,
activeOut,
worktreeId,
group.id,
groups[0]?.id ?? null
)
layoutOut = collapsed.layoutByWorktree
// Why: only the collapsed group's own focus moves; an untouched active group keeps it.
const activeGroupId = activeOut[worktreeId]
activeOut =
activeGroupId === undefined || activeGroupId === group.id
? collapsed.activeGroupIdByWorktree
: { ...collapsed.activeGroupIdByWorktree, [worktreeId]: activeGroupId }
}
if (groups !== (groupsOut[worktreeId] ?? [])) {
groupsOut = { ...groupsOut, [worktreeId]: groups }
}
}
return { groups: groupsOut, layout: layoutOut, activeGroups: activeOut }
}
/**
* Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into
* them. `kept` names surfaces leaving workspaces that stay, whose entries are pruned instead.
*/
export function removeWorktreeLayout(
s: AppState,
removed: {
worktreeIds: ReadonlySet<string>
tabIds: ReadonlySet<string>
activeFileCleared: boolean
kept?: KeptWorktreeSurfaceRemoval
}
): WorktreeLayoutPatch {
const { worktreeIds, tabIds, activeFileCleared } = removed
const omitByWorktree = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, worktreeIds)
const omitByTabId = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, tabIds)
const removedActiveWorktree = s.activeWorktreeId != null && worktreeIds.has(s.activeWorktreeId)
const kept = removed.kept ? createKeptWorktreeSurfacePruners(s, removed.kept) : null
const keep = <T>(value: T, prune: ((value: T) => T) | undefined): T =>
prune ? prune(value) : value
const groupLayout = removed.kept
? collapseEmptiedKeptGroups(
s,
keep(omitByWorktree(s.groupsByWorktree), kept?.groups),
removed.kept.worktreeIds
)
: null
const remainingFiles = s.openFiles.some((f) => worktreeIds.has(f.worktreeId))
? s.openFiles.filter((f) => !worktreeIds.has(f.worktreeId))
: s.openFiles
return {
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
tabsByWorktree: keep(omitByWorktree(s.tabsByWorktree), kept?.tabs),
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
// Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable.
unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree),
groupsByWorktree: omitByWorktree(s.groupsByWorktree),
layoutByWorktree: omitByWorktree(s.layoutByWorktree),
activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree),
unifiedTabsByWorktree: keep(omitByWorktree(s.unifiedTabsByWorktree), kept?.unifiedTabs),
groupsByWorktree: groupLayout?.groups ?? omitByWorktree(s.groupsByWorktree),
layoutByWorktree: omitByWorktree(groupLayout?.layout ?? s.layoutByWorktree),
activeGroupIdByWorktree: omitByWorktree(groupLayout?.activeGroups ?? s.activeGroupIdByWorktree),
// Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained.
tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree),
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
tabBarOrderByWorktree: keep(omitByWorktree(s.tabBarOrderByWorktree), kept?.tabBarOrder),
browserTabsByWorktree: keep(omitByWorktree(s.browserTabsByWorktree), kept?.browserTabs),
// Why guarded: a removed worktree usually has no open file, and an unconditional
// filter would hand openFiles a new identity anyway.
openFiles: s.openFiles.some((f) => worktreeIds.has(f.worktreeId))
? s.openFiles.filter((f) => !worktreeIds.has(f.worktreeId))
: s.openFiles,
activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree),
activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree),
activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree),
openFiles: keep(remainingFiles, kept?.files),
activeTabIdByWorktree: keep(omitByWorktree(s.activeTabIdByWorktree), kept?.activeTabIds),
activeFileIdByWorktree: keep(omitByWorktree(s.activeFileIdByWorktree), kept?.activeFileIds),
activeBrowserTabIdByWorktree: keep(
omitByWorktree(s.activeBrowserTabIdByWorktree),
kept?.activeBrowserTabIds
),
activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree),
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
activeFileId: activeFileCleared ? null : s.activeFileId,
@@ -1,9 +1,11 @@
import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import {
isRemoteRuntimePtyId,
parseRemoteRuntimePtyId
} from '../../../../shared/remote-runtime-pty-id'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import type { AppState } from '../types'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
@@ -1,7 +1,19 @@
import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import type { Worktree } from '../../../../shared/worktree/types'
/** A purge target with optional host ownership for host-scoped state. */
/** A purge target after normalization: a raw id, or one host's copy of it. */
export type WorktreePurgeTarget = Pick<Worktree, 'id' | 'hostId'>
/** Strings remain supported for callers that only have a raw worktree id. */
export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[]
/**
* A raw id purges every bucket under it. A host target purges only that host's attributable
* state while another host still publishes the id, and everything otherwise.
*/
export type WorktreePurgeTargets = readonly (string | HostWorktreeTarget)[]
export function normalizeWorktreePurgeTarget(
target: string | HostWorktreeTarget
): WorktreePurgeTarget {
return typeof target === 'string'
? { id: target }
: { id: target.worktreeId, hostId: target.hostId }
}
@@ -26,6 +26,7 @@ import {
type WorktreeListingMergeOutcome
} from './detected-worktree-refresh-admission'
import { buildWorktreePurgeState } from '../teardown/worktree-purge-state'
import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity'
import { isDisplayNamePersistencePending } from '../metadata/worktree-meta-persist'
import { branchName } from '@/lib/git-utils'
import {
@@ -271,7 +272,7 @@ export function mergeFetchedWorktrees(
...(removedIds.length > 0
? buildWorktreePurgeState(
s,
removedIds.map((id) => ({ id, hostId: args.hostId }))
removedIds.map((id) => hostWorktreeTarget({ id, hostId: args.hostId }))
)
: {})
}
@@ -145,7 +145,7 @@ export function createPurgeWorktreeTerminalState(
): WorktreeSlice['purgeWorktreeTerminalState'] {
return (worktreeTargets) => {
const purgeableWorktreeTargets = worktreeTargets.filter((target) => {
const worktreeId = typeof target === 'string' ? target : target.id
const worktreeId = typeof target === 'string' ? target : target.worktreeId
return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID
})
if (purgeableWorktreeTargets.length === 0) {
@@ -0,0 +1,250 @@
import type { AppState } from '../../../types'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id'
import {
composeWorktreeHostIdentity,
type HostScopedWorktreeKey
} from '../../../../../../shared/worktree/host-qualified-identity'
import type { WorktreePurgeTarget } from '../../worktree-purge-target'
import { getFolderWorkspaceHostId } from '../../../folder-workspaces/folder-workspace-catalog'
import { getProjectGroupHostId } from '../../project-group-owner-routing'
import {
collectRemovedHostTwinState,
removedHostCanonicalizer
} from '../../../repos/repo-removal-host-twin-attribution'
import { twinSelectionPatch } from '../../../repos/repo-removal-host-twin-teardown'
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
import { buildActiveSurfacePatch } from '../../tabs/tabs-surface'
import { worktreeHostMatchOptions, worktreeMatchesHost } from '../listing/worktree-host-ownership'
import { collectSurfacePurgeDoomedIds } from './worktree-purge-doomed-ids'
import { collectPtyIdsForTab } from '../../terminal-tab-retirement'
import { createWorktreePurgeOmitters } from './worktree-purge-omitters'
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
import {
cancelStructuredLaunchesForWorktrees,
retirePurgedTabLaunches
} from './purged-structured-launch-retirement'
import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch'
type PublisherRow = {
id: string
hostId?: ExecutionHostId
runtimeOwnerEnvironmentId?: string
}
/**
* For one targeted id: hosts outside the purge that still publish it, and the host each purged
* target's state is attributed to. Rows match by the listing's host matching, so an unstamped
* legacy row or a paired runtime's SSH row counts as the purged copy, not a twin.
*/
function resolvePurgedPublishers(
s: AppState,
workspaceId: string,
purgedHosts: ReadonlySet<ExecutionHostId>
): { survivors: ExecutionHostId[]; attributedHostIds: Map<ExecutionHostId, ExecutionHostId> } {
const scope = parseWorkspaceKey(workspaceId)
const attributedHostIds = new Map([...purgedHosts].map((hostId) => [hostId, hostId]))
if (scope?.type === 'folder') {
const groups = s.projectGroups ?? []
const hosts = (s.folderWorkspaces ?? [])
.filter((folder) => folder.id === scope.folderWorkspaceId)
.flatMap((folder) => {
const groupHosts = groups
.filter((group) => group.id === folder.projectGroupId)
.map(getProjectGroupHostId)
// Why every group host: an unstamped folder under a group two hosts publish may be either's.
return folder.executionHostId || folder.connectionId || groupHosts.length === 0
? [getFolderWorkspaceHostId(folder, groups)]
: groupHosts
})
return {
survivors: [...new Set(hosts)].filter((hostId) => !purgedHosts.has(hostId)),
attributedHostIds
}
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
const repoId = getRepoIdFromWorktreeId(worktreeId)
const rows: PublisherRow[] = [
...(s.worktreesByRepo[repoId] ?? []),
...(s.detectedWorktreesByRepo[repoId]?.worktrees ?? [])
].filter((row) => row.id === worktreeId)
const survivors = new Set<ExecutionHostId>()
for (const row of rows) {
const purgedHost = [...purgedHosts].find((hostId) =>
worktreeMatchesHost(row, hostId, worktreeHostMatchOptions(s, repoId, hostId))
)
if (purgedHost) {
// Why: a copy reached through a paired runtime stamps its tabs with its own SSH host.
if (row.hostId && !purgedHosts.has(row.hostId)) {
attributedHostIds.set(purgedHost, row.hostId)
}
continue
}
survivors.add(
row.hostId ??
(row.runtimeOwnerEnvironmentId
? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId)
: LOCAL_EXECUTION_HOST_ID)
)
}
return {
survivors: [...survivors].filter((hostId) => !purgedHosts.has(hostId)),
attributedHostIds
}
}
export type HostTwinPurgeScopes = {
/** Host identities of every host target whose id a twin still publishes. */
twinTargetKeys: Set<HostScopedWorktreeKey>
/** Per attributed host, its twin ids with the hosts that still publish them. */
byHost: Map<ExecutionHostId, Map<string, ExecutionHostId[]>>
}
/** Host targets whose id a host outside the purge still publishes. A raw id for the same id wins. */
export function collectHostTwinPurgeScopes(
s: AppState,
targets: readonly WorktreePurgeTarget[]
): HostTwinPurgeScopes {
const rawIds = new Set(targets.filter((target) => !target.hostId).map((target) => target.id))
const purgedHostsById = new Map<string, Set<ExecutionHostId>>()
for (const { id, hostId } of targets) {
if (hostId && !rawIds.has(id)) {
purgedHostsById.set(id, (purgedHostsById.get(id) ?? new Set()).add(hostId))
}
}
const scopes: HostTwinPurgeScopes = { twinTargetKeys: new Set(), byHost: new Map() }
for (const [id, purgedHosts] of purgedHostsById) {
const { survivors, attributedHostIds } = resolvePurgedPublishers(s, id, purgedHosts)
if (survivors.length === 0) {
continue
}
for (const hostId of purgedHosts) {
scopes.twinTargetKeys.add(composeWorktreeHostIdentity(hostId, id))
const attributedHostId = attributedHostIds.get(hostId) ?? hostId
const scope = scopes.byHost.get(attributedHostId) ?? new Map<string, ExecutionHostId[]>()
scopes.byHost.set(attributedHostId, scope.set(id, survivors))
}
}
return scopes
}
/**
* Purges one host's attributable surfaces under ids a twin still publishes. Worktree-keyed
* buckets, raw-id module stores and surfaces with no host evidence stay: they may be the twin's.
*/
export function buildHostTwinPurgeState(
s: AppState,
hostId: ExecutionHostId,
twinHostIds: ReadonlyMap<string, readonly ExecutionHostId[]>
): Partial<AppState> {
const twinIds = new Set(twinHostIds.keys())
const removed = collectRemovedHostTwinState(s, twinIds, hostId, twinHostIds)
const canonical = (worktreeId: string) =>
removedHostCanonicalizer(s, worktreeId, hostId, twinHostIds.get(worktreeId) ?? [])
const cancelledSessionIds = cancelStructuredLaunchesForWorktrees(
twinIds,
(worktreeId, launchHostId) => canonical(worktreeId)(launchHostId) === hostId
)
const terminalTabs: { id: string; ptyId: string | null }[] = []
for (const worktreeId of twinIds) {
const legacyTabs = (s.tabsByWorktree[worktreeId] ?? []).filter((tab) =>
removed.terminalTabIds.has(tab.id)
)
terminalTabs.push(...legacyTabs)
retirePurgedTabLaunches(
s,
worktreeId,
legacyTabs,
(s.unifiedTabsByWorktree[worktreeId] ?? []).filter((tab) => removed.otherTabIds.has(tab.id)),
cancelledSessionIds
)
}
// Why: a hydrated terminal can exist only as a unified tab, with no legacy row.
for (const tabId of removed.terminalTabIds) {
if (!terminalTabs.some((tab) => tab.id === tabId)) {
terminalTabs.push({ id: tabId, ptyId: null })
}
}
// Why: a preview keys its source's editor state, unless a surviving file still uses that source.
const survivingFiles = s.openFiles.filter((file) => !removed.fileIds.has(file.id))
const previewSourceIds = s.openFiles.flatMap((file) =>
removed.fileIds.has(file.id) &&
file.markdownPreviewSourceFileId &&
!survivingFiles.some(
(other) =>
other.id === file.markdownPreviewSourceFileId ||
other.markdownPreviewSourceFileId === file.markdownPreviewSourceFileId
)
? [file.markdownPreviewSourceFileId]
: []
)
const doomed = collectSurfacePurgeDoomedIds(s, {
tabs: terminalTabs,
browserWorkspaceIds: removed.browserTabIds,
fileIds: [...removed.fileIds, ...previewSourceIds]
})
// Why: a PTY a kept tab still binds stays live, so its PTY-keyed state must stay with it.
for (const worktreeId of twinIds) {
for (const tab of s.tabsByWorktree[worktreeId] ?? []) {
if (!doomed.doomedTabIds.has(tab.id)) {
for (const ptyId of collectPtyIdsForTab(s, tab.id, tab.ptyId)) {
doomed.doomedPtyIds.delete(ptyId)
}
}
}
}
const omitters = createWorktreePurgeOmitters(s, new Set(), doomed)
const tabPrefixedSleeping = {
sleepingAgentSessionsByPaneKey: omitters.omitByPaneKeyTabPrefix(
s.sleepingAgentSessionsByPaneKey
),
agentLaunchConfigByPaneKey: omitters.omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey)
}
// Why SSH only: a record whose tab is gone names its host just by connection; null is ambiguous.
const sshRecordPaneKeys = Object.entries(s.sleepingAgentSessionsByPaneKey ?? {}).flatMap(
([paneKey, record]) =>
twinIds.has(record.worktreeId) &&
record.connectionId &&
canonical(record.worktreeId)(toSshExecutionHostId(record.connectionId)) === hostId
? [paneKey]
: []
)
const layout = removeWorktreeLayout(s, {
worktreeIds: new Set(),
tabIds: doomed.doomedTabIds,
activeFileCleared: s.activeFileId != null && removed.fileIds.has(s.activeFileId),
kept: {
worktreeIds: twinIds,
terminalTabIds: removed.terminalTabIds,
fileIds: removed.fileIds,
browserTabIds: removed.browserTabIds,
unifiedTabIds: removed.otherTabIds
}
})
const active = s.activeWorktreeId && twinIds.has(s.activeWorktreeId) ? s.activeWorktreeId : null
// Why only then: re-deriving an untouched selection could switch the visible surface.
const selectionRemoved =
active !== null &&
((s.activeTabId != null && removed.terminalTabIds.has(s.activeTabId)) ||
(s.activeFileId != null && removed.fileIds.has(s.activeFileId)) ||
(s.activeBrowserTabId != null && removed.browserTabIds.has(s.activeBrowserTabId)) ||
(layout.groupsByWorktree[active] ?? []).some(
(group, index) => group.activeTabId !== s.groupsByWorktree[active]?.[index]?.activeTabId
))
return {
...layout,
// Why: a group may now show a surviving tab of another kind, so selection is derived again.
...(selectionRemoved ? buildActiveSurfacePatch({ ...s, ...layout }, active) : {}),
...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, {
...tabPrefixedSleeping,
...omitSleepingAgentSessionsForWorktrees(tabPrefixedSleeping, new Set(), sshRecordPaneKeys)
}),
...twinSelectionPatch(s, twinHostIds, hostId)
}
}
@@ -15,7 +15,7 @@ import {
dropWorktreeRowsForRemovedRuntimeEnvironments,
isRemovedRuntimeHostId
} from '../../stale-runtime-host-rows'
import { buildWorktreePurgeState } from './worktree-purge-state'
import { buildFullWorktreePurgeState } from './worktree-purge-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up'
@@ -211,7 +211,9 @@ export function createPurgeStaleRuntimeHostState(
const targets = removedWorktreeTargets.filter((target) => target.id === worktreeId)
return targets.length > 0 ? targets : [{ id: worktreeId }]
})
const purgeState = purgeTargets.length > 0 ? buildWorktreePurgeState(s, purgeTargets) : {}
// Why full: survivor ids were dropped above unless the session partition proves the bare-id state is the removed host's.
const purgeState =
purgeTargets.length > 0 ? buildFullWorktreePurgeState(s, purgeTargets) : {}
const visitPurgeTargets = [
...removedWorktreeTargets,
...purgeTargets.filter(
@@ -0,0 +1,89 @@
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { TerminalTab } from '../../../../../../shared/terminal-tab-types'
import type { Tab } from '../../../../../../shared/tab-types'
import type { AppState } from '../../../types'
import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start'
import {
getStructuredAgentSessionLaunchOwner,
markStructuredAgentSessionLaunchCancelledSilently,
shouldRetainStructuredAgentSessionLaunchTab,
structuredLaunchStates
} from '@/lib/structured-agent-session-launch-registry'
import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt'
import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry'
import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent'
import {
structuredAgentSessionFocusOwner,
structuredAgentSessionTargetForTab,
structuredAgentSessionTargetForHost
} from '@/runtime/structured-agent-session-owner'
/** Settles launch waits and retires chat ownership for purged tabs of one workspace. */
export function retirePurgedTabLaunches(
s: AppState,
worktreeId: string,
terminalTabs: readonly TerminalTab[],
unifiedTabs: readonly Tab[],
cancelledSessionIds: ReadonlySet<string>
): void {
// A launch tab that never spawned has no spawn left to answer its prompt's wait.
for (const tab of terminalTabs) {
if (tab.agentLaunchPane) {
settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null)
}
}
for (const tab of unifiedTabs) {
if (tab.contentType === 'agent-session') {
const target = structuredAgentSessionTargetForTab(s, tab)
if (target) {
retireStructuredAgentSessionReadOwner(tab.entityId, target)
}
}
// A retained launch here survived a reload, so its persisted record names its host.
const owner =
tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId)
? getStructuredAgentSessionLaunchOwner(tab.entityId)
: undefined
const target = structuredAgentSessionTargetForHost(owner)
if (owner && target && shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) {
markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner)
discardStructuredAgentSessionChatSends(tab.entityId)
clearWebSessionFocusIntentIfMatches(
structuredAgentSessionFocusOwner(target),
worktreeId,
`agent-session:${tab.entityId}`
)
}
}
}
/** Cancels provisional structured-chat launches in these worktrees, on the given host only. */
export function cancelStructuredLaunchesForWorktrees(
worktreeIdSet: ReadonlySet<string>,
ownedByHost?: (worktreeId: string, executionHostId: ExecutionHostId) => boolean
): Set<string> {
const cancelledSessionIds = new Set<string>()
for (const launch of structuredLaunchStates()) {
const worktreeId = launch.intent.worktreeId
if (
worktreeIdSet.has(worktreeId) &&
(!ownedByHost || ownedByHost(worktreeId, launch.intent.executionHostId)) &&
shouldRetainStructuredAgentSessionLaunchTab(worktreeId, launch.intent.sessionId)
) {
markStructuredAgentSessionLaunchCancelledSilently(
worktreeId,
launch.intent.sessionId,
launch.intent.executionHostId
)
discardStructuredAgentSessionChatSends(launch.intent.sessionId)
retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target)
clearWebSessionFocusIntentIfMatches(
structuredAgentSessionFocusOwner(launch.intent.target),
worktreeId,
`agent-session:${launch.intent.sessionId}`
)
cancelledSessionIds.add(launch.intent.sessionId)
}
}
return cancelledSessionIds
}
@@ -16,6 +16,37 @@ export type WorktreePurgeDoomedIds = {
export function collectWorktreePurgeDoomedIds(
s: AppState,
worktreeIdSet: Set<string>
): WorktreePurgeDoomedIds {
const tabs: { id: string; ptyId: string | null }[] = []
const browserWorkspaceIds: string[] = []
const fileIds: string[] = []
for (const id of worktreeIdSet) {
tabs.push(...(s.tabsByWorktree[id] ?? []))
for (const workspace of s.browserTabsByWorktree[id] ?? []) {
browserWorkspaceIds.push(workspace.id)
}
// Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session.
forgetDetachedHeadAutoDerivedDisplayName(id)
}
for (const file of s.openFiles) {
if (worktreeIdSet.has(file.worktreeId)) {
fileIds.push(file.id)
if (file.markdownPreviewSourceFileId) {
fileIds.push(file.markdownPreviewSourceFileId)
}
}
}
return collectSurfacePurgeDoomedIds(s, { tabs, browserWorkspaceIds, fileIds })
}
/** Doomed ids for named surfaces, which may sit in a workspace that stays (a surviving twin's). */
export function collectSurfacePurgeDoomedIds(
s: AppState,
surfaces: {
tabs: readonly { id: string; ptyId: string | null }[]
browserWorkspaceIds: Iterable<string>
fileIds: Iterable<string>
}
): WorktreePurgeDoomedIds {
// Collect every tab id (and removed file id) we are about to orphan.
const doomedTabIds = new Set<string>()
@@ -37,22 +68,15 @@ export function collectWorktreePurgeDoomedIds(
addDoomedPtyId(ptyId)
}
}
const doomedBrowserWorkspaceIds = new Set<string>()
const doomedBrowserWorkspaceIds = new Set(surfaces.browserWorkspaceIds)
const doomedPageIds = new Set<string>()
const removedFileIds = new Set<string>()
for (const id of worktreeIdSet) {
for (const tab of s.tabsByWorktree[id] ?? []) {
doomedTabIds.add(tab.id)
// Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}).
addDoomedTabPtyIds(tab.id, tab.ptyId)
// Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0).
forgetAgentHibernationTabOutput(tab.id)
}
for (const workspace of s.browserTabsByWorktree[id] ?? []) {
doomedBrowserWorkspaceIds.add(workspace.id)
}
// Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session.
forgetDetachedHeadAutoDerivedDisplayName(id)
const removedFileIds = new Set(surfaces.fileIds)
for (const tab of surfaces.tabs) {
doomedTabIds.add(tab.id)
// Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}).
addDoomedTabPtyIds(tab.id, tab.ptyId)
// Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0).
forgetAgentHibernationTabOutput(tab.id)
}
// Why: same rationale for doomed tabs' foreground last-seen timestamps and agent-startup delivery guards — retired tab ids never recur.
forgetForegroundTerminalTabs(doomedTabIds)
@@ -66,14 +90,6 @@ export function collectWorktreePurgeDoomedIds(
doomedPageIds.add(page.id)
}
}
for (const file of s.openFiles) {
if (worktreeIdSet.has(file.worktreeId)) {
removedFileIds.add(file.id)
if (file.markdownPreviewSourceFileId) {
removedFileIds.add(file.markdownPreviewSourceFileId)
}
}
}
return {
doomedTabIds,
doomedPtyIds,
@@ -1,6 +1,6 @@
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { AppState } from '../../../types'
import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers'
import { normalizeWorktreePurgeTarget } from '../../worktree-purge-target'
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation'
@@ -8,72 +8,79 @@ import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids'
import { createWorktreePurgeOmitters } from './worktree-purge-omitters'
import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission'
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { omitRecordKeys } from './record-key-omission'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start'
import {
getStructuredAgentSessionLaunchOwner,
markStructuredAgentSessionLaunchCancelledSilently,
shouldRetainStructuredAgentSessionLaunchTab,
structuredLaunchStates
} from '@/lib/structured-agent-session-launch-registry'
import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt'
import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry'
import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent'
import {
structuredAgentSessionFocusOwner,
structuredAgentSessionTargetForTab,
structuredAgentSessionTargetForHost
} from '@/runtime/structured-agent-session-owner'
import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions'
import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle'
import {
cancelStructuredLaunchesForWorktrees,
retirePurgedTabLaunches
} from './purged-structured-launch-retirement'
import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch'
import { buildHostTwinPurgeState, collectHostTwinPurgeScopes } from './host-twin-worktree-purge'
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
/** Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual terminal teardown. */
/**
* Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual
* terminal teardown. A host target whose id another host still publishes purges only that host's
* attributable state; everything else purges every bucket under the id.
*/
export function buildWorktreePurgeState(
s: AppState,
worktreeTargets: WorktreePurgeTargets
): Partial<AppState> {
const normalizedTargets: WorktreePurgeTarget[] = worktreeTargets.map((target) =>
typeof target === 'string' ? { id: target } : target
const targets = worktreeTargets.map(normalizeWorktreePurgeTarget)
const twinScopes = collectHostTwinPurgeScopes(s, targets)
if (twinScopes.byHost.size === 0) {
return buildFullWorktreePurgeState(s, targets)
}
const fullTargets = targets.filter(
(target) =>
!target.hostId ||
!twinScopes.twinTargetKeys.has(composeWorktreeHostIdentity(target.hostId, target.id))
)
let patch = fullTargets.length > 0 ? buildFullWorktreePurgeState(s, fullTargets) : {}
for (const [hostId, twinHostIds] of twinScopes.byHost) {
patch = { ...patch, ...buildHostTwinPurgeState({ ...s, ...patch }, hostId, twinHostIds) }
}
// Why both hosts: a paired runtime's copy writes recency and delete state under either identity.
const hostKeys = [
...targets.filter((target) => !fullTargets.includes(target)),
...[...twinScopes.byHost].flatMap(([hostId, twins]) =>
[...twins.keys()].map((id) => ({ id, hostId }))
)
].flatMap((target) => (target.hostId ? [{ id: target.id, hostId: target.hostId }] : []))
const next = { ...s, ...patch }
return {
...patch,
lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets(
next.lastVisitedAtByWorktreeId,
hostKeys
),
deleteStateByWorktreeId: omitRecordKeys(
next.deleteStateByWorktreeId,
hostKeys.map((target) => composeWorktreeHostIdentity(target.hostId, target.id))
)
}
}
/** Drops every bucket under the targets' ids; visit recency alone stays host-scoped. */
export function buildFullWorktreePurgeState(
s: AppState,
normalizedTargets: readonly WorktreePurgeTarget[]
): Partial<AppState> {
const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id))
// Why: bulk purges bypass close actions, so provisional ownership must die before tab state does.
const cancelledSessionIds = cancelStructuredLaunchesForWorktrees(worktreeIdSet)
for (const worktreeId of worktreeIdSet) {
// A launch tab that never spawned has no spawn left to answer its prompt's wait.
for (const tab of s.tabsByWorktree[worktreeId] ?? []) {
if (tab.agentLaunchPane) {
settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null)
}
}
for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) {
if (tab.contentType === 'agent-session') {
const target = structuredAgentSessionTargetForTab(s, tab)
if (target) {
retireStructuredAgentSessionReadOwner(tab.entityId, target)
}
}
// A retained launch here survived a reload, so its persisted record names its host.
const owner =
tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId)
? getStructuredAgentSessionLaunchOwner(tab.entityId)
: undefined
const target = structuredAgentSessionTargetForHost(owner)
if (
owner &&
target &&
shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)
) {
markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner)
discardStructuredAgentSessionChatSends(tab.entityId)
clearWebSessionFocusIntentIfMatches(
structuredAgentSessionFocusOwner(target),
worktreeId,
`agent-session:${tab.entityId}`
)
}
}
retirePurgedTabLaunches(
s,
worktreeId,
s.tabsByWorktree[worktreeId] ?? [],
s.unifiedTabsByWorktree[worktreeId] ?? [],
cancelledSessionIds
)
}
pruneHostedReviewLinkMutationGenerations(worktreeIdSet)
// Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep.
@@ -85,17 +92,13 @@ export function buildWorktreePurgeState(
forgetAmbiguousOwnerWarnings(worktreeIdSet)
const doomed = collectWorktreePurgeDoomedIds(s, worktreeIdSet)
const omitters = createWorktreePurgeOmitters(s, worktreeIdSet, doomed)
const {
omitByWorktree,
omitWorkspaceLineageByWorktree,
pruneRightSidebarTabByWorktree,
omitByTabId,
omitByPtyId,
omitByPaneKeyTabPrefix,
omitByBrowserWorkspaceId,
omitByPageId,
omitByFileId
} = createWorktreePurgeOmitters(s, worktreeIdSet, doomed)
omitByPaneKeyTabPrefix
} = omitters
const removedActive = s.activeWorktreeId != null && worktreeIdSet.has(s.activeWorktreeId)
const activeFileCleared = s.activeFileId != null && doomed.removedFileIds.has(s.activeFileId)
@@ -117,7 +120,6 @@ export function buildWorktreePurgeState(
}
return next
})()
const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey)
const tabPrefixedSleeping = {
sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey),
agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey)
@@ -134,44 +136,10 @@ export function buildWorktreePurgeState(
tabIds: doomed.doomedTabIds,
activeFileCleared
}),
...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, nextSleeping),
// Worktree-scoped terminal/tab state
worktreeLineageById: omitByWorktree(s.worktreeLineageById),
workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey),
localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId),
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),
// Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself.
expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId),
canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId),
// Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees.
lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId),
// Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness.
pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId),
unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds),
deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId),
pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId),
pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId),
pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId),
codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId),
migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId),
suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds),
pendingPaneAccountRestarts: omitByPtyId(s.pendingPaneAccountRestarts),
// Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge).
// retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync).
agentStatusByPaneKey: nextAgentStatusByPaneKey,
...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
? { agentStatusEpoch: s.agentStatusEpoch + 1 }
: {}),
agentLaunchConfigByPaneKey: nextSleeping.agentLaunchConfigByPaneKey,
acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey),
activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey),
manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey),
paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey),
sleepingAgentSessionsByPaneKey: nextSleeping.sleepingAgentSessionsByPaneKey,
...removeTabsAttention(s, doomed.doomedTabIds),
lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey),
// Delete state
deleteStateByWorktreeId: removeDeleteStatesForWorktreeIds(
s.deleteStateByWorktreeId,
@@ -181,21 +149,7 @@ export function buildWorktreePurgeState(
remoteBranchConflictByWorktreeId: omitByWorktree(s.remoteBranchConflictByWorktreeId),
// File search
fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree),
// Browser state
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
// Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees.
browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId),
browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId),
remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId),
pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId),
// createBrowserTab writes both the workspace id and the page id into this map.
pendingAddressBarFocusByTabId: omitByPageId(
omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId)
),
recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId(
s.recentlyClosedBrowserPagesByWorkspace
),
// Editor state
pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree),
rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(),
@@ -220,14 +174,6 @@ export function buildWorktreePurgeState(
explorerDisplayRootByWorktree: omitByWorktree(s.explorerDisplayRootByWorktree),
showDotfilesByWorktree: omitByWorktree(s.showDotfilesByWorktree),
expandedDirs: omitByWorktree(s.expandedDirs),
// Per-file editor state for removed files
editorDrafts: omitByFileId(s.editorDrafts),
markdownViewMode: omitByFileId(s.markdownViewMode),
markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride),
markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible),
// Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file.
editorCursorLine: omitByFileId(s.editorCursorLine),
editorViewMode: omitByFileId(s.editorViewMode),
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (editor-undo / Cmd+Shift+T snapshots).
recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree),
recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree),
@@ -255,34 +201,3 @@ export function buildWorktreePurgeState(
})()
}
}
/** Cancels provisional structured-chat launches in these worktrees, on the given host only. */
export function cancelStructuredLaunchesForWorktrees(
worktreeIdSet: ReadonlySet<string>,
ownedByHost?: (worktreeId: string, executionHostId: ExecutionHostId) => boolean
): Set<string> {
const cancelledSessionIds = new Set<string>()
for (const launch of structuredLaunchStates()) {
const worktreeId = launch.intent.worktreeId
if (
worktreeIdSet.has(worktreeId) &&
(!ownedByHost || ownedByHost(worktreeId, launch.intent.executionHostId)) &&
shouldRetainStructuredAgentSessionLaunchTab(worktreeId, launch.intent.sessionId)
) {
markStructuredAgentSessionLaunchCancelledSilently(
worktreeId,
launch.intent.sessionId,
launch.intent.executionHostId
)
discardStructuredAgentSessionChatSends(launch.intent.sessionId)
retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target)
clearWebSessionFocusIntentIfMatches(
structuredAgentSessionFocusOwner(launch.intent.target),
worktreeId,
`agent-session:${launch.intent.sessionId}`
)
cancelledSessionIds.add(launch.intent.sessionId)
}
}
return cancelledSessionIds
}
@@ -0,0 +1,88 @@
import type { AppState } from '../../../types'
import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions'
import type { createWorktreePurgeOmitters } from './worktree-purge-omitters'
type WorktreePurgeOmitters = ReturnType<typeof createWorktreePurgeOmitters>
/** The paneKey-keyed sleeping maps after the caller's own omission policy. */
export type WorktreePurgeSleepingMaps = Pick<
AppState,
'sleepingAgentSessionsByPaneKey' | 'agentLaunchConfigByPaneKey'
>
/** Tab-, PTY-, pane-, page- and file-keyed state of purged surfaces; never a worktree-keyed bucket. */
export function buildWorktreePurgeSurfacePatch(
s: AppState,
omitters: WorktreePurgeOmitters,
doomedTabIds: ReadonlySet<string>,
sleeping: WorktreePurgeSleepingMaps
): Partial<AppState> {
const {
omitByTabId,
omitByPtyId,
omitByPaneKeyTabPrefix,
omitByBrowserWorkspaceId,
omitByPageId,
omitByFileId
} = omitters
const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey)
return {
localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId),
runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId),
automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId),
nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId),
nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId),
// Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself.
expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId),
canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId),
// Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees.
lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId),
// Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness.
pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId),
unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds),
deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId),
pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId),
pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId),
pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId),
codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId),
migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId),
suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds),
pendingPaneAccountRestarts: omitByPtyId(s.pendingPaneAccountRestarts),
// Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge).
// retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync).
agentStatusByPaneKey: nextAgentStatusByPaneKey,
...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
? { agentStatusEpoch: s.agentStatusEpoch + 1 }
: {}),
agentLaunchConfigByPaneKey: sleeping.agentLaunchConfigByPaneKey,
acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey),
activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey),
manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey),
paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey),
sleepingAgentSessionsByPaneKey: sleeping.sleepingAgentSessionsByPaneKey,
...removeTabsAttention(s, doomedTabIds),
lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey),
// Browser state
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
// Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees.
browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId),
browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId),
remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId),
pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId),
// createBrowserTab writes both the workspace id and the page id into this map.
pendingAddressBarFocusByTabId: omitByPageId(
omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId)
),
recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId(
s.recentlyClosedBrowserPagesByWorkspace
),
// Per-file editor state for removed files
editorDrafts: omitByFileId(s.editorDrafts),
markdownViewMode: omitByFileId(s.markdownViewMode),
markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride),
markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible),
// Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file.
editorCursorLine: omitByFileId(s.editorCursorLine),
editorViewMode: omitByFileId(s.editorViewMode)
}
}
@@ -5,7 +5,9 @@ import {
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
type HostScopedWorktreeKey
hostWorktreeTarget,
type HostScopedWorktreeKey,
type HostWorktreeTarget
} from './host-qualified-identity'
const WORKTREE_ID = 'repo-1::/work/orca'
@@ -120,3 +122,18 @@ describe('worktree host identity golden bytes', () => {
expect([raw, forged]).toEqual([key, WORKTREE_ID])
})
})
describe('host worktree target', () => {
it('carries the row id and its concrete host', () => {
const target = hostWorktreeTarget({ id: WORKTREE_ID, hostId: toSshExecutionHostId('box') })
expect(target).toEqual({ hostId: 'ssh:box', worktreeId: WORKTREE_ID })
})
it('cannot be forged from a plain object or built without a host', () => {
// @ts-expect-error only hostWorktreeTarget mints the brand
const forged: HostWorktreeTarget = { hostId: 'local', worktreeId: WORKTREE_ID }
// @ts-expect-error an unqualified row has no host to scope to
hostWorktreeTarget({ id: WORKTREE_ID, hostId: undefined })
expect(forged.worktreeId).toBe(WORKTREE_ID)
})
})
@@ -22,6 +22,23 @@ declare const hostScopedWorktreeKeyBrand: unique symbol
// Why: for new code only; existing raw-id stores stay as they are (D7).
export type HostScopedWorktreeKey = string & { readonly [hostScopedWorktreeKeyBrand]: true }
declare const hostWorktreeTargetBrand: unique symbol
/** One host's copy of a workspace, for destructive entry points that must not reach its twin. */
export type HostWorktreeTarget = {
readonly hostId: ExecutionHostId
readonly worktreeId: string
readonly [hostWorktreeTargetBrand]: true
}
export function hostWorktreeTarget(row: {
id: string
hostId: ExecutionHostId
}): HostWorktreeTarget {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the only constructor of the brand; `hostId` is a concrete host by the parameter type.
return { hostId: row.hostId, worktreeId: row.id } as HostWorktreeTarget
}
/**
* Stable key for one workspace on one host (STA-4343).
*