fix(runtime): a slept remote workspace stays hidden after host updates and restarts (#29437)

* fix(runtime): keep pending wake bindings out of live terminal activity

* test(runtime): cover slept snapshot handle wait before resume

* test(runtime): follow main's web-session-tabs-sync module split

---------

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-15 23:30:15 -07:00
committed by GitHub
co-authored by mmarabel
parent 67a9c6e4cc
commit 89361ecdee
5 changed files with 249 additions and 2 deletions
@@ -1,4 +1,9 @@
import path from 'node:path'
import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api'
import {
makeSnapshot,
resetWebSessionTabsSyncTestState
} from '@/runtime/web-session-tabs-sync-test-harness'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
@@ -274,6 +279,47 @@ describe('resume across the mirror handle gap', () => {
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
it('holds a slept binding retained through a pending snapshot without replacing it', () => {
resetWebSessionTabsSyncTestState()
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
const snapshot = makeSnapshot(
[
{
type: 'terminal',
id: `host-tab-1::${LEAF_ID}`,
title: 'Claude',
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
isActive: true,
status: 'pending-handle',
terminal: null
}
],
{ worktree: worktree.id }
)
useAppStore.setState(
applyWebSessionTabsSnapshot(useAppStore.getState(), snapshot, RUNTIME_ENV_ID, Date.now())
)
expect(
useAppStore.getState().terminalLayoutsByTabId[WEB_TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID]
).toBe('remote:env-handle-gap@@term_1')
expect(useAppStore.getState().ptyIdsByTabId[WEB_TAB_ID] ?? []).toEqual([])
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0)
vi.advanceTimersByTime(1)
// The deadline ends the wait, but an unverifiable binding never authorizes a replacement.
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0)
resetWebSessionTabsSyncTestState()
})
it('does not resume a published host row without a PTY binding', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { isInactiveWorkspace } from '../lib/worktree-activity-state'
import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '../lib/worktree-sleep-intent'
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
import {
ENV,
HOST_SURFACE_ID,
LEAF_ID,
NOW,
SECOND_LEAF_ID,
WT,
makeSnapshot,
makeState,
resetWebSessionTabsSyncTestState
} from './web-session-tabs-sync-test-harness'
vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } }))
const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1')
const PTY_ID = `remote:${ENV}@@terminal-1`
const FOLDER = 'folder:folder-1'
function sleptState(worktreeId = WT, ptyId = PTY_ID) {
return makeState({
activeWorktreeId: 'another-workspace',
tabsByWorktree: {
[worktreeId]: [
{
id: TAB_ID,
ptyId,
worktreeId,
title: 'shell',
defaultTitle: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
]
},
ptyIdsByTabId: { [TAB_ID]: [] },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
}
}
})
}
function pendingSnapshot(worktreeId = WT) {
return makeSnapshot(
[
{
type: 'terminal',
id: HOST_SURFACE_ID,
title: 'shell',
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
isActive: true,
status: 'pending-handle',
terminal: null
}
],
{ worktree: worktreeId }
)
}
function inactive(state: ReturnType<typeof makeState>, worktreeId = WT) {
return isInactiveWorkspace(worktreeId, state.tabsByWorktree, state.ptyIdsByTabId, {}, new Set())
}
describe('sleep activity during host terminal mirroring', () => {
beforeEach(resetWebSessionTabsSyncTestState)
afterEach(() => {
clearWorktreeSleepIntent(WT)
clearWorktreeSleepIntent(FOLDER)
})
it.each([WT, FOLDER])('keeps %s asleep while retaining its wake binding', (worktreeId) => {
const state = sleptState(worktreeId)
markWorktreeSleepIntent(worktreeId)
expect(inactive(state, worktreeId)).toBe(true)
const next = {
...state,
...applyWebSessionTabsSnapshot(state, pendingSnapshot(worktreeId), ENV, NOW + 1)
}
expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: PTY_ID })
expect(next.tabsByWorktree[worktreeId]?.[0]?.ptyId).toBe(PTY_ID)
expect(next.ptyIdsByTabId[TAB_ID] ?? []).toEqual([])
expect(inactive(next, worktreeId)).toBe(true)
expect(next.activeWorktreeId).toBe('another-workspace')
})
it('does not turn a restored wake binding into live activity without a sleep marker', () => {
const state = sleptState()
const next = {
...state,
...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1)
}
expect(inactive(next)).toBe(true)
})
it('keeps a previously live binding during an ordinary reconnect', () => {
const state = sleptState()
state.ptyIdsByTabId[TAB_ID] = [PTY_ID]
const next = {
...state,
...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1)
}
expect(next.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID])
expect(inactive(next)).toBe(false)
})
it('publishes a ready replacement after explicit wake', () => {
const state = sleptState()
markWorktreeSleepIntent(WT)
clearWorktreeSleepIntent(WT)
const snapshot = makeSnapshot([
{
type: 'terminal',
id: HOST_SURFACE_ID,
title: 'shell',
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-2'
}
])
const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) }
expect(next.ptyIdsByTabId[TAB_ID]).toEqual([`remote:${ENV}@@terminal-2`])
expect(inactive(next)).toBe(false)
})
it('counts only ready panes when a slept split sibling is still pending', () => {
const state = sleptState()
const snapshot = pendingSnapshot()
snapshot.tabs.push({
type: 'terminal',
id: `host-tab-1::${SECOND_LEAF_ID}`,
title: 'new shell',
parentTabId: 'host-tab-1',
leafId: SECOND_LEAF_ID,
isActive: false,
status: 'ready',
terminal: 'terminal-2'
})
const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) }
expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID]).toBe(PTY_ID)
expect(next.ptyIdsByTabId[TAB_ID]).toEqual([`remote:${ENV}@@terminal-2`])
expect(inactive(next)).toBe(false)
})
it('keeps the wake binding inactive across repeated host updates', () => {
const state = sleptState()
const snapshot = pendingSnapshot()
const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) }
const updatedSnapshot = { ...snapshot, snapshotVersion: 2 }
const updated = {
...next,
...applyWebSessionTabsSnapshot(next, updatedSnapshot, ENV, NOW + 2)
}
expect(inactive(updated)).toBe(true)
expect(updated.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: PTY_ID })
})
it('keeps local-mode pending wake bindings out of live activity', () => {
const state = sleptState(WT, 'local-pty')
const next = {
...state,
...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1, {
terminalPtyMode: 'local'
})
}
expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'local-pty' })
expect(inactive(next)).toBe(true)
})
})
@@ -155,6 +155,7 @@ export function prepareWebSessionTabsSnapshotBase(
environmentId,
existingTerminalById,
state.terminalLayoutsByTabId,
state.ptyIdsByTabId,
retainedTerminalTabs.length,
now,
callerFocusIntentTab?.type === 'terminal'
@@ -55,7 +55,15 @@ function rebuild(existing?: Partial<TerminalTab>): TerminalTab {
const existingById = new Map<string, TerminalTab>(
existing ? [[localTabId, { id: localTabId, ...existing } as TerminalTab]] : []
)
const [mirrored] = buildMirroredTerminalTabs(snapshot(), ENVIRONMENT, existingById, {}, 0, 1_000)
const [mirrored] = buildMirroredTerminalTabs(
snapshot(),
ENVIRONMENT,
existingById,
{},
{},
0,
1_000
)
return mirrored!.tab
}
@@ -57,6 +57,7 @@ export function buildMirroredTerminalTabs(
environmentId: string,
existingById: ReadonlyMap<string, TerminalTab>,
existingLayoutsByTabId: Readonly<Record<string, TerminalLayoutSnapshot>>,
existingLivePtyIdsByTabId: Readonly<Record<string, readonly string[]>>,
sortOffset: number,
now: number,
focusTarget?: { parentTabId: string; leafId: string },
@@ -101,7 +102,14 @@ export function buildMirroredTerminalTabs(
chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId)
).snapshot
const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {})
const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId)
// Wake bindings survive Sleep; only ready surfaces or already-live bindings count as activity.
const livePtyIds = new Set([
...Object.values(freshPtyIdsByLeafId),
...(existingLivePtyIdsByTabId[localTabId] ?? [])
])
const ptyIds = layoutPtyEntries
.map(([, ptyId]) => ptyId)
.filter((ptyId) => livePtyIds.has(ptyId))
let retainedSurfaceByPrunedLeafId: Map<string, TerminalSurface> | undefined
if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) {
const retainedLeafIdByPtyId = new Map(