merge PR 20059 handle-gap-outage-not-a-verdict (incl 19882)

This commit is contained in:
Neil
2026-09-11 22:14:18 -07:00
21 changed files with 2860 additions and 35 deletions
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// What this file pins, and why it is separate from host-mirror-handle-gap-resume.test.ts: that file
// drives the waiter through the real resume sweep, so it cannot choose what a replay DOES. These
// tests park with a `run` of their own to exercise the drain itself — the loop that releases every
// due pane from one store write, running synchronously inside a zustand subscriber. The panes in
// that loop are strangers to each other and the store write that triggered it is a stranger to all
// of them, so one pane's replay must not be able to reach either.
const ENVIRONMENT_ID = 'env-handle-gap-drain'
const WORKTREE_ID = 'repo-1::/workspace/repo'
const FIRST_TAB_ID = 'web-terminal-host-tab-1'
const SECOND_TAB_ID = 'web-terminal-host-tab-2'
const initialAppStoreState = useAppStore.getState()
function seedRows(): void {
// Layout bindings are seeded because a verdict names the PANE by the environment-minted PTY it
// held at park time. A pane with no binding never reaches the park path in production, and its
// verdict deliberately refuses to answer, so a fixture without one models nothing real.
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: {
[WORKTREE_ID]: [
{ id: FIRST_TAB_ID, title: 'one' },
{ id: SECOND_TAB_ID, title: 'two' }
]
},
terminalLayoutsByTabId: {
[FIRST_TAB_ID]: {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': `remote:${ENVIRONMENT_ID}@@term_1` }
},
[SECOND_TAB_ID]: {
root: { type: 'leaf', leafId: 'leaf-2' },
activeLeafId: 'leaf-2',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-2': `remote:${ENVIRONMENT_ID}@@term_2` }
}
}
} as never)
}
/** The host publishes both panes' PTY handles on one frame: both waiters come due together. */
function publishBothHandles(): void {
useAppStore.setState({
ptyIdsByTabId: { [FIRST_TAB_ID]: ['pty-1'], [SECOND_TAB_ID]: ['pty-2'] }
} as never)
}
describe('host-mirror handle-gap drain', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
seedRows()
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
// The drain runs inside `useAppStore.subscribe`, so an unguarded throw from one pane's replay
// leaves the store write that published the handle throwing at its own call site — the mirror
// apply path, which has nothing to do with this pane. `resumeSleepingAgentSessionsForWorktree`
// reaches `state.createTab` with no guard of its own, so the throw is reachable.
it('does not let one pane’s replay throw out of the store write that released it', () => {
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, FIRST_TAB_ID, () => {
throw new Error('replay blew up')
})
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, SECOND_TAB_ID, () => {})
expect(() => publishBothHandles()).not.toThrow()
})
// Same write, the other victim: the panes in a drain are strangers. A replay that throws must not
// strand every pane queued behind it — a stranded pane holds its park until its own deadline and
// then decides on a connection whose evidence has long since landed.
it('releases every other due pane when one pane’s replay throws', () => {
const secondReplay = vi.fn()
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, FIRST_TAB_ID, () => {
throw new Error('replay blew up')
})
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, SECOND_TAB_ID, secondReplay)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(2)
try {
publishBothHandles()
} catch {
// The assertion is about the second pane, not about who swallowed the throw.
}
expect(secondReplay).toHaveBeenCalledTimes(1)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
// Both deadlines are cancelled, so neither pane can record an expiry it did not earn.
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS * 2)
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, FIRST_TAB_ID)).toBe(false)
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, SECOND_TAB_ID)).toBe(false)
})
// The deadline path fans out the same way: one expiring pane's replay must not keep another pane
// from recording its own verdict on the same connection.
it('records the expiry of a pane whose replay throws and still frees the pane', () => {
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, FIRST_TAB_ID, () => {
throw new Error('replay blew up')
})
expect(() => vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)).not.toThrow()
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, FIRST_TAB_ID)).toBe(true)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
/**
* The fourth eviction trigger: a PUBLISHED HANDLE ends the gap episode its verdict measured.
*
* Why none of the other three reach it. The generation rule cannot: the #19647 change in this same
* stack stops recording `status: null` for an unreachable host, so `connectionChanged` no longer
* fires across an outage on one runtime. The tab-death rule cannot: the row stays published the
* whole time — it is the HANDLE that comes and goes, which is the definition of the gap. Teardown
* cannot: the environment is still here. And the read-time pane-identity check cannot, because the
* pane that reattaches to the SAME PTY is deliberately the same pane
* (`host-mirror-handle-gap-verdict-union.test.ts`, "answers for a genuine reattach").
*
* So a verdict outlives the gap it was about, and the NEXT gap on that pane gets no wait at all —
* #19735 with the bounded wait removed rather than merely shortened.
*
* Why this does not reopen the park/expire/replay loop the verdict exists to break: that loop is
* a handle that NEVER lands. A landed handle between two gaps is positive host evidence, and each
* wait is still individually bounded by the deadline.
*/
const ENV_ID = 'env-landed-handle'
const WORKTREE = 'repo-1::wt-landed'
const TAB_ID = 'web-terminal-landed'
const PANE_PTY_ID = `remote:${encodeURIComponent(ENV_ID)}@@term_1`
const initialAppStoreState = useAppStore.getState()
/** Publishes the row AND the layout binding that makes the pane unverifiable rather than dead. */
function publishRow(options: { handleLanded: boolean }): void {
useAppStore.setState({
tabsByWorktree: { [WORKTREE]: [{ id: TAB_ID, title: 't', ptyId: null }] },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': PANE_PTY_ID }
}
},
ptyIdsByTabId: options.handleLanded ? { [TAB_ID]: [PANE_PTY_ID] } : {}
} as unknown as AppState)
}
describe('handle-gap verdict, landed-handle eviction', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
setRuntimeEnvironmentConnectionGenerationForTests(ENV_ID, 1)
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
it('retires the verdict when the pane it was about finally publishes its handle', () => {
publishRow({ handleLanded: false })
parkUntilHostMirrorHandleLands(ENV_ID, WORKTREE, TAB_ID, vi.fn())
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
expect(hasHostMirrorHandleWaitExpired(ENV_ID, TAB_ID)).toBe(true)
// Same connection, same pane, same layout binding — only the handle is new. The verdict's
// subject has answered, so the verdict is spent.
publishRow({ handleLanded: true })
expect(hasHostMirrorHandleWaitExpired(ENV_ID, TAB_ID)).toBe(false)
})
it('gives the next gap on that pane its own full wait', () => {
publishRow({ handleLanded: false })
parkUntilHostMirrorHandleLands(ENV_ID, WORKTREE, TAB_ID, vi.fn())
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
publishRow({ handleLanded: true })
// A later frame republishes the row ahead of its handle: a NEW gap on the same connection.
publishRow({ handleLanded: false })
expect(hasHostMirrorHandleWaitExpired(ENV_ID, TAB_ID)).toBe(false)
const replay = vi.fn()
parkUntilHostMirrorHandleLands(ENV_ID, WORKTREE, TAB_ID, replay)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
expect(replay).not.toHaveBeenCalled()
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
expect(replay).toHaveBeenCalledTimes(1)
})
it('a wait re-parked under a new worktree is released by that worktree, not the old one', () => {
// Adopting an orphaned terminal re-keys `tabsByWorktree` without re-keying the record, so the
// re-park hands the live wait a new worktree. Retraction evidence about the OLD one says
// nothing about the wait that is actually running.
useAppStore.setState({
tabsByWorktree: {
'wt-old': [{ id: TAB_ID, title: 't', ptyId: null }],
'wt-new': [{ id: TAB_ID, title: 't', ptyId: null }]
},
ptyIdsByTabId: {}
} as unknown as AppState)
parkUntilHostMirrorHandleLands(ENV_ID, 'wt-old', TAB_ID, vi.fn())
const replayAfterAdoption = vi.fn()
parkUntilHostMirrorHandleLands(ENV_ID, 'wt-new', TAB_ID, replayAfterAdoption)
useAppStore.setState({
tabsByWorktree: { 'wt-new': [{ id: TAB_ID, title: 't', ptyId: null }] }
} as unknown as AppState)
expect(replayAfterAdoption).not.toHaveBeenCalled()
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
useAppStore.setState({ tabsByWorktree: {} } as unknown as AppState)
expect(replayAfterAdoption).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// What this pins: the parked replay is `resumeSleepingAgentSessionsForWorktree`, a large
// synchronous sweep, and it is released from inside `useAppStore.subscribe`. Zustand notifies
// listeners in a plain loop, so a replay that throws escapes the `setState` that triggered it:
// the listeners registered after this module never see the write, and every sibling pane the
// same mirror frame made due is left parked. The waiter's own state is torn down before `run`,
// so containing the throw holds nothing back.
const initialAppStoreState = useAppStore.getState()
const ENV_ID = 'env-gap-containment'
function seedTwoMirroredPanes(): void {
useAppStore.setState({
tabsByWorktree: {
wt: [
{ id: 'tab-a', title: 'a', ptyId: null },
{ id: 'tab-b', title: 'b', ptyId: null }
] as never
},
ptyIdsByTabId: {}
})
}
describe('host-mirror handle-gap replay containment', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
vi.restoreAllMocks()
})
it('a replay that throws neither aborts the store write nor strands its sibling panes', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
seedTwoMirroredPanes()
const siblingReplay = vi.fn()
parkUntilHostMirrorHandleLands(ENV_ID, 'wt', 'tab-a', () => {
throw new Error('replay blew up')
})
parkUntilHostMirrorHandleLands(ENV_ID, 'wt', 'tab-b', siblingReplay)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(2)
// A store subscriber registered after this module's, so it is notified after the drain.
const laterSubscriber = vi.fn()
const unsubscribe = useAppStore.subscribe(laterSubscriber)
// One mirror frame lands both handles, making both waiters due on a single store write.
expect(() =>
useAppStore.setState({ ptyIdsByTabId: { 'tab-a': ['pty-a'], 'tab-b': ['pty-b'] } })
).not.toThrow()
unsubscribe()
expect(siblingReplay).toHaveBeenCalledTimes(1)
expect(laterSubscriber).toHaveBeenCalledTimes(1)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
})
it('a replay that throws on the deadline path does not escape the timer', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
seedTwoMirroredPanes()
parkUntilHostMirrorHandleLands(ENV_ID, 'wt', 'tab-a', () => {
throw new Error('replay blew up')
})
expect(() => vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)).not.toThrow()
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
})
})
@@ -0,0 +1,451 @@
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
markHostSessionMirrorHydrated,
resetHostSessionMirrorHydrationForTests
} from '@/runtime/host-session-mirror-hydration'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
countParkedHostMirrorHandleGapPanesForTests,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// The window this pins: a paired runtime publishes a workspace's tab rows and its PTY handles on
// separate frames, so there is a frame where the row exists and `ptyIdsByTabId` is still empty.
// An empty handle map for a row the host is still publishing is `unverifiable`, never `exited`
// (docs/reference/ssh-execution-boundary.md), so nothing may be resumed off it.
//
// HOW TO ASSERT ON THIS MODULE, because the obvious way cannot fail. "Did the waiter release" is
// NOT an observable here: a waiter released for the wrong reason is immediately re-parked by the
// replayed sweep, so the store, the record and the parked count all read identically one tick
// later. A mutation that released every waiter on any tab's handle survived twelve tests written
// that way. What a spurious release actually costs is the deadline — the re-park starts a fresh
// budget — so the assertion has to advance the clock: park, advance part of the budget, do the
// thing, then advance to the ORIGINAL deadline and require the pane to decide on schedule.
const initialAppStoreState = useAppStore.getState()
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
const WEB_TAB_ID = 'web-terminal-host-tab-1'
const SECOND_LEAF_ID = '33333333-3333-4333-8333-333333333333'
const SECOND_TAB_ID = 'web-terminal-host-tab-2'
const RUNTIME_ENV_ID = 'env-handle-gap'
function makeRuntimeOwnedWorktree(): ReturnType<typeof makeCreatedAgentWorktree> {
return {
...makeCreatedAgentWorktree(),
createdWithAgent: undefined,
hostId: `runtime:${encodeURIComponent(RUNTIME_ENV_ID)}`
}
}
/** A published mirrored row: tab, layout leaf, and the leaf's host PTY binding. */
function seedMirroredWorkspace(worktree: ReturnType<typeof makeCreatedAgentWorktree>): void {
const state: Partial<AppState> = {
repos: [
{
id: 'repo-1',
path: path.join(path.sep, 'workspace', 'repo'),
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
activeRepoId: 'repo-1',
activeWorktreeId: worktree.id,
activeView: 'terminal',
tabsByWorktree: {
[worktree.id]: [{ id: WEB_TAB_ID, title: 'Claude', ptyId: null } as never]
},
terminalLayoutsByTabId: {
[WEB_TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'remote:env-handle-gap@@term_1' }
} as never
},
// The gap itself: the row is published, its handle has not arrived.
ptyIdsByTabId: {},
sleepingAgentSessionsByPaneKey: {},
pendingStartupByTabId: {},
automaticAgentResumeClaimsByTabId: {},
agentStatusByPaneKey: {}
}
useAppStore.setState(state as AppState)
}
/** A second published mirrored row in the same environment, with its own leaf binding. */
function seedSecondMirroredPane(worktreeId: string): void {
const before = useAppStore.getState()
useAppStore.setState({
tabsByWorktree: {
[worktreeId]: [
...(before.tabsByWorktree[worktreeId] ?? []),
{ id: SECOND_TAB_ID, title: 'Claude 2', ptyId: null } as never
]
},
terminalLayoutsByTabId: {
...before.terminalLayoutsByTabId,
[SECOND_TAB_ID]: {
root: { type: 'leaf', leafId: SECOND_LEAF_ID },
activeLeafId: SECOND_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [SECOND_LEAF_ID]: 'remote:env-handle-gap@@term_2' }
} as never
}
} as never)
}
/** The capture the reported flow produces: recorded mid-turn, so it is active work, not history. */
function seedActiveSleepingRecordFor(
worktreeId: string,
tabId: string,
leafId: string,
sessionId: string
): string {
const paneKey = makePaneKey(tabId, leafId)
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
...useAppStore.getState().sleepingAgentSessionsByPaneKey,
[paneKey]: {
paneKey,
tabId,
worktreeId,
agent: 'claude',
providerSession: { key: 'session_id', id: sessionId },
connectionId: null,
prompt: '',
state: 'working',
capturedAt: 1000,
updatedAt: 1000,
terminalTitle: 'Claude',
origin: 'live'
}
}
} as never)
return paneKey
}
function seedActiveSleepingRecord(worktreeId: string): string {
return seedActiveSleepingRecordFor(worktreeId, WEB_TAB_ID, LEAF_ID, 'handle-gap-session')
}
/** A recorded status entry whose runtime answered nothing: the shape a dropped link leaves behind. */
function setRuntimeEnvironmentDisconnectedForTests(environmentId: string): void {
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map(useAppStore.getState().runtimeStatusByEnvironmentId).set(
environmentId,
{ status: null } as never
)
} as never)
}
function clearRuntimeEnvironmentStatusEntryForTests(environmentId: string): void {
const next = new Map(useAppStore.getState().runtimeStatusByEnvironmentId)
next.delete(environmentId)
useAppStore.setState({ runtimeStatusByEnvironmentId: next } as never)
}
describe('resume across the mirror handle gap', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
resetHostSessionMirrorHydrationForTests()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
afterEach(() => {
// Why first: the store reset below retracts every row, which would replay a still-parked wait.
resetHostMirrorHandleGapWaitsForTests()
useAppStore.setState(initialAppStoreState, true)
resetHostSessionMirrorHydrationForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.useRealTimers()
})
it('does not resume a published mirrored pane whose handle has not landed yet', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
// The rows have arrived; only the handles are outstanding.
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
const launched = resumeSleepingAgentSessionsForWorktree(worktree.id)
const after = useAppStore.getState()
expect(launched).toBe(0)
expect(after.tabsByWorktree[worktree.id]).toHaveLength(1)
expect(Object.keys(after.pendingStartupByTabId)).toHaveLength(0)
// The record survives: the next frame carries the handle and decides for real.
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
// And something is armed to decide it — a hold with nothing armed is the defect, not the fix.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
it('still resumes once the host has published the row without any live handle', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedActiveSleepingRecord(worktree.id)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@other'] } })
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(1)
})
// The three exits of the per-pane park. A park with no bounded release is the
// latch-that-never-releases defect, so each one must replay the sweep.
it("releases when the pane's own handle lands and keeps the pane it now owns", () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_1'] } })
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
// The released waiter must not fire again at the deadline.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
const after = useAppStore.getState()
expect(after.tabsByWorktree[worktree.id]).toHaveLength(1)
expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
})
it('releases when a handle lands for the tab and resumes if it belongs to another pane', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@other'] } })
const after = useAppStore.getState()
const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? [])
.map((tab) => tab.id)
.filter((id) => id !== WEB_TAB_ID)
expect(resumeTabIds).toHaveLength(1)
expect(after.automaticAgentResumeClaimsByTabId[resumeTabIds[0]!]?.providerSession).toEqual({
key: 'session_id',
id: 'handle-gap-session'
})
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('releases when the host retracts the row and resumes into a fresh tab', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
useAppStore.setState({ tabsByWorktree: { [worktree.id]: [] } })
const after = useAppStore.getState()
const tabs = after.tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
expect(after.automaticAgentResumeClaimsByTabId[tabs[0]!.id]?.providerSession).toEqual({
key: 'session_id',
id: 'handle-gap-session'
})
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('releases at the deadline and resumes rather than holding the pane forever', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
vi.advanceTimersByTime(1)
const after = useAppStore.getState()
const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? [])
.map((tab) => tab.id)
.filter((id) => id !== WEB_TAB_ID)
expect(resumeTabIds).toHaveLength(1)
expect(after.automaticAgentResumeClaimsByTabId[resumeTabIds[0]!]?.providerSession).toEqual({
key: 'session_id',
id: 'handle-gap-session'
})
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('keeps the original deadline when a second sweep re-parks the same pane', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
// A re-activation mid-wait must not push the decision out another full budget.
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
it('re-arms the wait after a reconnect instead of inheriting the expired verdict', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
// A host restart: the same row, a new connection, its handle unknown again.
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
setRuntimeEnvironmentConnectionGenerationForTests(RUNTIME_ENV_ID, 1)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
})
// Why this is not the test above: there the wait had already expired before the reconnect, so
// the stale verdict was a map entry. Here the wait is still armed when the generation moves, and
// its deadline then fires on a connection that has had no chance at all to publish the handle.
it('does not let a wait armed on the previous connection decide the new one', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
// The host reconnects one millisecond before the wait's own deadline.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1)
setRuntimeEnvironmentConnectionGenerationForTests(RUNTIME_ENV_ID, 1)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
vi.advanceTimersByTime(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0)
// Re-armed, not held: the new connection gets its own budget and then decides.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
// The journey: the network drops mid-turn on a paired runtime. Nothing is unpaired and no
// reconnect has happened, so the connection generation has not moved — runtime-status.ts
// advances it on the *reconnect*, under a new runtime id. The deadline therefore fires with a
// generation that still matches, and its silence is about the outage, not about the host. A
// verdict recorded there resumes the agent the host is still running (#19735 through the
// disconnect door, docs/reference/ssh-execution-boundary.md).
it('does not turn an outage into a verdict when the environment dropped mid-park', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
setRuntimeEnvironmentDisconnectedForTests(RUNTIME_ENV_ID)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
const during = useAppStore.getState()
expect(during.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
expect(Object.keys(during.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
expect((during.tabsByWorktree[worktree.id] ?? []).map((tab) => tab.id)).toEqual([WEB_TAB_ID])
// Held, not abandoned: something is still armed to decide once contact returns.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
// Contact returns and the host still publishes no handle for the pane. That silence IS
// evidence, so the next full budget decides — a hold that outlives the outage would be the
// latch-that-never-releases defect this module exists to avoid.
clearRuntimeEnvironmentStatusEntryForTests(RUNTIME_ENV_ID)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
const after = useAppStore.getState()
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(1)
})
it('releases only the pane whose handle landed when two panes share the environment', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedSecondMirroredPane(worktree.id)
const firstPaneKey = seedActiveSleepingRecordFor(worktree.id, WEB_TAB_ID, LEAF_ID, 'session-1')
const secondPaneKey = seedActiveSleepingRecordFor(
worktree.id,
SECOND_TAB_ID,
SECOND_LEAF_ID,
'session-2'
)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(2)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_1'] } })
// The first pane owns its live PTY; the second is still undecided, not resumed.
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
const after = useAppStore.getState()
expect(after.sleepingAgentSessionsByPaneKey[firstPaneKey]).toBeDefined()
expect(after.sleepingAgentSessionsByPaneKey[secondPaneKey]).toBeDefined()
expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
// Why the clock matters: releasing the second pane here and letting the replay re-park it
// would look identical right now and silently restart its budget. Its own deadline still has
// to land on the original schedule.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[secondPaneKey]).toBeUndefined()
})
it('does not release or reschedule a park because another environment published a handle', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
const paneKey = seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
useAppStore.setState({
ptyIdsByTabId: { 'web-terminal-other-env-tab': ['remote:env-other@@term_1'] }
})
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
// The unrelated handle must not have restarted this pane's budget.
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('leaves no waiter or timer behind when the environment tears its rows down mid-park', () => {
const worktree = makeRuntimeOwnedWorktree()
seedMirroredWorkspace(worktree)
seedActiveSleepingRecord(worktree.id)
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
// Teardown drops every row the environment owned.
useAppStore.setState({ tabsByWorktree: {}, terminalLayoutsByTabId: {} } as never)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
// Nothing may still be scheduled against the torn-down environment.
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
clearHostMirrorHandleGapVerdictsForEnvironment,
countHostMirrorHandleGapVerdictsForTests,
countParkedHostMirrorHandleGapPanesForTests,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// The retention suite that the reconciled verdict loop replaced carried one assertion the split
// suites did not: the store subscription is held for exactly as long as something needs it.
//
// Measured rather than assumed, because half of it turned out to be covered already:
// - RETAIN direction (drop the verdict term from `stopStoreSubscriptionIfIdle`, so a verdict
// with no waiter behind it loses the subscription its drain needs): already caught, by
// host-mirror-handle-gap-landed-handle.test.ts. Two failures there without this file.
// - RELEASE direction (never release the subscription at all): caught by NOTHING. That mutation
// passes all 272 tests across the 33 other handle-gap and session-tabs suites. A leaked
// subscription rescans every parked pane on every store write for the life of the session and
// no test notices.
//
// So this file exists for the release direction; the retain cases are here because the two belong
// in one place, not because they were missing. `stopStoreSubscriptionIfIdle` counts VERDICTS as
// well as waiters -- the landed-handle drain observes a transition no waiter is parked for -- and
// that is exactly the term the reconcile moved, so both directions are worth holding still.
//
// Asserted through a spy rather than a new test-only export: whether the module is subscribed is
// already observable at the store boundary, and the production surface should not grow to say so.
const ENVIRONMENT_ID = 'env-subscription'
const OTHER_ENVIRONMENT_ID = 'env-other'
const WORKTREE_ID = 'repo-1::/workspace/repo'
const initialAppStoreState = useAppStore.getState()
let unsubscribeCalls: number
let subscribeCalls: number
function publishPaneAndPark(environmentId: string, tabId: string): void {
const state = useAppStore.getState()
const published = state.tabsByWorktree[WORKTREE_ID] ?? []
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: {
[WORKTREE_ID]: [...published.filter((tab) => tab.id !== tabId), { id: tabId, title: tabId }]
},
terminalLayoutsByTabId: {
...state.terminalLayoutsByTabId,
[tabId]: {
root: { type: 'leaf', leafId: `leaf-${tabId}` },
activeLeafId: `leaf-${tabId}`,
expandedLeafId: null,
ptyIdsByLeafId: { [`leaf-${tabId}`]: `remote:${environmentId}@@term_${tabId}` }
}
}
} as never)
parkUntilHostMirrorHandleLands(environmentId, WORKTREE_ID, tabId, () => {})
}
/** Lands the pane's handle, which is what both releases a waiter and retires a verdict. */
function landHandle(tabId: string): void {
useAppStore.setState({
ptyIdsByTabId: { ...useAppStore.getState().ptyIdsByTabId, [tabId]: [`pty-${tabId}`] }
} as never)
}
describe('host-mirror handle-gap store subscription lifetime', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
unsubscribeCalls = 0
subscribeCalls = 0
const realSubscribe = useAppStore.subscribe.bind(useAppStore)
vi.spyOn(useAppStore, 'subscribe').mockImplementation(((listener: never) => {
subscribeCalls += 1
const unsubscribe = realSubscribe(listener)
return () => {
unsubscribeCalls += 1
unsubscribe()
}
}) as never)
})
afterEach(() => {
vi.restoreAllMocks()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
it('holds exactly one subscription across several parked panes', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
publishPaneAndPark(ENVIRONMENT_ID, 'tab-b')
publishPaneAndPark(OTHER_ENVIRONMENT_ID, 'tab-c')
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(3)
expect(subscribeCalls).toBe(1)
expect(unsubscribeCalls).toBe(0)
})
it('releases the subscription once the last waiter leaves and no verdict remains', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
publishPaneAndPark(ENVIRONMENT_ID, 'tab-b')
landHandle('tab-a')
expect(unsubscribeCalls).toBe(0)
landHandle('tab-b')
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0)
expect(unsubscribeCalls).toBe(1)
})
it('keeps the subscription for a verdict with no waiter parked behind it', () => {
// The case the reconcile introduced: the waiter is gone, but the landed-handle drain still has
// a verdict to watch. Counting only waiters here would drop the subscription that drain needs.
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(1)
expect(unsubscribeCalls).toBe(0)
})
it('releases the subscription when the last verdict is cleared by teardown', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(unsubscribeCalls).toBe(0)
clearHostMirrorHandleGapVerdictsForEnvironment(ENVIRONMENT_ID)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0)
expect(unsubscribeCalls).toBe(1)
})
it('re-subscribes rather than reusing a dropped subscription', () => {
publishPaneAndPark(ENVIRONMENT_ID, 'tab-a')
landHandle('tab-a')
expect(unsubscribeCalls).toBe(1)
publishPaneAndPark(ENVIRONMENT_ID, 'tab-d')
expect(subscribeCalls).toBe(2)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
})
})
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import { clearWebSessionTabsTrackingForEnvironment } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
clearHostMirrorHandleGapVerdictsForEnvironment,
countHostMirrorHandleGapVerdictsForTests,
countParkedHostMirrorHandleGapPanesForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
// The orphan class no recording-driven prune can reach. Both existing rules — stale generation and
// tab death — run only when a verdict is RECORDED, so an environment that is removed and never
// expires another pane keeps its rows for the life of the session.
const ENVIRONMENT_ID = 'env-torn-down'
const OTHER_ENVIRONMENT_ID = 'env-survivor'
const WORKTREE_ID = 'repo-1::/workspace/repo'
const initialAppStoreState = useAppStore.getState()
function parkAndExpire(environmentId: string, tabId: string): void {
// Rows ACCUMULATE. Replacing them would unpublish the panes parked earlier, and the tab-death
// rule would then legitimately sweep their verdicts before teardown was ever reached — this
// suite is about a class no recording-driven prune can reach, so every pane here stays live.
const state = useAppStore.getState()
const published = state.tabsByWorktree[WORKTREE_ID] ?? []
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: {
[WORKTREE_ID]: [...published.filter((tab) => tab.id !== tabId), { id: tabId, title: tabId }]
},
// A verdict names its PANE by the environment-minted PTY held at park time, so a fixture with
// no layout binding records '' and the verdict refuses to answer. Bind per environment: one
// shared environment id would filter to '' for every other environment's pane.
terminalLayoutsByTabId: {
...state.terminalLayoutsByTabId,
[tabId]: {
root: { type: 'leaf', leafId: `leaf-${tabId}` },
activeLeafId: `leaf-${tabId}`,
expandedLeafId: null,
ptyIdsByLeafId: { [`leaf-${tabId}`]: `remote:${environmentId}@@term_${tabId}` }
}
}
} as never)
parkUntilHostMirrorHandleLands(environmentId, WORKTREE_ID, tabId, () => {})
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
}
describe('host-mirror handle-gap verdicts across environment teardown', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
useAppStore.setState(initialAppStoreState, true)
vi.useRealTimers()
})
it('drops the torn-down environment’s verdicts and keeps every other environment’s', () => {
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-1')
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-2')
parkAndExpire(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(3)
clearHostMirrorHandleGapVerdictsForEnvironment(ENVIRONMENT_ID)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(1)
expect(hasHostMirrorHandleWaitExpired(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')).toBe(
true
)
})
// Matches `clearHostSessionMirrorHydration`: a re-pair replaces the connection's evidence, it
// does not cancel the recovery this client still owes the pane. Clearing the waiter here would
// silently drop a parked resume sweep that nothing else will replay.
it('leaves a parked waiter alone, cancelling only the verdicts', () => {
const replay = vi.fn()
useAppStore.setState({
ptyIdsByTabId: {},
tabsByWorktree: { [WORKTREE_ID]: [{ id: 'web-terminal-host-tab-9', title: 'nine' }] }
} as never)
parkUntilHostMirrorHandleLands(ENVIRONMENT_ID, WORKTREE_ID, 'web-terminal-host-tab-9', replay)
clearHostMirrorHandleGapVerdictsForEnvironment(ENVIRONMENT_ID)
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
expect(replay).toHaveBeenCalledTimes(1)
})
// The live wiring: session-tabs tracking teardown is the only caller that fires for an
// environment that is going away, so the hook has to hang off it or the rows never drain.
it('drains through the session-tabs tracking teardown for the environment', () => {
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-1')
parkAndExpire(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(2)
clearWebSessionTabsTrackingForEnvironment(ENVIRONMENT_ID)
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(1)
expect(hasHostMirrorHandleWaitExpired(OTHER_ENVIRONMENT_ID, 'web-terminal-host-tab-3')).toBe(
true
)
})
// Why the stranded row was inert rather than dangerous, pinned so nobody "optimises" the
// generation advance away: removing an environment advances its connection generation, so a
// verdict left behind can never match again even if the id returns.
it('cannot match again after the environment returns on a new generation', () => {
parkAndExpire(ENVIRONMENT_ID, 'web-terminal-host-tab-1')
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, 'web-terminal-host-tab-1')).toBe(true)
setRuntimeEnvironmentConnectionGenerationForTests(ENVIRONMENT_ID, 1)
expect(hasHostMirrorHandleWaitExpired(ENVIRONMENT_ID, 'web-terminal-host-tab-1')).toBe(false)
})
})
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
setRuntimeEnvironmentConnectionGenerationForTests
} from '@/store/slices/runtime-status'
import {
HOST_MIRROR_HANDLE_GAP_DEADLINE_MS,
clearHostMirrorHandleGapVerdictsForEnvironment,
countHostMirrorHandleGapVerdictsForTests,
hasHostMirrorHandleWaitExpired,
parkUntilHostMirrorHandleLands,
resetHostMirrorHandleGapWaitsForTests
} from './host-mirror-handle-gap-wait'
/**
* The UNION suite for `expiredGenerationByPane`.
*
* Three agents changed this one map on three branches and each verified only their own. These
* cases exist because nothing else proves the rules compose: individually-correct rules whose
* interaction nobody tested is the exact failure this was looking for.
*
* Four orphan classes, and what covers each:
* A tab churn on a LIVE environment tab-death rule, recording environment only
* B REMOVED environment clearHostMirrorHandleGapVerdictsForEnvironment
* C cross-environment QUIESCENCE generation rule, per key, every environment
* D REUSED tab id read-time pane identity, NOT a prune
*
* D is the one that needed no new trigger: every trigger the other three own fires downstream of
* the moment it needs. The verdict instead carries the PTY binding its pane held AT PARK TIME and
* only answers for a pane that still holds it.
*
* Plus the properties no rule may break: the verdict stays sticky enough to break the
* park/expire/replay loop, a genuine reattach still inherits, and no rule evicts a verdict a live
* pane still needs.
*/
const ENV_A = 'env-union-a'
const ENV_B = 'env-union-b'
const ENV_C = 'env-union-c'
const WORKTREE = 'repo-1::wt-union'
const initialAppStoreState = useAppStore.getState()
/** Which environment minted each pane's PTY; the binding only counts for its own environment. */
const ENV_OF_TAB: Record<string, string> = {
a1: ENV_A,
a2: ENV_A,
reused: ENV_A,
b1: ENV_B,
c1: ENV_C
}
/** Publishes rows AND the layout PTY binding each pane holds — the binding is the pane's identity. */
function setLiveTabs(tabIds: string[], ptyByTabId: Record<string, string> = {}): void {
const layouts: Record<string, unknown> = {}
for (const id of tabIds) {
const ptyId = ptyByTabId[id] ?? `remote:${ENV_OF_TAB[id] ?? ENV_A}@@term_${id}`
layouts[id] = {
root: { type: 'leaf', leafId: `leaf-${id}` },
activeLeafId: `leaf-${id}`,
expandedLeafId: null,
ptyIdsByLeafId: { [`leaf-${id}`]: ptyId }
}
}
useAppStore.setState({
tabsByWorktree: { [WORKTREE]: tabIds.map((id) => ({ id, title: id, ptyId: null })) },
terminalLayoutsByTabId: layouts,
ptyIdsByTabId: {}
} as unknown as AppState)
}
function parkAndExpire(environmentId: string, tabId: string): void {
parkUntilHostMirrorHandleLands(environmentId, WORKTREE, tabId, () => {})
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
}
describe('handle-gap verdict map, all rules on one tree', () => {
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(initialAppStoreState, true)
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
afterEach(() => {
resetHostMirrorHandleGapWaitsForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.useRealTimers()
})
it('handles all four orphan classes simultaneously', () => {
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1)
}
setLiveTabs(['a1', 'a2', 'b1', 'c1', 'reused'])
// A: tab churn on a live environment. a1 expires, then its tab closes.
parkAndExpire(ENV_A, 'a1')
// B: a whole environment that will be removed.
parkAndExpire(ENV_B, 'b1')
// C: an environment that will reconnect and then never expire another pane.
parkAndExpire(ENV_C, 'c1')
// D: a tab id that will be retracted and republished under the same id.
parkAndExpire(ENV_A, 'reused')
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(4)
// C reconnects and goes quiet. B's environment is removed outright.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_C, 2)
clearHostMirrorHandleGapVerdictsForEnvironment(ENV_B)
// A's tab closes; the reused id is retracted and republished as a DIFFERENT pane, which binds
// a PTY the host newly minted. That new binding is what makes it a different pane, not the id.
setLiveTabs(['a2', 'reused'], { reused: `remote:${ENV_A}@@term_freshly_minted` })
parkAndExpire(ENV_A, 'a2')
// A drained: a1's row is gone and env-a recorded again, so the tab-death rule swept it.
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
// B drained: by teardown, which is the only trigger that fires for a removed environment.
expect(hasHostMirrorHandleWaitExpired(ENV_B, 'b1')).toBe(false)
// C drained: env-a's expiry retired env-c's superseded row, though env-c never expired again.
expect(hasHostMirrorHandleWaitExpired(ENV_C, 'c1')).toBe(false)
// D is closed, and NOT by a prune. No trigger any rule above owns fires at the right moment:
// the tab-death predicate stops matching once the id is live again, teardown is the wrong
// event, and no waiter observes the retraction because a pane holding a verdict never parks.
// It is closed at READ time instead — the verdict names the pane it was about, so a pane that
// binds a newly minted PTY does not answer to it and serves its own wait.
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'reused')).toBe(false)
// Only the two live verdicts survive: a2's and the stranded reused-id row.
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(2)
})
it('answers for a genuine reattach that still holds the same PTY', () => {
// The verdict follows the PTY, not the tab id. A pane that reattaches to the SAME environment
// PTY is the same pane, so it must inherit — otherwise the identity check would have quietly
// removed the loop-breaker for every reattach.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'])
parkAndExpire(ENV_A, 'a1')
setLiveTabs([])
setLiveTabs(['a1'])
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('records the binding the pane held at PARK time, not at expiry', () => {
// The mutation this kills: reading the binding inside `recordExpiredWait` from the store
// instead of from the waiter. A pane replaced mid-wait leaves the original waiter running to
// term, and an expiry-time read would attribute the verdict to whoever holds the id by then —
// handing the new pane a wait it never served. Three earlier cases all survived that bug;
// only rebinding BETWEEN park and expire distinguishes the two implementations.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'])
parkUntilHostMirrorHandleLands(ENV_A, WORKTREE, 'a1', () => {})
setLiveTabs(['a1'], { a1: `remote:${ENV_A}@@term_replacement` })
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS + 1)
// The replacement pane never served this wait, so it must not inherit its verdict.
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
})
it('refuses to answer on an empty binding, which is a match value and not a null', () => {
// '' is what `paneBindingFor` returns when no leaf holds an environment-minted PTY. Two
// different panes both reading '' would compare EQUAL and inherit, which is the reused-tab-id
// shape again. Measured unreachable through the production park path rather than assumed: the
// only route into `parkUntilHostMirrorHandleLands` is `kind: 'handle'`, which
// `findUnhydratedHostMirrorForPane` reports only when `tabHoldsEnvironmentPtyBinding`
// (host-mirrored-pane-liveness.ts:28-31) finds a match — the SAME `terminalLayoutsByTabId`
// map through the SAME `parseRemoteRuntimePtyId` predicate `paneBindingFor` uses, so a pane
// that would bind '' never parks. It must still refuse rather than match, because that
// coupling is two functions in two files and nothing enforces it.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'], { a1: 'remote:some-other-env@@term_1' })
parkAndExpire(ENV_A, 'a1')
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
})
it('keeps a verdict sticky enough to break the park/expire/replay loop', () => {
// The verdict exists to stop a pane re-parking forever. If any rule evicted it while the pane
// is live and its connection current, the wait would rearm on a fresh budget every replay.
setRuntimeEnvironmentConnectionGenerationForTests(ENV_A, 1)
setLiveTabs(['a1'])
parkAndExpire(ENV_A, 'a1')
for (let replay = 0; replay < 20; replay += 1) {
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
parkAndExpire(ENV_A, 'a1')
}
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('never evicts a live pane verdict, whichever environment sweeps', () => {
// Three environments on purpose: with two at one generation the candidate rules are
// indistinguishable and the naive "judge everything against the recording environment"
// mutation survives. env-c is the discriminator — its verdict is live.
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1)
}
setLiveTabs(['a1', 'a2', 'b1', 'c1'])
parkAndExpire(ENV_A, 'a1')
parkAndExpire(ENV_B, 'b1')
parkAndExpire(ENV_C, 'c1')
// env-b is briefly rowless mid-rehydration while env-a sweeps. Row absence is transient, so
// this must not be read as retraction for an environment other than the one recording.
setLiveTabs(['a1', 'a2', 'c1'])
parkAndExpire(ENV_A, 'a2')
setLiveTabs(['a1', 'a2', 'b1', 'c1'])
expect(hasHostMirrorHandleWaitExpired(ENV_B, 'b1')).toBe(true)
expect(hasHostMirrorHandleWaitExpired(ENV_C, 'c1')).toBe(true)
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
})
it('returns to baseline under churn across all three drains', () => {
for (let round = 0; round < 300; round += 1) {
const environmentId = [ENV_A, ENV_B, ENV_C][round % 3]!
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, round + 1)
setLiveTabs([`tab-${round}`])
parkAndExpire(environmentId, `tab-${round}`)
}
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
clearHostMirrorHandleGapVerdictsForEnvironment(environmentId)
}
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,398 @@
import { useAppStore } from '@/store'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { WEB_SESSION_TAB_RPC_TIMEOUT_MS } from '@/runtime/web-session-tab-rpc-timeout'
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
import {
isDisconnectedRuntimeHostState,
runtimeHostConnectionStateForEntry
} from '@/runtime/runtime-host-connection-state'
/**
* Per-pane park for the frame between a host's tab rows and its PTY handles.
*
* Why: mirror hydration says "the rows arrived", not "this pane's liveness is
* decidable" — the handle lands one relay round trip later. A pane whose leaf
* is still bound to a PTY of the same environment, with no published handle,
* is `unverifiable` (docs/reference/ssh-execution-boundary.md); resuming on it
* forked a session the host was still running (#19735).
*
* The wait is bounded because mirror settlement has already happened and will
* not replay a parked sweep again. Three exits, each replaying the sweep:
* - the pane's own handle lands (`ptyIdsByTabId[tabId]` non-empty);
* - the row is retracted (the host has spoken: the pane is gone);
* - the deadline expires. A handle that has not landed within the RPC budget
* is not coming on this connection, so the pane is released to ordinary
* recovery: a resume after a bounded wait is defensible, an indefinite hold
* is the latch-that-never-releases defect. A reconnect bumps the connection
* generation and arms a fresh wait.
*
* Sustained reconnect churn can therefore hold a pane parked indefinitely: each reconnect voids the
* in-flight verdict and grants a fresh full budget. That is CORRECT, not the defect above. Under
* churn the pane's liveness genuinely is unverifiable, and `docs/reference/ssh-execution-boundary.md`
* forbids resolving unverifiable to `exited`. It has the shape of a latch that never releases, so
* do not "fix" it by letting a verdict from one connection decide another — that is #19735.
*/
export const HOST_MIRROR_HANDLE_GAP_DEADLINE_MS = WEB_SESSION_TAB_RPC_TIMEOUT_MS
type HandleGapWaiter = {
worktreeId: string
tabId: string
/** Connection generation the wait was armed on; its verdict is void on any other. */
generation: number
/** Which PANE this wait is about, captured at park time; see ExpiredHandleGapVerdict. */
paneBinding: string
deadline: ReturnType<typeof setTimeout>
run: () => void
}
type HandleGapStoreState = Pick<
ReturnType<typeof useAppStore.getState>,
'ptyIdsByTabId' | 'tabsByWorktree'
>
const waitersByPane = new Map<string, HandleGapWaiter>()
/**
* Connection generation whose wait already expired for the pane.
*
* FOUR drains, with four different triggers. Getting the scopes right is the whole design; see
* `recordExpiredWait` for why the first two must NOT share a scope.
* - superseded generation: per key, EVERY environment. Runs on any recording, anywhere.
* - dead tab row: the recording environment ONLY. Runs on a recording in that environment.
* - removed environment: `clearHostMirrorHandleGapVerdictsForEnvironment`, on teardown. The only
* trigger that fires at all for an environment that will never record again. A row stranded
* there is inert — removal advances the generation, so it can never match — so that one is a
* leak fix, not a correctness fix.
* - PUBLISHED HANDLE: `retireVerdictsWithLandedHandles`, from the store subscription. The gap a
* verdict measured is over once its pane publishes a handle, so the NEXT gap must get its own
* wait. The other three provably cannot reach this: the generation no longer moves across an
* outage on one runtime (#19647, same stack), the row stays published the whole time — it is
* the HANDLE that comes and goes — the environment is still here, and the read-time pane
* identity below deliberately lets the same PTY inherit. It is the only drain that needs the
* subscription to outlive the waiters, which is why `stopStoreSubscriptionIfIdle` counts
* verdicts too.
*
* A FIFTH class is covered but NOT by any of those drains: a retracted tab id republished as a
* different pane, which would inherit the old pane's verdict and skip its own wait — the #19735
* direction rather than a longer hold. No trigger can reach it, and the reason is worth keeping:
* the dead-row predicate stops matching once the id is live again, teardown is the wrong event,
* and a pane holding a verdict never parks, so no waiter is there to observe the retraction. It is
* closed at READ time instead, by `hasHostMirrorHandleWaitExpired` comparing the verdict's
* park-time `paneBinding` — a pane that binds a newly minted PTY does not answer to a verdict
* about its predecessor. Pinned as class D in host-mirror-handle-gap-verdict-union.test.ts; do not
* delete that case.
*
* The PUBLISHED HANDLE drain does not close that class and must not be read as closing it: it
* needs the row to stay published throughout, and that class needs the row to go away. Read-time
* identity separates two panes behind one tab id; the drain separates two gaps on one pane. They
* look adjacent and are orthogonal — mutation kills them with disjoint tests.
*
* Why this comment block is worth re-reading against the code rather than trusting: the paragraph
* above it spent one commit asserting this class was still open and demanding a trigger that had
* just been replaced by the read-time check, while the test it named as its pin said the opposite.
* Several agents change this map in parallel and the invariants move faster than the prose, so
* when the two disagree the test file is the one that ran.
*/
type ExpiredHandleGapVerdict = {
generation: number
/** Sorted environment-minted PTY ids the tab's leaves held AT PARK TIME; '' when none. */
paneBinding: string
}
const expiredGenerationByPane = new Map<string, ExpiredHandleGapVerdict>()
let unsubscribeStore: (() => void) | null = null
function paneWaitKey(environmentId: string, tabId: string): string {
return `${environmentId}\0${tabId}`
}
/**
* The environment-minted PTY ids this tab's leaves are bound to, as one comparable string.
*
* Read from the layout, not `ptyIdsByTabId`: during the handle gap the published-handle map is
* empty by definition — that is the gap — while the layout binding is what
* `tabHoldsEnvironmentPtyBinding` already uses to call the pane unverifiable rather than dead.
*/
function paneBindingFor(tabId: string, environmentId: string): string {
const bindings = useAppStore.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}
return Object.values(bindings)
.filter(
(ptyId): ptyId is string =>
typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId)?.environmentId === environmentId
)
.sort()
.join('')
}
/** True once the deadline fired for THIS pane on the current connection. */
export function hasHostMirrorHandleWaitExpired(environmentId: string, tabId: string): boolean {
const verdict = expiredGenerationByPane.get(paneWaitKey(environmentId, tabId))
if (verdict === undefined || verdict.paneBinding === '') {
// Why '' never answers: it is a MATCH VALUE, not a null. Two different panes that both hold no
// environment-minted PTY compare equal, which is the reused-tab-id inheritance this check
// exists to stop, in a narrower window. Unreachable through the production park path —
// `findUnhydratedHostMirrorForPane` only reports `kind: 'handle'` when
// `tabHoldsEnvironmentPtyBinding` finds a binding, reading the same map through the same
// predicate as `paneBindingFor` — and pinned by the coupling test in
// host-mirror-handle-gap-verdict-union.test.ts. Refusing costs a re-park, which is the
// conservative direction, so the pair stays safe even if those two reads ever drift apart.
return false
}
return (
verdict.generation === getRuntimeEnvironmentConnectionGeneration(environmentId) &&
// Why this and not the key alone: the key is a tab id, and the pane behind it can be replaced.
verdict.paneBinding === paneBindingFor(tabId, environmentId)
)
}
function liveTabIds(): Set<string> {
const tabIds = new Set<string>()
for (const tabs of Object.values(useAppStore.getState().tabsByWorktree)) {
for (const tab of tabs) {
tabIds.add(tab.id)
}
}
return tabIds
}
/**
* True only when the client positively knows it is out of contact — the link dropped, or its
* replacement is still being established.
*
* Why not `isConnectedRuntimeHostState`: that reads a host nobody has probed yet as not
* connected, and a never-probed host is not the outage this guards. Narrowing to the two states
* an outage actually produces keeps the guard to the case where silence provably means "we could
* not ask" rather than "the host had nothing to say".
*
* Why this and not the connection generation: a plain disconnect leaves the generation where it
* was — runtime-status.ts advances it on the *reconnect*, under a new runtime id — so a wait that
* expires mid-outage is indistinguishable, to the generation guard, from one that expired on a
* healthy connection.
*/
function environmentContactIsLost(environmentId: string): boolean {
const connectionState = runtimeHostConnectionStateForEntry(
useAppStore.getState().runtimeStatusByEnvironmentId.get(environmentId)
)
return isDisconnectedRuntimeHostState(connectionState) || connectionState === 'reconnecting'
}
function recordExpiredWait(environmentId: string, key: string): void {
const generation = getRuntimeEnvironmentConnectionGeneration(environmentId)
// TWO rules with DIFFERENT scopes, deliberately. Flattening them to one scope is wrong either
// way round, and both wrong shapes were independently written before this was reconciled.
const prefix = `${environmentId}\0`
const liveTabs = liveTabIds()
for (const [staleKey, stale] of expiredGenerationByPane) {
// GENERATION, judged per key across EVERY environment. `hasHostMirrorHandleWaitExpired`
// compares a row against its own environment's CURRENT generation, so a row whose generation
// has moved can never return true for anyone. Retiring it cannot cost a reader a verdict,
// whoever owns it. Scoped to the recording environment, an environment that reconnects and
// then goes quiet strands its rows forever.
const staleEnvironmentId = staleKey.slice(0, staleKey.indexOf('\0'))
if (stale.generation !== getRuntimeEnvironmentConnectionGeneration(staleEnvironmentId)) {
expiredGenerationByPane.delete(staleKey)
continue
}
// TAB DEATH, this environment ONLY. Unlike a generation, row absence is transient: a sibling
// mid-republish has no rows for a frame and would lose a verdict its pane still needs. What
// licenses the inference here is that the recording pane's own row is published right now —
// the deadline only records while its waiter is parked — which establishes that THIS
// environment has a published row. It does not establish that it has finished republishing,
// so do not widen this further: a host that has published p1 but not yet p2 can still cost p2
// its verdict. That residual is conservative — drop, re-park, hold longer, never resume early.
if (staleKey.startsWith(prefix) && !liveTabs.has(staleKey.slice(prefix.length))) {
expiredGenerationByPane.delete(staleKey)
}
}
// Why the waiter's park-time binding and not a fresh read: this verdict is about the pane whose
// wait just ran out. Re-reading here would attribute it to whatever holds the id NOW, handing a
// pane that replaced it mid-wait a verdict it never served. The caller must therefore record
// BEFORE `releaseWaiter` deletes the entry; the union suite pins that ordering.
// The `?? ''` is unreachable solely because of the record-before-release ordering above it. The
// caller's generation gate LOOKS like a second guard on it and is not: drop the ordering and that
// gate stops recording anything at all rather than admitting ''. It pins a different property
// (reconnect-void, host-mirror-handle-gap-resume.test.ts). Both are load-bearing, for different
// reasons — do not collapse them as redundant.
expiredGenerationByPane.set(key, {
generation,
paneBinding: waitersByPane.get(key)?.paneBinding ?? ''
})
// The landed-handle drain has to keep watching after this waiter is released.
startStoreSubscription()
}
/**
* Retires the verdict of any pane whose handle is now published.
*
* A published handle is the mirror having spoken for the pane, so the gap the verdict measured is
* over. Read from `ptyIdsByTabId`, deliberately NOT from the layout `paneBinding` — the binding is
* the pane's IDENTITY and holds across the gap by design, which is exactly why it cannot see this.
*/
function retireVerdictsWithLandedHandles(state: HandleGapStoreState): void {
for (const key of expiredGenerationByPane.keys()) {
const tabId = key.slice(key.indexOf('\0') + 1)
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
expiredGenerationByPane.delete(key)
}
}
}
function stopStoreSubscriptionIfIdle(): void {
// Verdicts count: the landed-handle drain observes a transition no waiter is parked for.
if (waitersByPane.size === 0 && expiredGenerationByPane.size === 0 && unsubscribeStore) {
unsubscribeStore()
unsubscribeStore = null
}
}
function releaseWaiter(key: string): void {
const waiter = waitersByPane.get(key)
if (!waiter) {
return
}
clearTimeout(waiter.deadline)
waitersByPane.delete(key)
stopStoreSubscriptionIfIdle()
try {
waiter.run()
} catch (error) {
// Why: one write releases every due pane, and the drain runs inside the store subscriber. The
// panes in it are strangers to each other and to the frame that published the handle, so an
// unguarded replay throw both strands every pane queued behind it and surfaces at the mirror
// apply's own `setState`. The pane is already unparked here; only its replay is lost.
console.warn('[host-mirror-handle-gap] parked resume replay failed:', error)
}
}
function waiterIsReleased(waiter: HandleGapWaiter, state: HandleGapStoreState): boolean {
if ((state.ptyIdsByTabId[waiter.tabId]?.length ?? 0) > 0) {
return true
}
const tabs = state.tabsByWorktree[waiter.worktreeId] ?? []
return !tabs.some((tab) => tab.id === waiter.tabId)
}
function releaseDueWaiters(state: HandleGapStoreState): void {
// Why: drain from a snapshot — a replay can re-park the pane, and that new
// waiter belongs to the next store write, not this one.
const dueKeys: string[] = []
for (const [key, waiter] of waitersByPane) {
if (waiterIsReleased(waiter, state)) {
dueKeys.push(key)
}
}
for (const key of dueKeys) {
releaseWaiter(key)
}
}
function startStoreSubscription(): void {
if (unsubscribeStore) {
return
}
let previous: HandleGapStoreState = useAppStore.getState()
unsubscribeStore = useAppStore.subscribe((state) => {
// Why: only these two slices can release a waiter; title, status, and
// usage ticks must not rescan every parked pane.
if (
state.ptyIdsByTabId === previous.ptyIdsByTabId &&
state.tabsByWorktree === previous.tabsByWorktree
) {
return
}
previous = state
retireVerdictsWithLandedHandles(state)
releaseDueWaiters(state)
stopStoreSubscriptionIfIdle()
})
}
/**
* Parks `run` until the pane's handle lands, its row is retracted, or the
* deadline expires. Re-parking an already-parked pane replaces `run` but keeps
* the original deadline, so a replay that re-parks cannot extend the wait.
*/
export function parkUntilHostMirrorHandleLands(
environmentId: string,
worktreeId: string,
tabId: string,
run: () => void
): void {
const key = paneWaitKey(environmentId, tabId)
const existing = waitersByPane.get(key)
if (existing) {
existing.run = run
// Why the worktree moves with `run`: adopting an orphaned terminal re-keys `tabsByWorktree`
// without re-keying the record, so a live wait left on the old worktree released on retraction
// evidence about a workspace it is no longer about. The park-time `paneBinding` deliberately
// does NOT move — that is the pane's identity, and this is only where its rows are filed.
existing.worktreeId = worktreeId
return
}
const generation = getRuntimeEnvironmentConnectionGeneration(environmentId)
const deadline = setTimeout(() => {
// Why the generation is re-read: a reconnect mid-park makes this wait's silence
// evidence about a connection that is gone. Recording it would let a wait armed
// milliseconds before the reconnect authorize a resume on the new one — the #19735
// fork with an extra step. Release without a verdict instead; the replay re-parks
// and the new connection gets its own full budget.
//
// Why contact is checked too: an environment that dropped mid-park publishes nothing,
// so the deadline measures the outage rather than the host. Loss of contact is never
// evidence about a process (docs/reference/ssh-execution-boundary.md), and a verdict
// recorded here authorizes the resume that forks the agent the host is still running.
// The generation cannot stand in for it — a plain disconnect never advances it.
if (
!environmentContactIsLost(environmentId) &&
waitersByPane.get(key)?.generation ===
getRuntimeEnvironmentConnectionGeneration(environmentId)
) {
recordExpiredWait(environmentId, key)
}
releaseWaiter(key)
}, HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)
waitersByPane.set(key, {
worktreeId,
tabId,
generation,
paneBinding: paneBindingFor(tabId, environmentId),
deadline,
run
})
startStoreSubscription()
}
export function countParkedHostMirrorHandleGapPanesForTests(): number {
return waitersByPane.size
}
/**
* Drops the verdicts an environment's teardown makes unreachable.
*
* Only the verdicts. Parked waiters deliberately survive, matching
* `clearHostSessionMirrorHydration`: a re-pair or effect restart replaces the connection's
* evidence, it does not cancel the recovery this client still owes the pane. A waiter left here is
* bounded by its own deadline and replays the sweep exactly as it would have.
*/
export function clearHostMirrorHandleGapVerdictsForEnvironment(environmentId: string): void {
const prefix = `${environmentId}\0`
for (const key of expiredGenerationByPane.keys()) {
if (key.startsWith(prefix)) {
expiredGenerationByPane.delete(key)
}
}
// The landed-handle drain may have been the only thing holding the subscription open.
stopStoreSubscriptionIfIdle()
}
export function countHostMirrorHandleGapVerdictsForTests(): number {
return expiredGenerationByPane.size
}
export function resetHostMirrorHandleGapWaitsForTests(): void {
for (const waiter of waitersByPane.values()) {
clearTimeout(waiter.deadline)
}
waitersByPane.clear()
expiredGenerationByPane.clear()
unsubscribeStore?.()
unsubscribeStore = null
}
@@ -1,15 +1,34 @@
import type { useAppStore } from '@/store'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { hasHostSessionMirrorHydrated } from '@/runtime/host-session-mirror-hydration'
import { hasHostMirrorHandleWaitExpired } from './host-mirror-handle-gap-wait'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
type AppStoreState = ReturnType<typeof useAppStore.getState>
export type UnhydratedHostMirror = {
/** Null when no paired runtime claims the workspace, so nothing will ever answer for the pane. */
environmentId: string | null
export type UnhydratedHostMirror =
/** The host's tab rows have not arrived; mirror settlement replays the sweep. */
| {
kind: 'mirror'
/** Null when no paired runtime claims the workspace, so nothing will ever answer for the pane. */
environmentId: string | null
}
/** The rows arrived but this pane's PTY handle has not; a bounded per-pane wait replays. */
| { kind: 'handle'; environmentId: string; tabId: string }
/** The layout still binds a leaf of this tab to a PTY the environment minted. */
function tabHoldsEnvironmentPtyBinding(
state: AppStoreState,
tabId: string,
environmentId: string
): boolean {
const bindings = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}
return Object.values(bindings).some(
(ptyId) => parseRemoteRuntimePtyId(ptyId)?.environmentId === environmentId
)
}
/**
@@ -19,7 +38,9 @@ export type UnhydratedHostMirror = {
* Why: a `web-terminal-*` tab exists only because a host published it, and its
* PTY handle arrives one relay round trip later. An empty local handle map is
* therefore "unverifiable", never "exited" — the incident's replacement
* `codex resume` forked a session the host still held.
* `codex resume` forked a session the host still held. Mirror hydration only
* says the rows landed, so a pane still bound to this environment's PTY with
* no handle yet gets its own bounded wait (#19735).
*/
export function findUnhydratedHostMirrorForPane(
record: SleepingAgentSessionRecord,
@@ -41,8 +62,14 @@ export function findUnhydratedHostMirrorForPane(
return null
}
const environmentId = getRuntimeEnvironmentIdForWorktree(state, record.worktreeId)
if (environmentId && hasHostSessionMirrorHydrated(environmentId, record.worktreeId)) {
return null
if (!environmentId || !hasHostSessionMirrorHydrated(environmentId, record.worktreeId)) {
return { kind: 'mirror', environmentId }
}
return { environmentId }
if (
tabHoldsEnvironmentPtyBinding(state, tabId, environmentId) &&
!hasHostMirrorHandleWaitExpired(environmentId, tabId)
) {
return { kind: 'handle', environmentId, tabId }
}
return null
}
@@ -141,4 +141,104 @@ describe('resume sleeping agent provider claims', () => {
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
// Why a peer in another workspace is reachable at all: adopting an orphaned terminal re-keys
// `tabsByWorktree` onto the canonical worktree id and leaves the sleeping records that named the
// old one untouched (workspace-session-worktree-id.ts). A provider session id names one
// transcript, so the live pane owns it wherever it sits; resuming here forks the agent the user
// is watching. `done` is the cell that had no cover: a finished turn on a still-live pane.
// The same-workspace half of the same rule, pinned here so this file covers both cells whether or
// not #19736 (which fixes this one in `activeOrQueuedResumeClaimsProviderSession` too) has landed.
it('does not fork a provider session a live pane in this workspace already finished a turn on', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
const record = makeRecord(paneKey)
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-peer')] },
terminalLayoutsByTabId: {
'tab-peer': {
root: { type: 'leaf', leafId: OTHER_LEAF_ID },
activeLeafId: OTHER_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' }
}
},
ptyIdsByTabId: { 'tab-peer': ['pty-peer'] },
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
agentStatusByPaneKey: {
[peerPaneKey]: { ...makeWorkingStatus(peerPaneKey, 'tab-peer', record), state: 'done' }
}
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('does not fork a provider session a live pane in another workspace is running', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
const record = makeRecord(paneKey)
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
// The record's own pane is gone, so nothing local can own its recovery.
tabsByWorktree: { 'wt-1': [], 'wt-2': [makeTerminalTab('tab-peer')] },
terminalLayoutsByTabId: {
'tab-peer': {
root: { type: 'leaf', leafId: OTHER_LEAF_ID },
activeLeafId: OTHER_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' }
}
},
ptyIdsByTabId: { 'tab-peer': ['pty-peer'] },
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
agentStatusByPaneKey: {
[peerPaneKey]: {
...makeWorkingStatus(peerPaneKey, 'tab-peer', record),
worktreeId: 'wt-2',
state: 'done'
}
}
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
const state = useAppStore.getState()
expect(state.tabsByWorktree['wt-1']).toHaveLength(0)
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
// The same peer without a live PTY is history, not a claim: the session must still come back.
it('still resumes when the other workspace peer finished and holds no live PTY', () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
const record = makeRecord(paneKey)
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
tabsByWorktree: { 'wt-1': [], 'wt-2': [makeTerminalTab('tab-peer')] },
terminalLayoutsByTabId: {
'tab-peer': {
root: { type: 'leaf', leafId: OTHER_LEAF_ID },
activeLeafId: OTHER_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [OTHER_LEAF_ID]: 'pty-peer' }
}
},
ptyIdsByTabId: {},
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
agentStatusByPaneKey: {
[peerPaneKey]: {
...makeWorkingStatus(peerPaneKey, 'tab-peer', record),
worktreeId: 'wt-2',
state: 'done'
}
}
} as never)
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
})
})
@@ -4,17 +4,23 @@ import {
type SleepingAgentSessionRecord
} from '../../../shared/agent-session-resume'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
getProviderSessionClaimKey,
isPassiveCompletedHibernationEvidence,
recordPaneIsOwnedByPreservedPane
recordPaneIsOwnedByPreservedPane,
stablePaneHasLivePty
} from './sleeping-agent-pane-ownership'
import {
launchSleepingAgentSession,
type ResumeSleepingAgentSessionsOptions
} from './sleeping-agent-session-launch'
import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record'
import { findUnhydratedHostMirrorForPane } from './host-mirrored-pane-liveness'
import {
findUnhydratedHostMirrorForPane,
type UnhydratedHostMirror
} from './host-mirrored-pane-liveness'
import { parkUntilHostMirrorHandleLands } from './host-mirror-handle-gap-wait'
import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority'
import { parkUntilHostSessionMirrorHydrates } from '@/runtime/host-session-mirror-hydration'
@@ -96,12 +102,37 @@ function activeOrQueuedResumeClaimsProviderSession(
if (samePaneOwnsRecovery && entry.paneKey === record.paneKey) {
continue
}
const tabId = getAgentStatusTabId(entry)
const pane = parsePaneKey(entry.paneKey)
if (
entry.agentType !== record.agent ||
!agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession)
) {
continue
}
// Why this arm carries no workspace scope: a provider session id names one transcript, so a
// pane whose exact PTY is live right now already owns it wherever that pane happens to sit, and
// resuming forks the agent the user is watching. The scoped arm below still needs its scope —
// a status row with no live PTY is a claim about the past. The two ids do drift: adopting an
// orphaned terminal re-keys `tabsByWorktree` without re-keying the sleeping records that name
// the old id (workspace-session-worktree-id.ts), and a completed turn on a live pane is exactly
// where the drift stops being caught.
if (
pane &&
tabId === pane.tabId &&
stablePaneHasLivePty(
pane.tabId,
pane.leafId,
state.ptyIdsByTabId,
state.terminalLayoutsByTabId[pane.tabId]
)
) {
return true
}
if (
worktreeTabIds.has(getAgentStatusTabId(entry) ?? '') &&
entry.worktreeId === record.worktreeId &&
entry.agentType === record.agent &&
entry.state !== 'done' &&
agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession)
worktreeTabIds.has(tabId ?? '') &&
entry.worktreeId === record.worktreeId
) {
return true
}
@@ -148,27 +179,37 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord):
)
}
function parkWorktreeResumeSweepUntilHostMirrorHydrates(
function replayParkedWorktreeResumeSweep(
worktreeId: string,
environmentId: string | null,
options: ResumeSleepingAgentSessionsOptions | undefined
): void {
if (!environmentId) {
// Why: the mirror can settle long after the user moved on, so a replayed
// resume must not steal the surface they are looking at now.
const isActive = useAppStore.getState().activeWorktreeId === worktreeId
// Why `skipClaimKeys` is dropped: it is a park-time snapshot of in-place
// wakes, and a latch that has since failed must stay resumable here.
resumeSleepingAgentSessionsForWorktree(worktreeId, {
...(options?.onSessionLaunched ? { onSessionLaunched: options.onSessionLaunched } : {}),
...(isActive ? {} : { suppressNavigation: true })
})
}
function parkWorktreeResumeSweepUntilHostMirrorAnswers(
worktreeId: string,
mirror: UnhydratedHostMirror,
options: ResumeSleepingAgentSessionsOptions | undefined
): void {
const replay = (): void => replayParkedWorktreeResumeSweep(worktreeId, options)
if (mirror.kind === 'handle') {
parkUntilHostMirrorHandleLands(mirror.environmentId, worktreeId, mirror.tabId, replay)
return
}
if (!mirror.environmentId) {
// No paired runtime owns the workspace, so no verdict is coming; the next
// activation re-runs this sweep once one does.
return
}
parkUntilHostSessionMirrorHydrates(environmentId, worktreeId, () => {
// Why: the mirror can settle long after the user moved on, so a replayed
// resume must not steal the surface they are looking at now.
const isActive = useAppStore.getState().activeWorktreeId === worktreeId
// Why `skipClaimKeys` is dropped: it is a park-time snapshot of in-place
// wakes, and a latch that has since failed must stay resumable here.
resumeSleepingAgentSessionsForWorktree(worktreeId, {
...(options?.onSessionLaunched ? { onSessionLaunched: options.onSessionLaunched } : {}),
...(isActive ? {} : { suppressNavigation: true })
})
})
parkUntilHostSessionMirrorHydrates(mirror.environmentId, worktreeId, replay)
}
export function resumeSleepingAgentSessionsForWorktree(
@@ -219,11 +260,7 @@ export function resumeSleepingAgentSessionsForWorktree(
// Why: pane ownership is undecidable until the mirror answers, and every
// branch below — launch and clear alike — trusts that verdict. Take no
// action on the record; the replay re-runs this pass with real evidence.
parkWorktreeResumeSweepUntilHostMirrorHydrates(
worktreeId,
unhydratedMirror.environmentId,
options
)
parkWorktreeResumeSweepUntilHostMirrorAnswers(worktreeId, unhydratedMirror, options)
continue
}
const isPaneOwned = recordPaneIsOwnedByPreservedPane(record, currentState)
@@ -94,7 +94,7 @@ function hasRestorableStablePanePty(
// the pane that reconnects on activation. Liveness comes from the runtime
// live-PTY map (ptyIdsByTabId), not the layout's ptyIdsByLeafId snapshot, which
// persists stale across sleep/restart.
function stablePaneHasLivePty(
export function stablePaneHasLivePty(
tabId: string,
leafId: string,
ptyIdsByTabId: Record<string, string[]>,
@@ -0,0 +1,115 @@
/**
* The resume half of the terminal-state floor.
*
* `workspace-terminal-host-authority.ts` says an SSH target whose sync terminated in
* `offline`/`error` without ever hydrating answers `none`, so this client may act. The seeding
* consumer is covered end to end (worktree-agent-activation-seam.test.ts); the sleeping-agent
* consumer (resume-sleeping-agent-session.ts) was only covered at the predicate. Without this,
* a failed target's agents stay unresumable for the rest of the app session and nothing fails.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { useAppStore } from '@/store'
import { makeWorktree } from '@/store/slices/store-test-helpers'
import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
const initialAppStoreState = useAppStore.getState()
const TARGET_ID = 'ssh-target-1'
const WORKTREE_ID = 'repoSsh::/srv/proj/feature'
afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
})
function seedFailedSshTarget(phase?: 'offline' | 'error' | 'pulling'): void {
const tab: TerminalTab = {
id: 'tab-1',
ptyId: null,
worktreeId: WORKTREE_ID,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
const record: SleepingAgentSessionRecord = {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: WORKTREE_ID,
agent: 'pi',
providerSession: { key: 'session_id', id: 'pi-session-1', transcriptPath: '/tmp/pi-1.jsonl' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
useAppStore.setState({
repos: [
{
id: 'repoSsh',
path: '/srv/proj',
displayName: 'repoSsh',
badgeColor: '#000',
addedAt: 0,
connectionId: TARGET_ID
}
] as never,
worktreesByRepo: {
repoSsh: [
makeWorktree({
id: WORKTREE_ID,
repoId: 'repoSsh',
path: '/srv/proj/feature',
hostId: `ssh:${TARGET_ID}`
} as never)
]
},
remoteWorkspaceHydratedTargetIds: new Set<string>(),
remoteWorkspaceSyncStatusByTargetId:
phase === undefined ? {} : { [TARGET_ID]: { phase, direction: 'pull' as const } },
tabsByWorktree: { [WORKTREE_ID]: [tab] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
})
}
describe('sleeping-agent resume on a failed SSH target', () => {
it.each(['offline', 'error'] as const)(
'resumes a sleeping agent once a sync terminates in %s without ever hydrating',
(phase) => {
seedFailedSshTarget(phase)
expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe(
'none'
)
// The gate this exists for: a target that failed must not stay unresumable for the session.
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
}
)
it('still declines to resume while the host has not answered', () => {
// Control: an in-flight sync is `unverifiable`, and resuming there forks a session the host
// may still be running. The floor must not widen into "resume whenever we are unsure".
seedFailedSshTarget('pulling')
expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe(
'unverifiable'
)
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeDefined()
})
it('still declines to resume when no sync status exists at all', () => {
seedFailedSshTarget(undefined)
expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe(
'unverifiable'
)
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0)
})
})
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status'
import {
markHostSessionMirrorHydrated,
parkUntilHostSessionMirrorHydrates,
resetHostSessionMirrorHydrationForTests
} from './host-session-mirror-hydration'
// The same fan-out hazard as host-mirror-handle-gap-drain.test.ts, one module up: settling an
// environment drains every worktree parked on it in one loop, from inside the frame apply. The
// waiters are strangers to each other and to that apply, so one replay must not be able to reach
// either of them.
const ENVIRONMENT_ID = 'env-hydration-drain'
describe('host session mirror hydration drain', () => {
afterEach(() => {
resetHostSessionMirrorHydrationForTests()
clearRuntimeEnvironmentConnectionGenerationsForTests()
})
it('settles the remaining parked worktrees when one replay throws', () => {
const secondReplay = vi.fn()
parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, 'repo::first', () => {
throw new Error('replay blew up')
})
parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, 'repo::second', secondReplay)
expect(() => markHostSessionMirrorHydrated(ENVIRONMENT_ID)).not.toThrow()
expect(secondReplay).toHaveBeenCalledTimes(1)
})
})
@@ -53,7 +53,14 @@ function drainParkedWaiters(matches: (waiter: ParkedMirrorWaiter) => boolean): v
const waiter = parkedWaitersByWorktree.get(key)
if (waiter) {
parkedWaitersByWorktree.delete(key)
waiter.run()
try {
waiter.run()
} catch (error) {
// Why: one settle drains every waiter the environment holds, and they are strangers to each
// other and to the frame apply that called it. An unguarded throw strands every waiter
// queued behind this one and surfaces in the caller applying the frame.
console.warn('[host-session-mirror-hydration] parked replay failed:', error)
}
}
}
}
@@ -38,6 +38,7 @@ import {
clearWebSessionTerminalPlacementsForEnvironment
} from '../web-session-terminal-placement'
import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration'
import { clearHostMirrorHandleGapVerdictsForEnvironment } from '@/lib/host-mirror-handle-gap-wait'
import { clearHostSessionTabIdMappings } from './tracking-mappings'
import {
sessionTabsFreshnessKey,
@@ -218,6 +219,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId)
clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId)
clearHostSessionMirrorHydration(trimmedEnvironmentId)
clearHostMirrorHandleGapVerdictsForEnvironment(trimmedEnvironmentId)
clearAllWebRuntimeWakeTerminalRespawn()
}
@@ -710,7 +710,10 @@ describe('runtime-status slice', () => {
clearRuntimeCompatibilityCacheForTests()
})
it('records null and returns false when a runtime refresh fails', async () => {
// #19647: a failed status.get dials its own fresh socket, so a refresh must not overwrite a
// recorded live verdict with null — that retires the host's session-tabs mirror and dims its
// still-live rows on a fault the client could not even ask through.
it('preserves a recorded live verdict when a refresh probe fails', async () => {
const getStatus = vi.fn().mockRejectedValue(new Error('closed'))
stubRuntimeEnvironmentApi({ getStatus })
const store = createSliceStore()
@@ -720,6 +723,14 @@ describe('runtime-status slice', () => {
const reachable = await store.getState().refreshRuntimeEnvironmentStatus('env-a')
expect(reachable).toBe(false)
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(cached)
})
it('records null on a first-contact refresh failure, so host coverage completes', async () => {
stubRuntimeEnvironmentApi({ getStatus: vi.fn().mockRejectedValue(new Error('closed')) })
const store = createSliceStore()
expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(false)
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null)
})
@@ -298,6 +298,15 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
environmentId,
timeoutMs,
(entry) => {
// A null entry with no snapshot only ever comes from a status.get that threw — an
// unverifiable transport failure, never a host-answered "gone". Publishing it would fire
// the disconnect toast and retire the host's session-tabs mirror while its established
// flows are still delivering. Keep the live verdict; the connection's status owner (#20003)
// holds the last verdict and retries until a real answer. A first-contact failure (no prior
// live status) still records null so host coverage completes. #19647
if (entry.status === null && get().runtimeStatusByEnvironmentId.get(environmentId)?.status) {
return
}
// Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null
// (reachable) status, so a recovered host's reuse-flagged refetches re-probe.
get().setRuntimeEnvironmentStatus(environmentId, entry)
@@ -0,0 +1,126 @@
import net from 'node:net'
import { decodePairingOffer, encodePairingOffer } from '../../../src/shared/pairing'
/**
* A TCP hop in front of a paired runtime's WebSocket endpoint whose fault mode can
* change mid-test.
*
* `stall-new` is the Tailscale-shaped fault this exists for: already-established
* flows keep delivering while a freshly dialed connection hangs unanswered. That
* asymmetry is what separates "the control plane could not ask" from "the runtime
* is gone", and neither killing the runtime nor `runtimeEnvironments.disconnect`
* can produce it — both take the two halves down together.
*/
export type RuntimeLinkFaultMode = 'pass' | 'stall-new'
export type RuntimeEndpointLinkFault = {
/** ws:// endpoint that routes through this hop. */
endpoint: string
setMode: (mode: RuntimeLinkFaultMode) => void
/**
* Severs every established flow once, leaving `mode` in force for what dials next.
* With `stall-new` this is the link flap that puts the shared-control socket into
* `reconnecting` while its replacement dial hangs.
*/
dropEstablished: () => number
/** Connections accepted since the last reset — proves a dial actually reached the hop. */
acceptedConnectionCount: () => number
stalledConnectionCount: () => number
resetCounters: () => void
close: () => Promise<void>
}
function parseWsEndpoint(endpoint: string): { host: string; port: number } {
const url = new URL(endpoint)
return { host: url.hostname, port: Number(url.port) }
}
export async function startRuntimeEndpointLinkFault(
upstreamEndpoint: string
): Promise<RuntimeEndpointLinkFault> {
const upstream = parseWsEndpoint(upstreamEndpoint)
let mode: RuntimeLinkFaultMode = 'pass'
let accepted = 0
let stalledTotal = 0
const stalled = new Set<net.Socket>()
const live = new Set<net.Socket>()
const server = net.createServer((client) => {
accepted += 1
live.add(client)
client.on('close', () => live.delete(client))
client.on('error', () => client.destroy())
if (mode === 'stall-new') {
// Accept the TCP handshake and answer nothing: the dialer waits out its own
// timeout, as it does on a half-open path, instead of failing fast on reset.
stalledTotal += 1
stalled.add(client)
client.on('close', () => stalled.delete(client))
return
}
const upstreamSocket = net.connect(upstream.port, upstream.host)
live.add(upstreamSocket)
upstreamSocket.on('close', () => live.delete(upstreamSocket))
upstreamSocket.on('error', () => client.destroy())
client.pipe(upstreamSocket)
upstreamSocket.pipe(client)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('runtime endpoint link fault did not bind a TCP port')
}
return {
endpoint: `ws://127.0.0.1:${address.port}`,
setMode: (next) => {
mode = next
if (next === 'pass') {
for (const socket of stalled) {
socket.destroy()
}
stalled.clear()
}
},
dropEstablished: () => {
let dropped = 0
for (const socket of live) {
if (!stalled.has(socket)) {
socket.destroy()
dropped += 1
}
}
return dropped
},
acceptedConnectionCount: () => accepted,
stalledConnectionCount: () => stalledTotal,
resetCounters: () => {
accepted = 0
stalledTotal = 0
},
close: async () => {
for (const socket of live) {
socket.destroy()
}
live.clear()
stalled.clear()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
}
/** Re-points a pairing offer at `endpoint` without touching its keys or device token. */
export function repointPairingUrl(pairingUrl: string, endpoint: string): string {
return encodePairingOffer({ ...decodePairingOffer(pairingUrl), endpoint })
}
export function readPairingEndpoint(pairingUrl: string): string {
return decodePairingOffer(pairingUrl).endpoint
}
@@ -0,0 +1,545 @@
/**
* #19647 "Orca has to reconnect every time I switch tabs".
*
* TOPOLOGY: real Orca desktop app as the remote server + a separate real Orca
* desktop client paired to it, with every client->host byte routed through a TCP
* hop whose fault mode this test controls. That hop is the reporter's Tailscale
* link; `stall-new` reproduces its characteristic failure, where established
* flows keep delivering (their agent kept working) while a freshly dialed
* connection hangs.
*
* Exploratory-first: the `[tab-switch-repro]` census and timeline lines are the
* diagnosis. The hard gate is the #19647 writer contract — a status.get the client
* could not push through must not null a recorded live verdict, and recovery must not
* advance the connection generation. Whether the pane repaints within budget is logged,
* NOT asserted: that is gated on the un-park latch (#19872) and the multiplexer
* cold-handshake (#19871), both filed separately. A green run is NOT a clean bill of health.
*
* Run:
* pnpm exec playwright test \
* tests/e2e/paired-remote-terminal-tab-switch-reconnect.spec.ts \
* --config tests/playwright.config.ts --project electron-headless --workers=1
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import {
HOST_TERMINAL_SURFACE_SEPARATOR,
toWebTerminalSurfaceTabId
} from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedElectronClient
} from './helpers/paired-electron-client'
import {
readPairingEndpoint,
repointPairingUrl,
startRuntimeEndpointLinkFault
} from './helpers/runtime-endpoint-link-fault'
import { waitForTabParked } from './helpers/terminal-hidden-parking'
const PARK_DELAY_MS = 2_000
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-tab-switch-reconnect-'))
const fixturePath = path.join(scratch, 'tab-switch-terminal.mjs')
writeFileSync(
fixturePath,
[
"import { appendFileSync } from 'node:fs'",
'const sink = process.argv[2]',
'const record = (line) => appendFileSync(sink, `${line}\\n`)',
"record('READY')",
"process.stdout.write('READY\\r\\n')",
"process.stdin.setEncoding('utf8')",
"let pending = ''",
"process.stdin.on('data', (data) => {",
' pending += data',
' const lines = pending.split(/\\r\\n|\\r|\\n/)',
" pending = lines.pop() ?? ''",
' for (const line of lines) {',
' record(`LINE:${line}`)',
' process.stdout.write(`LINE:${line}\\r\\n`)',
' }',
'})',
'process.stdin.resume()'
].join('\n')
)
test.afterAll(() => {
rmSync(scratch, { recursive: true, force: true })
})
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function fixtureCommand(sinkPath: string): string {
const command = [process.execPath, fixturePath, sinkPath]
return process.platform === 'win32'
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
: command.map(shellQuote).join(' ')
}
function readSink(sinkPath: string): string {
try {
return readFileSync(sinkPath, 'utf8')
} catch {
return ''
}
}
async function callEnvironment<TResult>(
page: Page,
environmentId: string,
method: string,
params: unknown
): Promise<TResult> {
return page.evaluate(
async ({ environmentId, method, params }) => {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method,
params
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
},
{ environmentId, method, params }
) as Promise<TResult>
}
type HostTerminal = {
hostTabId: string
sinkPath: string
terminal: string
webTabId: string
}
async function createHostTerminal(
page: Page,
environmentId: string,
worktreeId: string
): Promise<HostTerminal> {
const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`)
const result = await callEnvironment<{
tab: { id: string; terminal: string | null }
}>(page, environmentId, 'session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
command: fixtureCommand(sinkPath),
activate: false,
select: false,
navigation: 'caller'
})
if (!result.tab.terminal) {
throw new Error('host session terminal was not created')
}
const hostTabId = result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]
return {
hostTabId,
sinkPath,
terminal: result.tab.terminal,
webTabId: toWebTerminalSurfaceTabId(hostTabId)
}
}
async function waitForMirroredTab(page: Page, worktreeId: string, webTabId: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
(id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id),
worktreeId
),
{
timeout: 60_000,
message: `client never mirrored host tab ${webTabId}`
}
)
.toContain(webTabId)
}
async function selectClientTab(page: Page, worktreeId: string, webTabId: string): Promise<void> {
await page.evaluate(
({ webTabId, worktreeId }) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(worktreeId)
state?.setActiveTab(webTabId)
state?.setActiveTabType('terminal')
},
{ webTabId, worktreeId }
)
}
async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise<void> {
await waitForMirroredTab(page, worktreeId, webTabId)
await selectClientTab(page, worktreeId, webTabId)
await expect
.poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), {
timeout: 60_000,
message: `client pane for ${webTabId} did not mount`
})
.toBe(true)
}
type MultiplexCensus = {
activeStreamCount: number
transportSubscribeCount: number
transportUnsubscribeCount: number
streamSubscribeCount: number
streamUnsubscribeCount: number
}
async function readMultiplexCensus(page: Page): Promise<MultiplexCensus> {
return page.evaluate(() => {
const snapshot = (
window as Window & {
__remoteTerminalMultiplexAckGate?: {
snapshot: () => {
activeStreams: unknown[]
transportSubscribeCount: number
transportUnsubscribeCount: number
streamSubscribeCount: number
streamUnsubscribeCount: number
}
}
}
).__remoteTerminalMultiplexAckGate?.snapshot()
return {
activeStreamCount: snapshot?.activeStreams.length ?? -1,
transportSubscribeCount: snapshot?.transportSubscribeCount ?? -1,
transportUnsubscribeCount: snapshot?.transportUnsubscribeCount ?? -1,
streamSubscribeCount: snapshot?.streamSubscribeCount ?? -1,
streamUnsubscribeCount: snapshot?.streamUnsubscribeCount ?? -1
}
})
}
type PaneObservation = {
atMs: number
banner: string | null
recoveryState: string | null
mounted: boolean
nullStatusEnvironments: number
connectionGeneration: number | null
remoteControlState: string | null
}
async function observePane(
page: Page,
webTabId: string,
environmentId: string,
startedAt: number
): Promise<PaneObservation> {
const observation = await page.evaluate(
({ id, environmentId }) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const banner = document.querySelector('[data-terminal-remote-runtime-reconnect-banner]')
const statuses = window.__store?.getState().runtimeStatusByEnvironmentId
let nullStatusEnvironments = 0
for (const entry of statuses?.values() ?? []) {
if (entry.status === null) {
nullStatusEnvironments += 1
}
}
return {
banner: banner?.getAttribute('data-terminal-remote-runtime-reconnect-banner') ?? null,
recoveryState: pane?.container?.dataset?.ptyRecoveryState ?? null,
mounted: Boolean(manager),
nullStatusEnvironments,
connectionGeneration: statuses?.get(environmentId)?.connectionGeneration ?? null,
remoteControlState:
statuses?.get(environmentId)?.status?.remoteControl?.state ??
statuses?.get(environmentId)?.remoteControl?.state ??
null
}
},
{ id: webTabId, environmentId }
)
return { atMs: Date.now() - startedAt, ...observation }
}
async function readPaneContent(page: Page, webTabId: string): Promise<string> {
return page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.serializeAddon?.serialize?.() ?? ''
}, webTabId)
}
type RevealRecord = {
timeline: PaneObservation[]
paintedAtMs: number | null
sawBanner: boolean
sawNullStatus: boolean
}
/** Samples the revealed pane for `budgetMs`, stopping early once `marker` paints. */
async function recordRevealTimeline(
page: Page,
webTabId: string,
environmentId: string,
marker: string,
budgetMs: number
): Promise<RevealRecord> {
const startedAt = Date.now()
const timeline: PaneObservation[] = []
let paintedAtMs: number | null = null
let sawBanner = false
let sawNullStatus = false
let previous = ''
while (Date.now() - startedAt < budgetMs) {
const observation = await observePane(page, webTabId, environmentId, startedAt)
sawBanner ||= observation.banner !== null
sawNullStatus ||= observation.nullStatusEnvironments > 0
const key = `${observation.banner}|${observation.recoveryState}|${observation.mounted}|${observation.nullStatusEnvironments}|${observation.connectionGeneration}|${observation.remoteControlState}`
if (key !== previous) {
timeline.push(observation)
previous = key
}
if ((await readPaneContent(page, webTabId)).includes(marker)) {
paintedAtMs = Date.now() - startedAt
timeline.push(await observePane(page, webTabId, environmentId, startedAt))
break
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return { timeline, paintedAtMs, sawBanner, sawNullStatus }
}
test('paired client tab switch does not reconnect the remote runtime', async ({
orcaPage
}, testInfo) => {
test.setTimeout(900_000)
const rawOffer = await createRuntimeDesktopPairingOffer(orcaPage)
const link = await startRuntimeEndpointLinkFault(readPairingEndpoint(rawOffer.pairingUrl))
const offer = {
...rawOffer,
pairingUrl: repointPairingUrl(rawOffer.pairingUrl, link.endpoint)
}
const client = await launchPairedElectronClient(offer, testInfo, 'tab-switch-reconnect', {
extraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) }
})
const createdTerminals: string[] = []
try {
const worktreeId = await orcaPage.evaluate(() => {
const id = window.__store?.getState().activeWorktreeId
if (!id) {
throw new Error('headed host has no active worktree')
}
return id
})
await expect
.poll(
() =>
client.page.evaluate(
(id) =>
window.__store
?.getState()
.allWorktrees()
.some((worktree) => worktree.id === id) ?? false,
worktreeId
),
{
timeout: 60_000,
message: 'paired client never saw the host worktree'
}
)
.toBe(true)
const target = await createHostTerminal(client.page, client.environmentId, worktreeId)
const decoys = [
await createHostTerminal(client.page, client.environmentId, worktreeId),
await createHostTerminal(client.page, client.environmentId, worktreeId)
]
createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal))
await openClientTab(client.page, worktreeId, target.webTabId)
await expect
.poll(() => readPaneContent(client.page, target.webTabId), {
timeout: 60_000,
message: 'target terminal never painted READY'
})
.toContain('READY')
const censusAfterFirstOpen = await readMultiplexCensus(client.page)
console.log(
`[tab-switch-repro] census after first open: ${JSON.stringify(censusAfterFirstOpen)}`
)
// ---- Arm A: plain tab switching on a healthy link. No fault involved: if the
// transport subscribe count climbs here, tab switching alone tears down and
// re-establishes the environment's control channel.
for (let round = 0; round < 3; round += 1) {
await openClientTab(client.page, worktreeId, decoys[0].webTabId)
await openClientTab(client.page, worktreeId, decoys[1].webTabId)
await waitForTabParked(client.page, target.webTabId, {
parkDelayMs: PARK_DELAY_MS
})
await openClientTab(client.page, worktreeId, target.webTabId)
}
const censusAfterHealthySwitching = await readMultiplexCensus(client.page)
console.log(
`[tab-switch-repro] census after healthy switching: ${JSON.stringify(censusAfterHealthySwitching)}`
)
console.log(
`[tab-switch-repro] ARM A transportSubscribeCount delta over 3 healthy park/reveal rounds: ${censusAfterHealthySwitching.transportSubscribeCount - censusAfterFirstOpen.transportSubscribeCount} (unsubscribe delta ${censusAfterHealthySwitching.transportUnsubscribeCount - censusAfterFirstOpen.transportUnsubscribeCount})`
)
// ---- Arm B: park every terminal pane for this environment, so nothing holds
// the multiplexed control channel open, then reveal one under a link whose
// established flows are fine but whose new dials hang.
// Why not a plain view switch: cold-park exempts the single most-recently-hidden
// tab, so one pane (and its stream) keeps the multiplexer alive, and the exemption
// migrates to a parked tab the moment the exempt one closes. So: make a decoy the
// exempt one, park the rest, put the link into stall-new, and only then close that
// decoy on the host (over the established control flow). The multiplexer idles out
// and nothing can re-dial it on a healthy path before the reveal.
await openClientTab(client.page, worktreeId, decoys[1].webTabId)
await client.page.evaluate(() => {
window.__store?.getState().setActiveView('changes')
})
await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS })
await waitForTabParked(client.page, decoys[0].webTabId, { parkDelayMs: PARK_DELAY_MS })
const censusBeforeRelease = await readMultiplexCensus(client.page)
console.log(
`[tab-switch-repro] census with only the exempt decoy mounted: ${JSON.stringify(censusBeforeRelease)}`
)
link.resetCounters()
link.setMode('stall-new')
await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', {
terminal: decoys[1].terminal
})
createdTerminals.splice(createdTerminals.indexOf(decoys[1].terminal), 1)
await expect
.poll(async () => (await readMultiplexCensus(client.page)).transportUnsubscribeCount, {
timeout: 30_000,
message: 'the terminal multiplexer was never released after its last stream closed'
})
.toBeGreaterThan(censusBeforeRelease.transportUnsubscribeCount)
const censusAfterFullPark = await readMultiplexCensus(client.page)
console.log(
`[tab-switch-repro] census after every pane parked and the multiplexer released: ${JSON.stringify(censusAfterFullPark)}`
)
// Setup invariant, not a claim about the bug: the reveal below has to pay a fresh
// dial, or Arm B degrades into "reveal a tab whose transport is still up".
expect(
censusAfterFullPark.transportUnsubscribeCount,
'Arm B precondition: the terminal multiplexer must have been released before the stalled reveal'
).toBeGreaterThan(censusBeforeRelease.transportUnsubscribeCount)
await selectClientTab(client.page, worktreeId, target.webTabId)
const stalled = await recordRevealTimeline(
client.page,
target.webTabId,
client.environmentId,
'READY',
40_000
)
console.log(
`[tab-switch-repro] stalled reveal: painted=${String(stalled.paintedAtMs)} banner=${stalled.sawBanner} nullStatus=${stalled.sawNullStatus} timeline=${JSON.stringify(stalled.timeline)}`
)
console.log(
`[tab-switch-repro] dials at the hop: accepted=${link.acceptedConnectionCount()} stalled=${link.stalledConnectionCount()}`
)
// ---- Arm B2: the reporter's "reconnecting" state. Sever the established flows
// once while new dials still hang, so the shared-control socket enters
// `reconnecting` and the status recheck ladder arms. What the store does with
// the resulting failed status.get is the writer under test: a live verdict must
// not become `status: null` because the client could not ask.
const generationBeforeFlap = (
await observePane(client.page, target.webTabId, client.environmentId, 0)
).connectionGeneration
const dropped = link.dropEstablished()
const flapped = await recordRevealTimeline(
client.page,
target.webTabId,
client.environmentId,
'READY',
45_000
)
console.log(
`[tab-switch-repro] link flap under stall-new: dropped=${dropped} banner=${flapped.sawBanner} nullStatus=${flapped.sawNullStatus} timeline=${JSON.stringify(flapped.timeline)}`
)
// ---- Arm C: restore the link and measure time-to-recovery.
link.setMode('pass')
// Drive the documented recovery path. A pane whose attach timed out parks a retry that
// arms no timer (#19872): it waits for online/resume/manual Reconnect, so within a bounded
// budget the pane only comes back when one of those fires. Dispatching 'online' is exactly
// that trigger — the same one a real network-return raises — so this arm exercises genuine
// recovery, not the ~180s latch.
await client.page.evaluate(() => window.dispatchEvent(new Event('online')))
const recovered = await recordRevealTimeline(
client.page,
target.webTabId,
client.environmentId,
'READY',
30_000
)
console.log(
`[tab-switch-repro] recovery after link restored: painted=${String(recovered.paintedAtMs)} timeline=${JSON.stringify(recovered.timeline)}`
)
console.log(
`[tab-switch-repro] census after recovery: ${JSON.stringify(await readMultiplexCensus(client.page))}`
)
console.log(`[tab-switch-repro] target sink: ${readSink(target.sinkPath).slice(0, 200)}`)
const generationAfterRecovery = (
await observePane(client.page, target.webTabId, client.environmentId, 0)
).connectionGeneration
console.log(
`[tab-switch-repro] connection generation across the flap: ${String(generationBeforeFlap)} -> ${String(generationAfterRecovery)}`
)
// Writer contract (#19647), the fix under test: recovery is not a second connection, so the
// connection generation never advances. This fails on the unfixed writer.
expect(
generationAfterRecovery,
'recovery advanced the connection generation, so the session-tabs mirror was rebuilt (#19647)'
).toBe(generationBeforeFlap)
// `sawNullStatus` is DIAGNOSTIC here, not a gate. #20003 split the vocabulary: the store entry's
// `status` now means "verified on the current socket" and the owner's `snapshot.status` holds the
// last verdict, so `entry.status === null` during an outage no longer means the client declared
// the host gone. It also drives the mirror's own rebuild after the socket returns, so asserting
// it stays non-null would contradict the recovery this arm measures.
console.log(
`[tab-switch-repro] entry.status nulled during the flap (expected post-#20003): ${String(flapped.sawNullStatus)}`
)
// The fix keeps the environment revivable rather than retiring it: the pane is never disposed,
// so a later trigger can bring it back.
const finalObservation = await observePane(
client.page,
target.webTabId,
client.environmentId,
0
)
expect(
finalObservation.mounted,
'the revealed pane was disposed instead of kept revivable'
).toBe(true)
// Recovery TIMELINE is diagnostic, NOT a pass/fail gate: whether the pane actually repaints
// within budget depends on the un-park latch (#19872) and the multiplexer cold-handshake
// (#19871), both filed separately and out of scope here. A green run is not a clean bill of
// health — read the [tab-switch-repro] timeline above. This value is unchanged by this PR
// (the unfixed writer left it null too), so it is logged, not asserted.
console.log(
`[tab-switch-repro] recovery-contract observation (gated on #19872/#19871): painted=${String(recovered.paintedAtMs)}`
)
} finally {
link.setMode('pass')
for (const terminal of createdTerminals) {
await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', {
terminal
}).catch(() => undefined)
}
await client.dispose()
await link.close()
}
})
@@ -0,0 +1,124 @@
import type { Locator, Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
const ENVIRONMENT_ID = 'e2e-remote-host'
const HOST_LABEL = 'Remote Mac'
type RecordedState = 'no-entry' | 'probed-unreachable' | 'probed-reachable'
/** Put the seeded worktree's repo on a runtime host and record one status verdict for it. */
async function seedRuntimeHost(page: Page, recorded: RecordedState): Promise<string> {
return page.evaluate(
({ environmentId, hostLabel, recorded }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const worktree = Object.values(state.worktreesByRepo).flat()[0]
if (!worktree) {
throw new Error('no seeded worktree to place on a runtime host')
}
state.setRuntimeEnvironments([
{
id: environmentId,
name: hostLabel,
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: 'e2e-runtime',
endpoints: [
{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://127.0.0.1:1' }
],
preferredEndpointId: 'ws'
}
])
store.setState({
repos: state.repos.map((repo) =>
repo.id === worktree.repoId
? { ...repo, connectionId: undefined, executionHostId: `runtime:${environmentId}` }
: repo
)
})
if (recorded === 'probed-unreachable') {
state.setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() })
}
if (recorded === 'probed-reachable') {
state.setRuntimeEnvironmentStatus(environmentId, {
status: {
runtimeId: 'e2e-runtime',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
desktopWindowStatus: 'available',
liveTabCount: 0,
liveLeafCount: 0
},
checkedAt: Date.now()
})
}
return worktree.id
},
{ environmentId: ENVIRONMENT_ID, hostLabel: HOST_LABEL, recorded }
)
}
async function captureCard(card: Locator, testInfo: TestInfo, name: string): Promise<void> {
const shot = testInfo.outputPath(name)
await card.screenshot({ path: shot, animations: 'disabled' })
await testInfo.attach(name, { path: shot, contentType: 'image/png' })
}
test.describe('sidebar runtime host glyph', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
// Why: an absent entry means "not probed yet". Painting it destructive made every
// remote card red and dimmed between launch and the first probe answering.
test('does not call a runtime host disconnected before its first probe answers', async ({
orcaPage
}, testInfo) => {
await seedRuntimeHost(orcaPage, 'no-entry')
const card = orcaPage.locator(`[data-worktree-card-surface="true"]`).first()
await expect(card).toBeVisible()
// Captured before the assertions so a regression run still yields the evidence image.
await captureCard(card, testInfo, 'runtime-host-glyph-before-probe.png')
await expect(card.locator('svg.lucide-server')).toBeVisible()
await expect(card.locator('svg.lucide-server-off')).toHaveCount(0)
await expect(card).not.toHaveClass(/opacity-60/)
})
// The deliberate no-change case: a probe actually reported this host unreachable.
test('still marks a runtime host disconnected once a probe finds it unreachable', async ({
orcaPage
}, testInfo) => {
await seedRuntimeHost(orcaPage, 'probed-unreachable')
const card = orcaPage.locator(`[data-worktree-card-surface="true"]`).first()
await expect(card).toBeVisible()
await captureCard(card, testInfo, 'runtime-host-glyph-disconnected.png')
await expect(card.locator('svg.lucide-server-off')).toBeVisible()
await expect(card).toHaveClass(/opacity-60/)
})
test('clears the disconnected glyph when a later probe reaches the host', async ({
orcaPage
}, testInfo) => {
await seedRuntimeHost(orcaPage, 'probed-unreachable')
const card = orcaPage.locator(`[data-worktree-card-surface="true"]`).first()
await expect(card.locator('svg.lucide-server-off')).toBeVisible()
await seedRuntimeHost(orcaPage, 'probed-reachable')
await captureCard(card, testInfo, 'runtime-host-glyph-recovered.png')
await expect(card.locator('svg.lucide-server')).toBeVisible()
await expect(card.locator('svg.lucide-server-off')).toHaveCount(0)
await expect(card).not.toHaveClass(/opacity-60/)
})
})