diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 5c9e580b5fa..e60fed81afe 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -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) diff --git a/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts b/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts index 7d416ff856b..aba6c8b5d48 100644 --- a/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts +++ b/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/components/status-bar/resource-session-count-selector.ts b/src/renderer/src/components/status-bar/resource-session-count-selector.ts index 4fb924f4529..477c9d639a5 100644 --- a/src/renderer/src/components/status-bar/resource-session-count-selector.ts +++ b/src/renderer/src/components/status-bar/resource-session-count-selector.ts @@ -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, + 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 = 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 } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..1abfdf6673f 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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 } diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index 505110ac427..6cdd02133b7 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -12,6 +12,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + deadPtyIds: {}, activeTabIdByWorktree: {}, openFiles: [], editorDrafts: {}, diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index ad12ccec39c..8ad58d8f028 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -13,6 +13,7 @@ function createSnapshot( tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + deadPtyIds: {}, activeTabIdByWorktree: {}, openFiles: [], editorDrafts: {}, diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 2be516f48cc..de3e1d7910d 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -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', diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index db06aaf0e6c..8fba3700fad 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index ec4ea11e643..91639e3742c 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -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' diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index eff46edfa23..7caea2e9134 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -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) } diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 84b874a096d..2ceef162a51 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -12,6 +12,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { tabsByWorktree: true, ptyIdsByTabId: true, terminalLayoutsByTabId: true, + deadPtyIds: true, activeTabIdByWorktree: true, openFiles: true, editorDrafts: true, diff --git a/src/renderer/src/lib/workspace-session-terminal-shutdown.ts b/src/renderer/src/lib/workspace-session-terminal-shutdown.ts new file mode 100644 index 00000000000..0bcc01bfcf2 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-terminal-shutdown.ts @@ -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 { + 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 = {} + 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 + } +} diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index ee2884ac400..66ff580cf9c 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -16,6 +16,7 @@ function createSnapshot(overrides: Partial = {}): AppState { 'tab-1': ['pty-1'], 'tab-2': [] }, + deadPtyIds: {}, terminalLayoutsByTabId: { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } }, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index f0acc22e620..b0cb35ca1d7 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -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 { - 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 = {} - 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, diff --git a/src/renderer/src/store/slices/pty-session-liveness.test.ts b/src/renderer/src/store/slices/pty-session-liveness.test.ts new file mode 100644 index 00000000000..5f83838768e --- /dev/null +++ b/src/renderer/src/store/slices/pty-session-liveness.test.ts @@ -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): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +function makeClaims(overrides: Partial = {}): 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 + expect(markPtySessionsExited(current, [], claims)).toEqual({ [LOCAL_PTY_A]: true }) + }) +}) diff --git a/src/renderer/src/store/slices/pty-session-liveness.ts b/src/renderer/src/store/slices/pty-session-liveness.ts new file mode 100644 index 00000000000..9f7ed5fd5ce --- /dev/null +++ b/src/renderer/src/store/slices/pty-session-liveness.ts @@ -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 + ptyIdsByTabId: Record + terminalLayoutsByTabId: Record +} + +/** Every PTY session id the claim maps currently reference. */ +export function collectClaimedPtyIds(maps: PtySessionClaimMaps): Set { + const claimed = new Set() + 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 + attachedBeforeRequest: ReadonlySet + attachedNow: ReadonlySet + listedSessionIds: ReadonlySet +}): 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, + 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, + exitedPtyIds: readonly string[], + claims: PtySessionClaimMaps +): Record | null { + const claimed = collectClaimedPtyIds(claims) + const next: Record = {} + 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 +} diff --git a/src/renderer/src/store/slices/terminals-pty-liveness.test.ts b/src/renderer/src/store/slices/terminals-pty-liveness.test.ts new file mode 100644 index 00000000000..52a8eb7f45c --- /dev/null +++ b/src/renderer/src/store/slices/terminals-pty-liveness.test.ts @@ -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() + 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) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e127c1973a8..793bfddb314 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 + /** 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 pendingCodexPaneRestartIds: Record 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 unreadTerminalPanes: {}, unreadAgentCompletionPanes: {}, suppressedPtyExitIds: {}, + deadPtyIds: {}, pendingCodexPaneRestartIds: {}, codexRestartNoticeByPtyId: {}, expandedPaneByTabId: {}, @@ -2033,6 +2040,16 @@ export const createTerminalSlice: StateCreator 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 [tabId]: ptyId }, suppressedPtyExitIds: nextSuppressedPtyExitIds, + ...(nextDeadPtyIds !== s.deadPtyIds ? { deadPtyIds: nextDeadPtyIds } : {}), pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, @@ -2098,6 +2116,16 @@ export const createTerminalSlice: StateCreator } }, + 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 .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 diff --git a/src/shared/types.ts b/src/shared/types.ts index d08684fd4af..1c693d7e99c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. */ diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index ab5d354e017..0927b491070 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -254,6 +254,7 @@ export const workspaceSessionStateSchema: z.ZodType = 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(),