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:
Brennan Benson
2026-07-15 14:47:37 -07:00
parent 8a7cd84bda
commit b4ae485f5d
20 changed files with 668 additions and 91 deletions
@@ -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
}
+10
View File
@@ -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 }
},
+8 -87
View File
@@ -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
+5
View File
@@ -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. */
+1
View File
@@ -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(),