mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(status-bar): stop counting exited sessions in the closed resource badge
The closed badge counted every claimed PTY id (wake hints included), while the open popover counts live daemon sessions — so sessions that died while their tab was unmounted kept inflating the badge, and persisted wake hints made the stale count survive restarts (#8372). Record not-running evidence in deadPtyIds (renderer exit events + the popover's existing authoritative listing) and subtract it in the closed count selector. Claim maps stay untouched: dead ids still key daemon-side cold restore and sleep/hibernation wake. The record is claim-bounded, persisted across restarts, and cleared when a live pane re-registers the id. Terminal shutdown persistence helpers move to workspace-session-terminal-shutdown.ts to keep workspace-session.ts under the max-lines cap.
This commit is contained in:
@@ -78,6 +78,10 @@ import {
|
||||
type ResourceSessionBindingInputs
|
||||
} from './resource-session-bindings'
|
||||
import { createClosedResourceSessionCountSelector } from './resource-session-count-selector'
|
||||
import {
|
||||
collectClaimedPtyIds,
|
||||
deriveExitedPtyIdsFromListing
|
||||
} from '../../store/slices/pty-session-liveness'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const POLL_MS = 2_000
|
||||
@@ -837,6 +841,17 @@ export function ResourceUsageStatusSegment({
|
||||
)
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
// Why: a successful listing is the daemon's fail-closed local inventory,
|
||||
// so claims it omits are dead and the closed badge must stop counting
|
||||
// them (#8372). Snapshot claims/attachments before the RPC so a session
|
||||
// spawned or woken mid-flight is never declared dead by an older listing.
|
||||
const stateBefore = useAppStore.getState()
|
||||
const livenessSnapshot = stateBefore.workspaceSessionReady
|
||||
? {
|
||||
claimedBeforeRequest: collectClaimedPtyIds(stateBefore),
|
||||
attachedBeforeRequest: new Set(Object.values(stateBefore.ptyIdsByTabId).flat())
|
||||
}
|
||||
: null
|
||||
try {
|
||||
const result = await window.api.pty.listSessions()
|
||||
if (!mountedRef.current) {
|
||||
@@ -844,6 +859,16 @@ export function ResourceUsageStatusSegment({
|
||||
}
|
||||
setSessions(result)
|
||||
setSessionsError(false)
|
||||
if (livenessSnapshot) {
|
||||
const stateNow = useAppStore.getState()
|
||||
stateNow.markPtySessionsExited(
|
||||
deriveExitedPtyIdsFromListing({
|
||||
...livenessSnapshot,
|
||||
attachedNow: new Set(Object.values(stateNow.ptyIdsByTabId).flat()),
|
||||
listedSessionIds: new Set(result.map((session) => session.id))
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setSessionsError(true)
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeState(
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
workspaceSessionReady: true,
|
||||
deadPtyIds: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -216,4 +217,44 @@ describe('closed resource session count selector', () => {
|
||||
expect(selectCount(state)).toBe(0)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('subtracts proven-dead bound ids without rebuilding the binding index', () => {
|
||||
const buildIndex = vi.fn(buildResourceSessionBindingIndex)
|
||||
const selectCount = createClosedResourceSessionCountSelector(buildIndex)
|
||||
let state = makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab('tab-1', 'pty-wake')]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-live']
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': makeLayout({ 'leaf-1': 'pty-sleeping' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(selectCount(state)).toBe(3)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A dead wake hint stops counting the moment evidence lands, and the
|
||||
// subtraction must not pay for an index rebuild.
|
||||
state = { ...state, deadPtyIds: { 'pty-sleeping': true } }
|
||||
expect(selectCount(state)).toBe(2)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Dead ids that are not bound never push the count negative or below truth.
|
||||
state = { ...state, deadPtyIds: { 'pty-sleeping': true, 'pty-unrelated': true } }
|
||||
expect(selectCount(state)).toBe(2)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Wake resurrection: the id leaves the dead record and counts again.
|
||||
state = { ...state, deadPtyIds: {} }
|
||||
expect(selectCount(state)).toBe(3)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Unrelated writes with a stable dead record stay scalar-cheap.
|
||||
const stable = { ...state }
|
||||
expect(selectCount(stable)).toBe(3)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
|
||||
export type ClosedResourceSessionCountState = Pick<
|
||||
AppState,
|
||||
'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'workspaceSessionReady'
|
||||
| 'tabsByWorktree'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'workspaceSessionReady'
|
||||
| 'deadPtyIds'
|
||||
>
|
||||
|
||||
type BuildResourceSessionBindingIndex = (
|
||||
@@ -53,6 +57,22 @@ function haveSameTabBindings(
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: bound wake hints can reference sessions that already exited (they are
|
||||
// kept for cold-restore/wake). The badge counts RUNNING sessions, so proven
|
||||
// dead ids are subtracted rather than counted (#8372).
|
||||
function countRunningBoundPtyIds(
|
||||
boundPtyIds: ReadonlySet<string>,
|
||||
deadPtyIds: AppState['deadPtyIds']
|
||||
): number {
|
||||
let dead = 0
|
||||
for (const ptyId of Object.keys(deadPtyIds)) {
|
||||
if (boundPtyIds.has(ptyId)) {
|
||||
dead += 1
|
||||
}
|
||||
}
|
||||
return boundPtyIds.size - dead
|
||||
}
|
||||
|
||||
export function createClosedResourceSessionCountSelector(
|
||||
buildBindingIndex: BuildResourceSessionBindingIndex = buildResourceSessionBindingIndex
|
||||
): ClosedResourceSessionCountSelector {
|
||||
@@ -63,6 +83,8 @@ export function createClosedResourceSessionCountSelector(
|
||||
let previousPtyIdsByTabId: AppState['ptyIdsByTabId'] = {}
|
||||
let previousTerminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {}
|
||||
let previousWorkspaceSessionReady = false
|
||||
let previousDeadPtyIds: AppState['deadPtyIds'] = {}
|
||||
let boundPtyIds: ReadonlySet<string> = new Set()
|
||||
let count = 0
|
||||
|
||||
return (state): number => {
|
||||
@@ -85,17 +107,23 @@ export function createClosedResourceSessionCountSelector(
|
||||
const shouldRebuild =
|
||||
state.workspaceSessionReady &&
|
||||
(!initialized || bindingMapChanged || readinessChanged || tabBindingsChanged)
|
||||
const deadPtyIdsChanged = state.deadPtyIds !== previousDeadPtyIds
|
||||
|
||||
if (shouldRebuild) {
|
||||
count = buildBindingIndex(state).boundPtyIds.size
|
||||
boundPtyIds = buildBindingIndex(state).boundPtyIds
|
||||
count = countRunningBoundPtyIds(boundPtyIds, state.deadPtyIds)
|
||||
} else if (!state.workspaceSessionReady) {
|
||||
boundPtyIds = new Set()
|
||||
count = 0
|
||||
} else if (deadPtyIdsChanged) {
|
||||
count = countRunningBoundPtyIds(boundPtyIds, state.deadPtyIds)
|
||||
}
|
||||
|
||||
previousTabsByWorktree = state.tabsByWorktree
|
||||
previousPtyIdsByTabId = state.ptyIdsByTabId
|
||||
previousTerminalLayoutsByTabId = state.terminalLayoutsByTabId
|
||||
previousWorkspaceSessionReady = state.workspaceSessionReady
|
||||
previousDeadPtyIds = state.deadPtyIds
|
||||
initialized = true
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -855,6 +855,16 @@ export function useIpcEvents(): void {
|
||||
|
||||
unsubs.push(attachMobileMarkdownBridge())
|
||||
|
||||
// Why: exits for unmounted panes have no pane-level owner, so nothing else
|
||||
// records that the session stopped running and the closed Resource Manager
|
||||
// badge keeps counting it (#8372). Wake hints stay intact for cold restore;
|
||||
// only the liveness record changes.
|
||||
unsubs.push(
|
||||
window.api.pty.onExit(({ id }) => {
|
||||
useAppStore.getState().markPtySessionsExited([id])
|
||||
})
|
||||
)
|
||||
|
||||
const handleWorktreesChanged = async (
|
||||
repoId: string,
|
||||
renamed?: { oldWorktreeId: string; newWorktreeId: string }
|
||||
|
||||
@@ -12,6 +12,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
deadPtyIds: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ function createSnapshot(
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
deadPtyIds: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
|
||||
@@ -65,6 +65,10 @@ const FIELD_OWNERSHIP = {
|
||||
defaultTerminalTabsAppliedByWorktreeId: 'worktreeKeyed',
|
||||
activeWorkspaceKey: 'global',
|
||||
activeWorktreeIdsOnShutdown: 'worktreeArray',
|
||||
// Why: session ids are host-namespaced, so marks for another host's
|
||||
// sessions are inert in that host's slice and get dropped by the
|
||||
// claim-bounded GC on the next liveness write.
|
||||
deadPtyIds: 'global',
|
||||
terminalLayoutsByTabId: 'tabKeyed',
|
||||
remoteSessionIdsByTabId: 'tabKeyed',
|
||||
browserPagesByWorkspace: 'browserWorkspaceKeyed',
|
||||
|
||||
@@ -12,6 +12,7 @@ function createSnapshot(
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
deadPtyIds: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
@@ -91,4 +92,29 @@ describe('workspace session live PTY persistence', () => {
|
||||
expect(payload.activeWorktreeIdsOnShutdown).toEqual([])
|
||||
expect(payload.remoteSessionIdsByTabId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists dead session marks only while their wake hints are still claimed', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
title: 'shell',
|
||||
ptyId: 'claimed-dead-hint',
|
||||
worktreeId: 'wt-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
deadPtyIds: { 'claimed-dead-hint': true, 'released-dead-id': true }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.deadPtyIds).toEqual(['claimed-dead-hint'])
|
||||
})
|
||||
|
||||
it('omits the dead-mark field entirely when empty', () => {
|
||||
expect(buildWorkspaceSessionPayload(createSnapshot()).deadPtyIds).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ function createSnapshot(
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-2': []
|
||||
},
|
||||
deadPtyIds: {},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
@@ -206,6 +207,8 @@ describe('buildWorkspaceSessionPatch', () => {
|
||||
expect(Object.keys(patch).sort()).toEqual(
|
||||
[
|
||||
'activeWorktreeIdsOnShutdown',
|
||||
// Why: tab topology bounds which dead-session marks stay persisted.
|
||||
'deadPtyIds',
|
||||
'remoteSessionIdsByTabId',
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId'
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
buildEditorSessionData,
|
||||
buildPersistedBrowserPagesByWorkspace,
|
||||
buildPersistedBrowserTabsByWorktree,
|
||||
buildSanitizedTabsByWorktree,
|
||||
buildTerminalSessionData,
|
||||
type WorkspaceSessionSnapshot
|
||||
} from './workspace-session'
|
||||
import {
|
||||
buildSanitizedTabsByWorktree,
|
||||
buildTerminalSessionData
|
||||
} from './workspace-session-terminal-shutdown'
|
||||
import { collectPersistableDeadPtyIds } from '../store/slices/pty-session-liveness'
|
||||
import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs'
|
||||
import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency'
|
||||
import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents'
|
||||
@@ -74,6 +77,18 @@ export function buildWorkspaceSessionPatch(
|
||||
) {
|
||||
Object.assign(patch, buildTerminalSessionData(snapshot))
|
||||
}
|
||||
if (
|
||||
hasAnyChangedField(changed, [
|
||||
'deadPtyIds',
|
||||
'tabsByWorktree',
|
||||
'ptyIdsByTabId',
|
||||
'terminalLayoutsByTabId'
|
||||
] as const)
|
||||
) {
|
||||
// Why: [] rather than undefined — JSON IPC drops undefined keys, and a
|
||||
// fully-cleared record must overwrite a previously persisted list.
|
||||
patch.deadPtyIds = collectPersistableDeadPtyIds(snapshot.deadPtyIds, snapshot) ?? []
|
||||
}
|
||||
if (changed.has('sshConnectionStates')) {
|
||||
patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown(snapshot)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
|
||||
tabsByWorktree: true,
|
||||
ptyIdsByTabId: true,
|
||||
terminalLayoutsByTabId: true,
|
||||
deadPtyIds: true,
|
||||
activeTabIdByWorktree: true,
|
||||
openFiles: true,
|
||||
editorDrafts: true,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { WorkspaceSessionState } from '../../../shared/types'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
|
||||
export function buildSanitizedTabsByWorktree(
|
||||
tabsByWorktree: WorkspaceSessionSnapshot['tabsByWorktree']
|
||||
): WorkspaceSessionState['tabsByWorktree'] {
|
||||
// Why: pendingActivationSpawn is documented on TerminalTab as a transient
|
||||
// renderer-only handoff between setActiveWorktree and the next updateTabPtyId
|
||||
// — it must never be persisted. The main-process session:set handler writes
|
||||
// the payload to disk without re-parsing it against the Zod schema, so if
|
||||
// the flag were ever set and not consumed before a save (e.g. app quits
|
||||
// mid-handoff), it would round-trip to disk and the next session would
|
||||
// start with a stale suppression flag that drops the first legitimate PTY
|
||||
// spawn from the sidebar's recency sort. Strip it here to enforce the
|
||||
// type-level invariant at the persistence boundary.
|
||||
return Object.fromEntries(
|
||||
Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
tabs.map((tab) => {
|
||||
const { pendingActivationSpawn: _unused, ...rest } = tab
|
||||
void _unused
|
||||
return rest
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildTerminalSessionData(
|
||||
snapshot: WorkspaceSessionSnapshot
|
||||
): Pick<WorkspaceSessionState, 'activeWorktreeIdsOnShutdown' | 'remoteSessionIdsByTabId'> {
|
||||
const tabsByWorktree = snapshot.tabsByWorktree
|
||||
|
||||
// Why: ptyIdsByTabId is the live-PTY map. tab.ptyId is only a wake hint that
|
||||
// sleep intentionally preserves, so using it as liveness would revive slept
|
||||
// worktrees as active after restart.
|
||||
const ptyIdsByTabId = snapshot.ptyIdsByTabId
|
||||
const hasLivePty = (tabId: string): boolean => (ptyIdsByTabId[tabId]?.length ?? 0) > 0
|
||||
|
||||
// Why: lastKnownRelayPtyIdByTabId preserves remote session IDs across relay
|
||||
// disconnect/reconnect cycles, where clearTabPtyId(null) clears tab.ptyId
|
||||
// but keeps the relay PTY alive. Sleep is different: it preserves tab.ptyId
|
||||
// as a wake hint after clearing ptyIdsByTabId, so that shape must not count
|
||||
// as active on restart.
|
||||
const lastKnown = snapshot.lastKnownRelayPtyIdByTabId
|
||||
const hasReconnectableSession = (tab: { id: string; ptyId: string | null }): boolean =>
|
||||
hasLivePty(tab.id) || (!tab.ptyId && Boolean(lastKnown[tab.id]))
|
||||
|
||||
const activeWorktreeIdsOnShutdown = Object.entries(tabsByWorktree)
|
||||
.filter(([, tabs]) => tabs.some(hasReconnectableSession))
|
||||
.map(([worktreeId]) => worktreeId)
|
||||
|
||||
const worktreeById = new Map(
|
||||
Object.values(snapshot.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => [worktree.id, worktree])
|
||||
)
|
||||
const repoById = new Map(snapshot.repos.map((repo) => [repo.id, repo]))
|
||||
|
||||
// Why: the renderer already has tab.ptyId for every terminal tab and knows
|
||||
// which worktrees are SSH-backed via repo.connectionId. Deriving the map
|
||||
// here avoids a sync IPC round-trip during beforeunload, which is fragile
|
||||
// (can be dropped by Chromium under shutdown time pressure).
|
||||
// Why: this builder runs from the session-write debounce and beforeunload.
|
||||
// Pre-index repo/worktree identity once so large workspaces don't rescan all
|
||||
// repos/worktrees for every terminal tab while the renderer is trying to quit.
|
||||
const remoteSessionIdsByTabId: Record<string, string> = {}
|
||||
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
|
||||
const worktree = worktreeById.get(worktreeId)
|
||||
const repo = worktree ? repoById.get(worktree.repoId) : null
|
||||
if (!repo?.connectionId) {
|
||||
continue
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
if (!hasReconnectableSession(tab)) {
|
||||
continue
|
||||
}
|
||||
const sessionId = tab.ptyId || lastKnown[tab.id]
|
||||
if (sessionId) {
|
||||
remoteSessionIdsByTabId[tab.id] = sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorktreeIdsOnShutdown,
|
||||
remoteSessionIdsByTabId:
|
||||
Object.keys(remoteSessionIdsByTabId).length > 0 ? remoteSessionIdsByTabId : undefined
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function createSnapshot(overrides: Partial<AppState> = {}): AppState {
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-2': []
|
||||
},
|
||||
deadPtyIds: {},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
|
||||
@@ -10,6 +10,11 @@ import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-sessio
|
||||
import type { AppState } from '../store'
|
||||
import type { OpenFile } from '../store/slices/editor'
|
||||
import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs'
|
||||
import { collectPersistableDeadPtyIds } from '../store/slices/pty-session-liveness'
|
||||
import {
|
||||
buildSanitizedTabsByWorktree,
|
||||
buildTerminalSessionData
|
||||
} from './workspace-session-terminal-shutdown'
|
||||
import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency'
|
||||
import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents'
|
||||
|
||||
@@ -39,6 +44,7 @@ export type WorkspaceSessionSnapshot = Pick<
|
||||
| 'tabsByWorktree'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'deadPtyIds'
|
||||
| 'activeTabIdByWorktree'
|
||||
| 'openFiles'
|
||||
| 'editorDrafts'
|
||||
@@ -77,6 +83,7 @@ export const SESSION_RELEVANT_FIELDS = [
|
||||
'tabsByWorktree',
|
||||
'ptyIdsByTabId',
|
||||
'terminalLayoutsByTabId',
|
||||
'deadPtyIds',
|
||||
'activeTabIdByWorktree',
|
||||
'openFiles',
|
||||
'editorDrafts',
|
||||
@@ -244,93 +251,6 @@ export function buildPersistedBrowserPagesByWorkspace(
|
||||
)
|
||||
}
|
||||
|
||||
export function buildSanitizedTabsByWorktree(
|
||||
tabsByWorktree: WorkspaceSessionSnapshot['tabsByWorktree']
|
||||
): WorkspaceSessionState['tabsByWorktree'] {
|
||||
// Why: pendingActivationSpawn is documented on TerminalTab as a transient
|
||||
// renderer-only handoff between setActiveWorktree and the next updateTabPtyId
|
||||
// — it must never be persisted. The main-process session:set handler writes
|
||||
// the payload to disk without re-parsing it against the Zod schema, so if
|
||||
// the flag were ever set and not consumed before a save (e.g. app quits
|
||||
// mid-handoff), it would round-trip to disk and the next session would
|
||||
// start with a stale suppression flag that drops the first legitimate PTY
|
||||
// spawn from the sidebar's recency sort. Strip it here to enforce the
|
||||
// type-level invariant at the persistence boundary.
|
||||
return Object.fromEntries(
|
||||
Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
tabs.map((tab) => {
|
||||
const { pendingActivationSpawn: _unused, ...rest } = tab
|
||||
void _unused
|
||||
return rest
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildTerminalSessionData(
|
||||
snapshot: WorkspaceSessionSnapshot
|
||||
): Pick<WorkspaceSessionState, 'activeWorktreeIdsOnShutdown' | 'remoteSessionIdsByTabId'> {
|
||||
const tabsByWorktree = snapshot.tabsByWorktree
|
||||
|
||||
// Why: ptyIdsByTabId is the live-PTY map. tab.ptyId is only a wake hint that
|
||||
// sleep intentionally preserves, so using it as liveness would revive slept
|
||||
// worktrees as active after restart.
|
||||
const ptyIdsByTabId = snapshot.ptyIdsByTabId
|
||||
const hasLivePty = (tabId: string): boolean => (ptyIdsByTabId[tabId]?.length ?? 0) > 0
|
||||
|
||||
// Why: lastKnownRelayPtyIdByTabId preserves remote session IDs across relay
|
||||
// disconnect/reconnect cycles, where clearTabPtyId(null) clears tab.ptyId
|
||||
// but keeps the relay PTY alive. Sleep is different: it preserves tab.ptyId
|
||||
// as a wake hint after clearing ptyIdsByTabId, so that shape must not count
|
||||
// as active on restart.
|
||||
const lastKnown = snapshot.lastKnownRelayPtyIdByTabId
|
||||
const hasReconnectableSession = (tab: { id: string; ptyId: string | null }): boolean =>
|
||||
hasLivePty(tab.id) || (!tab.ptyId && Boolean(lastKnown[tab.id]))
|
||||
|
||||
const activeWorktreeIdsOnShutdown = Object.entries(tabsByWorktree)
|
||||
.filter(([, tabs]) => tabs.some(hasReconnectableSession))
|
||||
.map(([worktreeId]) => worktreeId)
|
||||
|
||||
const worktreeById = new Map(
|
||||
Object.values(snapshot.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => [worktree.id, worktree])
|
||||
)
|
||||
const repoById = new Map(snapshot.repos.map((repo) => [repo.id, repo]))
|
||||
|
||||
// Why: the renderer already has tab.ptyId for every terminal tab and knows
|
||||
// which worktrees are SSH-backed via repo.connectionId. Deriving the map
|
||||
// here avoids a sync IPC round-trip during beforeunload, which is fragile
|
||||
// (can be dropped by Chromium under shutdown time pressure).
|
||||
// Why: this builder runs from the session-write debounce and beforeunload.
|
||||
// Pre-index repo/worktree identity once so large workspaces don't rescan all
|
||||
// repos/worktrees for every terminal tab while the renderer is trying to quit.
|
||||
const remoteSessionIdsByTabId: Record<string, string> = {}
|
||||
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
|
||||
const worktree = worktreeById.get(worktreeId)
|
||||
const repo = worktree ? repoById.get(worktree.repoId) : null
|
||||
if (!repo?.connectionId) {
|
||||
continue
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
if (!hasReconnectableSession(tab)) {
|
||||
continue
|
||||
}
|
||||
const sessionId = tab.ptyId || lastKnown[tab.id]
|
||||
if (sessionId) {
|
||||
remoteSessionIdsByTabId[tab.id] = sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorktreeIdsOnShutdown,
|
||||
remoteSessionIdsByTabId:
|
||||
Object.keys(remoteSessionIdsByTabId).length > 0 ? remoteSessionIdsByTabId : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function buildActiveConnectionIdsAtShutdown(
|
||||
snapshot: WorkspaceSessionSnapshot
|
||||
): WorkspaceSessionState['activeConnectionIdsAtShutdown'] {
|
||||
@@ -359,6 +279,7 @@ export function buildWorkspaceSessionPayload(
|
||||
// must carry forward which worktrees still had live PTYs. Dropping this
|
||||
// field silently disables eager terminal reconnect on the next restart.
|
||||
activeWorktreeIdsOnShutdown: terminalSessionData.activeWorktreeIdsOnShutdown,
|
||||
deadPtyIds: collectPersistableDeadPtyIds(snapshot.deadPtyIds, snapshot),
|
||||
activeTabIdByWorktree: snapshot.activeTabIdByWorktree,
|
||||
...buildEditorSessionData(
|
||||
snapshot.openFiles,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
|
||||
import {
|
||||
collectClaimedPtyIds,
|
||||
deriveExitedPtyIdsFromListing,
|
||||
isLocalDaemonMintedPtyId,
|
||||
markPtySessionsExited,
|
||||
type PtySessionClaimMaps
|
||||
} from './pty-session-liveness'
|
||||
|
||||
const LOCAL_PTY_A = 'repo1::/path/wt1@@aaaa'
|
||||
const LOCAL_PTY_B = 'repo1::/path/wt1@@bbbb'
|
||||
const LOCAL_PTY_C = 'repo1::/path/wt2@@cccc'
|
||||
|
||||
function makeTab(id: string, ptyId: string | null = null): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId,
|
||||
worktreeId: `wt-${id}`,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeLayout(ptyIdsByLeafId: Record<string, string>): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId
|
||||
}
|
||||
}
|
||||
|
||||
function makeClaims(overrides: Partial<PtySessionClaimMaps> = {}): PtySessionClaimMaps {
|
||||
return {
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('collectClaimedPtyIds', () => {
|
||||
it('gathers ids from live attachments, tab wake hints, and layout leaf hints', () => {
|
||||
const claimed = collectClaimedPtyIds(
|
||||
makeClaims({
|
||||
tabsByWorktree: { 'wt-1': [makeTab('tab-1', LOCAL_PTY_A)] },
|
||||
ptyIdsByTabId: { 'tab-1': [LOCAL_PTY_B] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeLayout({ 'leaf-1': LOCAL_PTY_C }) }
|
||||
})
|
||||
)
|
||||
expect(claimed).toEqual(new Set([LOCAL_PTY_A, LOCAL_PTY_B, LOCAL_PTY_C]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLocalDaemonMintedPtyId', () => {
|
||||
it('accepts minted local session ids only', () => {
|
||||
expect(isLocalDaemonMintedPtyId(LOCAL_PTY_A)).toBe(true)
|
||||
expect(isLocalDaemonMintedPtyId('ssh:conn-1@@pty-3')).toBe(false)
|
||||
expect(isLocalDaemonMintedPtyId('remote:handle-7')).toBe(false)
|
||||
// Bare relay/uuid ids do not match the minted `${repoId}::${path}@@` shape.
|
||||
expect(isLocalDaemonMintedPtyId('pty-3')).toBe(false)
|
||||
expect(isLocalDaemonMintedPtyId('0b7c9a1e-4f3d-4c2a-9d7e-1234567890ab')).toBe(false)
|
||||
// SSH connection ids can themselves contain `::` (IPv6 targets); the
|
||||
// `ssh:` namespace must win over the minted-shape heuristic.
|
||||
expect(isLocalDaemonMintedPtyId('ssh:user@[::1]@@pty-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveExitedPtyIdsFromListing', () => {
|
||||
it('reports minted claims the listing omits and spares live, foreign, and mid-flight ids', () => {
|
||||
const exited = deriveExitedPtyIdsFromListing({
|
||||
claimedBeforeRequest: new Set([
|
||||
LOCAL_PTY_A, // dead: minted, absent from listing
|
||||
LOCAL_PTY_B, // alive: listed
|
||||
'ssh:conn-1@@pty-3', // ssh: absence is not authoritative
|
||||
'remote:handle-7', // runtime: never listed
|
||||
LOCAL_PTY_C // woken mid-flight: attached now but not before
|
||||
]),
|
||||
attachedBeforeRequest: new Set([LOCAL_PTY_A]),
|
||||
attachedNow: new Set([LOCAL_PTY_A, LOCAL_PTY_C]),
|
||||
listedSessionIds: new Set([LOCAL_PTY_B])
|
||||
})
|
||||
expect(exited).toEqual([LOCAL_PTY_A])
|
||||
})
|
||||
})
|
||||
|
||||
describe('markPtySessionsExited', () => {
|
||||
const claims = makeClaims({
|
||||
tabsByWorktree: { 'wt-1': [makeTab('tab-1', LOCAL_PTY_A)] },
|
||||
ptyIdsByTabId: { 'tab-1': [LOCAL_PTY_B] }
|
||||
})
|
||||
|
||||
it('records claimed ids and returns null when nothing changes', () => {
|
||||
const next = markPtySessionsExited({}, [LOCAL_PTY_A], claims)
|
||||
expect(next).toEqual({ [LOCAL_PTY_A]: true })
|
||||
expect(markPtySessionsExited(next!, [LOCAL_PTY_A], claims)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores exits for ids no longer claimed anywhere', () => {
|
||||
expect(markPtySessionsExited({}, [LOCAL_PTY_C], claims)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops recorded ids once their claims disappear', () => {
|
||||
const current = { [LOCAL_PTY_A]: true, [LOCAL_PTY_C]: true } as Record<string, true>
|
||||
expect(markPtySessionsExited(current, [], claims)).toEqual({ [LOCAL_PTY_A]: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
|
||||
import { parsePtySessionId } from '../../../../shared/pty-session-id-format'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
// Why: not runtime-terminal-inspection — that module imports the store, and a
|
||||
// store slice importing it back would make store creation order-dependent.
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
|
||||
/**
|
||||
* Liveness evidence for PTY session ids the workspace still claims.
|
||||
*
|
||||
* The claim maps (tab.ptyId wake hints, ptyIdsByTabId, layout ptyIdsByLeafId)
|
||||
* deliberately outlive the daemon session: a dead id is still the key to
|
||||
* daemon-side cold-restore history and sleep/hibernation wake. They must never
|
||||
* be pruned for accounting reasons. `deadPtyIds` records "this session is not
|
||||
* running" evidence separately so surfaces that count RUNNING sessions (the
|
||||
* closed Resource Manager badge) can subtract it while every reattach/restore
|
||||
* path keeps its hints (#8372). An id leaves the record only when a live pane
|
||||
* registers it again (updateTabPtyId) or its claims disappear.
|
||||
*/
|
||||
export type PtySessionClaimMaps = {
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
}
|
||||
|
||||
/** Every PTY session id the claim maps currently reference. */
|
||||
export function collectClaimedPtyIds(maps: PtySessionClaimMaps): Set<string> {
|
||||
const claimed = new Set<string>()
|
||||
for (const ptyIds of Object.values(maps.ptyIdsByTabId)) {
|
||||
for (const ptyId of ptyIds) {
|
||||
claimed.add(ptyId)
|
||||
}
|
||||
}
|
||||
for (const tabs of Object.values(maps.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.ptyId) {
|
||||
claimed.add(tab.ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const layout of Object.values(maps.terminalLayoutsByTabId)) {
|
||||
for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) {
|
||||
if (ptyId) {
|
||||
claimed.add(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a successful `pty:listSessions` result is authoritative about this
|
||||
* id. The local daemon listing fails closed (the router rejects on any adapter
|
||||
* error), so absence there proves death — but SSH providers degrade to `[]`
|
||||
* on transport errors and runtime (`remote:`) handles never appear at all, so
|
||||
* absence proves nothing for those namespaces.
|
||||
*/
|
||||
export function isLocalDaemonMintedPtyId(ptyId: string): boolean {
|
||||
return (
|
||||
parseRemoteRuntimePtyId(ptyId) === null &&
|
||||
parseAppSshPtyId(ptyId) === null &&
|
||||
parsePtySessionId(ptyId).worktreeId !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims an authoritative session listing proves dead.
|
||||
*
|
||||
* `claimedBeforeRequest` / `attachedBeforeRequest` must be snapshotted before
|
||||
* the listSessions RPC is issued: a session spawned or woken mid-flight is
|
||||
* missing from the (older) listing, and only the pre-request snapshot keeps it
|
||||
* from being declared dead.
|
||||
*/
|
||||
export function deriveExitedPtyIdsFromListing(args: {
|
||||
claimedBeforeRequest: ReadonlySet<string>
|
||||
attachedBeforeRequest: ReadonlySet<string>
|
||||
attachedNow: ReadonlySet<string>
|
||||
listedSessionIds: ReadonlySet<string>
|
||||
}): string[] {
|
||||
const exitedPtyIds: string[] = []
|
||||
for (const ptyId of args.claimedBeforeRequest) {
|
||||
if (args.listedSessionIds.has(ptyId) || !isLocalDaemonMintedPtyId(ptyId)) {
|
||||
continue
|
||||
}
|
||||
if (args.attachedNow.has(ptyId) && !args.attachedBeforeRequest.has(ptyId)) {
|
||||
continue
|
||||
}
|
||||
exitedPtyIds.push(ptyId)
|
||||
}
|
||||
return exitedPtyIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Dead marks worth persisting: only ids still claimed as wake hints — marks
|
||||
* for released claims are noise that would grow the session file without bound.
|
||||
*/
|
||||
export function collectPersistableDeadPtyIds(
|
||||
deadPtyIds: Record<string, true>,
|
||||
claims: PtySessionClaimMaps
|
||||
): string[] | undefined {
|
||||
const dead = Object.keys(deadPtyIds)
|
||||
if (dead.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const claimed = collectClaimedPtyIds(claims)
|
||||
const persisted = dead.filter((ptyId) => claimed.has(ptyId)).slice(0, 4096)
|
||||
return persisted.length > 0 ? persisted : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Record exited ids in the dead-id record. Returns the next record, or null
|
||||
* when nothing changed so callers can skip the store write. Entries no longer
|
||||
* claimed anywhere are dropped to keep the record claim-bounded.
|
||||
*/
|
||||
export function markPtySessionsExited(
|
||||
current: Record<string, true>,
|
||||
exitedPtyIds: readonly string[],
|
||||
claims: PtySessionClaimMaps
|
||||
): Record<string, true> | null {
|
||||
const claimed = collectClaimedPtyIds(claims)
|
||||
const next: Record<string, true> = {}
|
||||
let changed = false
|
||||
for (const ptyId of Object.keys(current)) {
|
||||
if (claimed.has(ptyId)) {
|
||||
next[ptyId] = true
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const ptyId of exitedPtyIds) {
|
||||
if (claimed.has(ptyId) && !next[ptyId]) {
|
||||
next[ptyId] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : null
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
|
||||
// Mock sonner (imported by repos.ts)
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
// Mock agent-status (imported by terminal-helpers)
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual }
|
||||
})
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
updateMeta: vi.fn().mockResolvedValue({})
|
||||
},
|
||||
repos: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
add: vi.fn().mockResolvedValue({}),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
pickFolder: vi.fn().mockResolvedValue(null)
|
||||
},
|
||||
pty: {
|
||||
kill: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
set: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error -- mock
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test-helpers'
|
||||
import { createClosedResourceSessionCountSelector } from '../../components/status-bar/resource-session-count-selector'
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
const DEAD_PTY = `${WT}@@dead`
|
||||
const LIVE_PTY = `${WT}@@live`
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('closed badge liveness across exits and restarts (#8372)', () => {
|
||||
it('stops counting a claimed session once its exit is recorded, and counts it again on wake', () => {
|
||||
const store = createTestStore()
|
||||
const selectCount = createClosedResourceSessionCountSelector()
|
||||
store.setState({
|
||||
workspaceSessionReady: true,
|
||||
tabsByWorktree: {
|
||||
[WT]: [
|
||||
makeTab({ id: 'tab1', worktreeId: WT, ptyId: DEAD_PTY }),
|
||||
makeTab({ id: 'tab2', worktreeId: WT, ptyId: LIVE_PTY })
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { tab1: [DEAD_PTY], tab2: [LIVE_PTY] }
|
||||
})
|
||||
|
||||
expect(selectCount(store.getState())).toBe(2)
|
||||
|
||||
// The daemon session behind tab1 exits while the tab is not mounted:
|
||||
// claims survive (they key cold-restore), the badge stops counting.
|
||||
store.getState().markPtySessionsExited([DEAD_PTY])
|
||||
expect(store.getState().tabsByWorktree[WT][0].ptyId).toBe(DEAD_PTY)
|
||||
expect(store.getState().ptyIdsByTabId.tab1).toEqual([DEAD_PTY])
|
||||
expect(selectCount(store.getState())).toBe(1)
|
||||
|
||||
// Wake: a mounted pane re-registers the same session id — alive again.
|
||||
store.getState().updateTabPtyId('tab1', DEAD_PTY)
|
||||
expect(store.getState().deadPtyIds).toEqual({})
|
||||
expect(selectCount(store.getState())).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores exits for unclaimed sessions and reports no store write', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ workspaceSessionReady: true })
|
||||
const before = store.getState().deadPtyIds
|
||||
store.getState().markPtySessionsExited(['repo1::/elsewhere@@gone'])
|
||||
expect(store.getState().deadPtyIds).toBe(before)
|
||||
})
|
||||
|
||||
it('keeps the badge honest across a restart: persisted dead ids survive hydration and reconnect', async () => {
|
||||
const store = createTestStore()
|
||||
const selectCount = createClosedResourceSessionCountSelector()
|
||||
store.setState({
|
||||
repos: [
|
||||
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })]
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().hydrateWorkspaceSession({
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: WT,
|
||||
activeTabId: 'tab1',
|
||||
tabsByWorktree: {
|
||||
[WT]: [
|
||||
makeTab({ id: 'tab1', worktreeId: WT, ptyId: DEAD_PTY }),
|
||||
makeTab({ id: 'tab2', worktreeId: WT, ptyId: LIVE_PTY })
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: { tab1: makeLayout(), tab2: makeLayout() },
|
||||
activeWorktreeIdsOnShutdown: [WT],
|
||||
deadPtyIds: [DEAD_PTY]
|
||||
})
|
||||
await store.getState().reconnectPersistedTerminals()
|
||||
|
||||
const state = store.getState()
|
||||
// Reconnect restored both wake hints — the dead one included, because it
|
||||
// still keys daemon-side cold restore…
|
||||
expect(state.tabsByWorktree[WT].map((tab) => tab.ptyId)).toEqual([DEAD_PTY, LIVE_PTY])
|
||||
// …but the badge only counts the session that was alive at last save.
|
||||
expect(selectCount(state)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-
|
||||
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
|
||||
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
import { markPtySessionsExited as recordExitedPtySessions } from './pty-session-liveness'
|
||||
import { isClaudeAgent } from '@/lib/agent-status'
|
||||
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
|
||||
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
|
||||
@@ -476,6 +477,10 @@ export type TerminalSlice = {
|
||||
// Remote guard keys must use renderer-visible, environment-scoped PTY ids;
|
||||
// raw runtime handles are only valid at the RPC boundary.
|
||||
suppressedPtyExitIds: Record<string, true>
|
||||
/** Claimed session ids proven not-running (exit events, authoritative
|
||||
* listings). Claims stay for wake/cold-restore; running-count surfaces
|
||||
* subtract this instead (#8372). Persisted so restarts keep the badge honest. */
|
||||
deadPtyIds: Record<string, true>
|
||||
pendingCodexPaneRestartIds: Record<string, true>
|
||||
codexRestartNoticeByPtyId: Record<
|
||||
string,
|
||||
@@ -648,6 +653,7 @@ export type TerminalSlice = {
|
||||
setTabColor: (tabId: string, color: string | null) => void
|
||||
updateTabPtyId: (tabId: string, ptyId: string) => void
|
||||
clearTabPtyId: (tabId: string, ptyId?: string) => void
|
||||
markPtySessionsExited: (ptyIds: readonly string[]) => void
|
||||
shutdownWorktreeTerminals: (
|
||||
worktreeId: string,
|
||||
opts?: {
|
||||
@@ -776,6 +782,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
unreadTerminalPanes: {},
|
||||
unreadAgentCompletionPanes: {},
|
||||
suppressedPtyExitIds: {},
|
||||
deadPtyIds: {},
|
||||
pendingCodexPaneRestartIds: {},
|
||||
codexRestartNoticeByPtyId: {},
|
||||
expandedPaneByTabId: {},
|
||||
@@ -2033,6 +2040,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
if (legacyRemotePtyId) {
|
||||
delete nextSuppressedPtyExitIds[legacyRemotePtyId]
|
||||
}
|
||||
// Why: a live pane registering this id is the only resurrection path a
|
||||
// dead-marked session has (wake respawns reuse the old session id).
|
||||
let nextDeadPtyIds = s.deadPtyIds
|
||||
if (nextDeadPtyIds[ptyId] || (legacyRemotePtyId && nextDeadPtyIds[legacyRemotePtyId])) {
|
||||
nextDeadPtyIds = { ...nextDeadPtyIds }
|
||||
delete nextDeadPtyIds[ptyId]
|
||||
if (legacyRemotePtyId) {
|
||||
delete nextDeadPtyIds[legacyRemotePtyId]
|
||||
}
|
||||
}
|
||||
const hasLegacyPendingRestart = legacyRemotePtyId
|
||||
? legacyRemotePtyId in s.pendingCodexPaneRestartIds
|
||||
: false
|
||||
@@ -2081,6 +2098,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
[tabId]: ptyId
|
||||
},
|
||||
suppressedPtyExitIds: nextSuppressedPtyExitIds,
|
||||
...(nextDeadPtyIds !== s.deadPtyIds ? { deadPtyIds: nextDeadPtyIds } : {}),
|
||||
pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds,
|
||||
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId,
|
||||
migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId,
|
||||
@@ -2098,6 +2116,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
}
|
||||
},
|
||||
|
||||
markPtySessionsExited: (ptyIds) => {
|
||||
if (ptyIds.length === 0) {
|
||||
return
|
||||
}
|
||||
set((s) => {
|
||||
const next = recordExitedPtySessions(s.deadPtyIds, ptyIds, s)
|
||||
return next ? { deadPtyIds: next } : {}
|
||||
})
|
||||
},
|
||||
|
||||
clearTabPtyId: (tabId, ptyId) => {
|
||||
let worktreeId: string | null = null
|
||||
let wasActivationSpawn = false
|
||||
@@ -3402,6 +3430,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
.flat()
|
||||
.map((tab) => [tab.id, []] as const)
|
||||
),
|
||||
// Why: dead-session evidence must survive restart or the closed badge
|
||||
// re-counts reconnect-restored wake hints for sessions that died in a
|
||||
// previous run (#8372). Save-time already intersected with claims.
|
||||
deadPtyIds: Object.fromEntries(
|
||||
(session.deadPtyIds ?? []).slice(0, 4096).map((id) => [id, true] as const)
|
||||
),
|
||||
// Why: with the daemon backend, ptyIds are daemon session IDs that
|
||||
// survive app restart. Preserve ptyIdsByLeafId so that
|
||||
// reconnectPersistedTerminals can reattach each split-pane leaf
|
||||
|
||||
@@ -1057,6 +1057,11 @@ export type WorkspaceSessionState = {
|
||||
* Used on startup to eagerly re-spawn PTY processes so the Active filter
|
||||
* works immediately after restart. */
|
||||
activeWorktreeIdsOnShutdown?: string[]
|
||||
/** Session ids still claimed by tab/layout wake hints but proven not
|
||||
* running (exit events, authoritative daemon listings). Restored so the
|
||||
* closed Resource Manager badge stays honest across restarts; the hints
|
||||
* themselves are kept for wake/cold-restore. */
|
||||
deadPtyIds?: string[]
|
||||
/** Editor files that were open at shutdown, keyed by worktree ID.
|
||||
* Only edit-mode files are persisted — diffs and conflict views are
|
||||
* transient and not restored. */
|
||||
|
||||
@@ -254,6 +254,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
|
||||
tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)),
|
||||
terminalLayoutsByTabId: z.record(terminalTabIdSchema, terminalLayoutSnapshotSchema),
|
||||
activeWorktreeIdsOnShutdown: z.array(z.string()).optional(),
|
||||
deadPtyIds: z.array(z.string()).optional(),
|
||||
openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(),
|
||||
activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
|
||||
markdownFrontmatterVisible: z.record(z.string(), z.boolean()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user