Keep remote agent status in sync after completion and approvals (#34729)

* fix(remote): reconcile stale paired agent status and pane titles

Adapt the remote completion repair from #25205 while preserving sparse runtime pane slots through existing PTY bindings.

Co-authored-by: Manan Chataut <mananchataut@gmail.com>

* fix(remote): preserve pane identity when reconciling moved titles

Use mounted manager identities and retained PTY bindings ahead of replay order; keep fresh byte-stream ownership on the correct moved pane.

* fix(remote): release byte status fence after host approval

* fix(remote): tighten paired title removal and test the timing fence

- Share the byte-write timing decision (resolveRendererOwnedAgentStatusTiming) between pty-connection and the flap test so reverting it fails a test.
- Only delete a saved pane title when its durable leaf is absent from both the host layout and local panes; unresolved or surface-less slots are kept.
- Share the client-fence predicate (clientAgentStatusFencesHost) between status and title reconciliation.
- Cover removed-leaf spinner fallback in worktree status and a client split absent from a pre-split host snapshot.

* fix(remote): correct fence predicate and title test types

---------

Co-authored-by: Manan Chataut <mananchataut@gmail.com>
This commit is contained in:
Neil
2026-10-28 01:19:22 -07:00
committed by GitHub
co-authored by Manan Chataut
parent c665754a8e
commit 96cd21bbc4
14 changed files with 975 additions and 16 deletions
@@ -26,7 +26,8 @@ import { isMainTerminalSideEffectAuthorityForPty } from '../terminal-side-effect
import { isRendererHiddenPtyDeliveryGateEnabled } from '../terminal-hidden-delivery-gate'
import {
markRendererOwnedAgentStatusWrite,
registerRendererOwnedAgentStatusPane
registerRendererOwnedAgentStatusPane,
resolveRendererOwnedAgentStatusTiming
} from '../renderer-owned-agent-status-registry'
import { resolveLatestAgentDoneStartedAt } from './agent-done-started-at'
@@ -138,12 +139,15 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession):
: resolvedStatusTitle
// Only actual byte-derived status proves authority to fence out host mirroring.
markRendererOwnedAgentStatusWrite(session.cacheKey)
const timing = resolveRendererOwnedAgentStatusTiming(
currentState.agentStatusByPaneKey[session.cacheKey]
)
if (session.launchToken) {
currentState.setAgentStatus(
session.cacheKey,
observedStatusPayload,
statusTitle,
undefined,
timing,
routing,
{
launchToken: session.launchToken
@@ -154,7 +158,7 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession):
session.cacheKey,
observedStatusPayload,
statusTitle,
undefined,
timing,
routing
)
}
@@ -1,3 +1,5 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
/** Fence host mirroring only after renderer byte-derived status proves its claim; otherwise two writers flap the same pane row between working/generated titles and host defaults. */
type RendererOwnedAgentStatusPane = {
environmentId: string
@@ -39,6 +41,15 @@ export function isClientAuthoritativeAgentStatusPane(paneKey: string): boolean {
return panesByPaneKey.get(paneKey)?.hasClientWrite === true
}
/** A byte write replacing an adopted host row cannot be ordered against the host's clock. */
export function resolveRendererOwnedAgentStatusTiming(
existing: Pick<AgentStatusEntry, 'mirroredEvidenceReceivedAt'> | undefined
): { allowOlderTimestamp: true } | undefined {
return existing?.mirroredEvidenceReceivedAt !== undefined
? { allowOlderTimestamp: true }
: undefined
}
export function _getRendererOwnedAgentStatusPaneCountForTest(): number {
return panesByPaneKey.size
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { getWorktreeStatus } from './worktree-status'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_C = '33333333-3333-4333-8333-333333333333'
describe('live numeric slots after closing an earlier split pane', () => {
it('keeps a working background pane whose runtime id exceeds the remaining leaf count', () => {
expect(
getWorktreeStatus(
[{ id: 'tab', title: 'zsh', launchAgent: 'codex' }],
[],
{ tab: ['pty-a', 'pty-c'] },
{ tab: { 1: 'zsh', 3: '⠋ Codex' } },
{
terminalLayoutRootsByTabId: {
tab: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_A },
second: { type: 'leaf', leafId: LEAF_C }
}
}
}
)
).toBe('working')
})
})
describe('pane titles from removed panes', () => {
it('ignores a removed durable leaf spinner and falls back to the idle tab title', () => {
const removedLeafTitles = { [LEAF_C]: '⠋ Codex' }
expect(
getWorktreeStatus(
[{ id: 'tab', title: 'zsh', launchAgent: 'codex' }],
[],
{ tab: ['pty-a'] },
{ tab: removedLeafTitles },
{ terminalLayoutRootsByTabId: { tab: { type: 'leaf', leafId: LEAF_A } } }
)
).not.toBe('working')
})
})
+15 -2
View File
@@ -1,7 +1,11 @@
import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows'
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '../../../shared/tab-has-live-pty'
import { resolveRuntimePaneTitleLeafIdFromRoot } from '../../../shared/runtime-pane-title-leaf-id'
import {
collectRuntimePaneLeafIds,
isRuntimePaneTitleOrphan,
resolveRuntimePaneTitleLeafIdFromRoot
} from '../../../shared/runtime-pane-title-leaf-id'
import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core'
import { isSyntheticAgentPermissionTitle } from '../../../shared/synthetic-agent-title'
import type {
@@ -81,12 +85,19 @@ function tabHasStatus(
const tabLayoutRoot =
options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root
const paneTitleEntries = Object.entries(paneTitles)
const layoutLeafIds = tabLayoutRoot ? new Set(collectRuntimePaneLeafIds(tabLayoutRoot)) : null
let hasCurrentPaneTitle = false
for (const [runtimePaneId, title] of paneTitleEntries) {
const agentStatusPaneIds =
status === 'permission' && isSyntheticAgentPermissionTitle(title)
? permissionPaneIds
: freshPaneIds
const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId)
// A removed pane's last title is no longer evidence for this tab.
if (layoutLeafIds && isRuntimePaneTitleOrphan(runtimePaneId, layoutLeafIds)) {
continue
}
hasCurrentPaneTitle = true
// Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner.
const hasSingleUnmappedAgentStatusPane =
leafId === null && agentStatusPaneIds?.size === 1 && paneTitleEntries.length === 1
@@ -104,7 +115,9 @@ function tabHasStatus(
return true
}
}
return false
if (hasCurrentPaneTitle) {
return false
}
}
// Why: a tab title can't identify its pane; once an agent row owns one, prefer the row over a completed pane's stale "working" title.
const agentStatusPaneIds =
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type * as WebRuntimeTerminalCreationModule from './web-runtime-terminal-creation'
import type * as WebSessionTerminalHandleEventsModule from './web-session-terminal-handle-events'
vi.mock('./web-session-terminal-handle-events', async (importOriginal) => {
const actual = await importOriginal<typeof WebSessionTerminalHandleEventsModule>()
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
return {
...actual,
queueAcceptedWebSessionTerminalSnapshot: frameOrderingMocks.queueAcceptedSnapshot
}
})
vi.mock('./use-runtime-session-mirror-environment-key', async () => {
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
return {
useRuntimeSessionMirrorEnvironmentKeys: () => ({
environmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey(),
resubscribeSignal: ''
})
}
})
vi.mock('./web-session-terminal-orphan-recovery', async () => {
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
return { recoverWebSessionTerminalOrphansBeforeApply: frameOrderingMocks.recoverSnapshot }
})
vi.mock('./web-runtime-terminal-creation', async (importOriginal) => {
const actual = await importOriginal<typeof WebRuntimeTerminalCreationModule>()
const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures')
return { ...actual, createWebRuntimeSessionTerminal: frameOrderingMocks.createTerminal }
})
import { useAppStore } from '@/store'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { resolveTerminalTabActivityStatus } from '../components/tab-bar/terminal-tab-activity-status'
import {
BG_MIRROR_TAB_ID,
BG_WT,
HOST_PARENT_TAB_ID,
HOST_SURFACE_ID,
LEAF_ID,
MIRROR_TAB_ID,
makeHostSnapshot,
WT
} from './host-session-mirror-frame-fixtures'
import {
findSubscription,
installFrameOrderingHarness,
publish,
settle,
setDocumentVisibility
} from './host-session-mirror-frame-ordering-test-harness'
import { useWebSessionTabsSync } from './web-session-tabs-sync/use-web-session-tabs-sync'
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking'
const NOW = 1_000_000
function agentSnapshot(
worktree: string,
parentTabId: string,
surfaceId: string,
state: 'working' | 'done'
) {
const snapshot = makeHostSnapshot(worktree, surfaceId, parentTabId)
const tab = snapshot.tabs[0]
if (tab?.type !== 'terminal') {
throw new Error('Missing terminal fixture')
}
tab.title = state === 'done' ? 'Pi ready' : '⠋ Pi'
tab.launchAgent = 'pi'
tab.agentStatus = {
paneKey: makePaneKey(parentTabId, LEAF_ID),
state,
agentType: 'pi',
worktreeId: worktree,
updatedAt: NOW,
stateStartedAt: NOW,
stateHistory: [],
prompt: ''
}
snapshot.snapshotVersion = state === 'done' ? 2 : 1
return snapshot
}
function tabActivity(tabId: string, worktreeId: string) {
const state = useAppStore.getState()
const tab = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
if (!tab) {
throw new Error('Missing mirrored terminal')
}
return resolveTerminalTabActivityStatus({
tab,
agentStatusByPaneKey: state.agentStatusByPaneKey,
agentStatusEpoch: state.agentStatusEpoch,
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
ptyIdsByTabId: state.ptyIdsByTabId,
terminalLayout: state.terminalLayoutsByTabId[tabId]
})
}
describe('remote completion after the client window is hidden', () => {
installFrameOrderingHarness({ fakeTimers: true })
it('resubscribes immediately on reveal and repairs completion in active and inactive workspaces', async () => {
vi.setSystemTime(NOW)
renderHook(() => useWebSessionTabsSync())
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll'), {
type: 'snapshots',
authoritative: true,
snapshots: [
agentSnapshot(WT, HOST_PARENT_TAB_ID, HOST_SURFACE_ID, 'working'),
agentSnapshot(BG_WT, 'host-tab-2', `host-tab-2::${LEAF_ID}`, 'working')
]
})
useAppStore.setState({
runtimePaneTitlesByTabId: {
[MIRROR_TAB_ID]: { 1: '⠋ Pi' },
[BG_MIRROR_TAB_ID]: { 1: '⠋ Pi' }
}
})
expect(tabActivity(MIRROR_TAB_ID, WT)).toBe('working')
expect(tabActivity(BG_MIRROR_TAB_ID, BG_WT)).toBe('working')
act(() => {
setDocumentVisibility('hidden')
vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS)
setDocumentVisibility('visible')
})
await act(settle)
await publish(findSubscription('session.tabs.subscribeAll', 1), {
type: 'snapshots',
authoritative: true,
snapshots: [
agentSnapshot(WT, HOST_PARENT_TAB_ID, HOST_SURFACE_ID, 'done'),
agentSnapshot(BG_WT, 'host-tab-2', `host-tab-2::${LEAF_ID}`, 'done')
]
})
expect(tabActivity(MIRROR_TAB_ID, WT)).toBe('done')
expect(tabActivity(BG_MIRROR_TAB_ID, BG_WT)).toBe('done')
expect(useAppStore.getState().runtimePaneTitlesByTabId).toMatchObject({
[MIRROR_TAB_ID]: { 1: 'Pi ready' },
[BG_MIRROR_TAB_ID]: { 1: 'Pi ready' }
})
})
})
@@ -15,7 +15,8 @@ import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-
import {
markRendererOwnedAgentStatusWrite,
registerRendererOwnedAgentStatusPane,
resetRendererOwnedAgentStatusPanesForTests
resetRendererOwnedAgentStatusPanesForTests,
resolveRendererOwnedAgentStatusTiming
} from '../components/terminal-pane/renderer-owned-agent-status-registry'
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle'
@@ -105,8 +106,7 @@ function applyHostSnapshot(
store.setState(patch as Partial<AppState>)
}
/** Replay paired pty-connection OSC handling with client Date.now(); return ownership release held until dispose. */
/** Returns the pane's release, as pty-connection holds it for dispose(). */
/** Replay paired pty-connection OSC handling with client Date.now(); returns the release pty-connection holds for dispose(). */
function replayClientOscWorking(store: TestStore, clientNow: number): () => void {
vi.setSystemTime(clientNow)
// Claim on transport creation, then verify ownership on each byte-derived status.
@@ -118,7 +118,7 @@ function replayClientOscWorking(store: TestStore, clientNow: number): () => void
MIRROR_PANE_KEY,
{ state: 'working', prompt: CLIENT_PROMPT, agentType: 'claude' },
'claude',
undefined,
resolveRendererOwnedAgentStatusTiming(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]),
{ tabId: MIRROR_TAB_ID, worktreeId: WT }
)
return release
@@ -432,6 +432,118 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status
expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?')
})
it('follows behind-clock host continuation and completion after an HTTP approval', () => {
const store = seedPairedClientStore()
replayClientOscWorking(store, T0 + 500)
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 2,
hostNow: T0 - 60_000,
includeAgentStatus: true,
agentStatusOverrides: {
state: 'waiting',
interactivePrompt: 'Allow exec_command?'
}
}),
T0 + 1_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({
state: 'waiting',
mirroredEvidenceReceivedAt: T0 + 1_000
})
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 3,
hostNow: T0 - 59_000,
includeAgentStatus: true,
agentStatusOverrides: { state: 'working' }
}),
T0 + 2_000
)
const continued = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]
expect(continued?.state).toBe('working')
expect(continued?.interactivePrompt).toBeUndefined()
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 4,
hostNow: T0 - 58_000,
includeAgentStatus: true
}),
T0 + 3_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('done')
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 5,
hostNow: T0 - 57_000,
includeAgentStatus: false
}),
T0 + 4_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined()
})
it('reclaims byte ownership after approval despite an ahead-clock host', () => {
const store = seedPairedClientStore()
replayClientOscWorking(store, T0 + 500)
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 2,
hostNow: T0 + 60_000,
includeAgentStatus: true,
agentStatusOverrides: {
state: 'waiting',
interactivePrompt: 'Allow exec_command?'
}
}),
T0 + 1_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('waiting')
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 3,
hostNow: T0 + 61_000,
includeAgentStatus: true,
agentStatusOverrides: { state: 'working' }
}),
T0 + 1_250
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working')
replayClientOscWorking(store, T0 + 1_500)
const byteEntry = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]
expect(byteEntry?.state).toBe('working')
expect(byteEntry?.mirroredEvidenceReceivedAt).toBeUndefined()
expect(byteEntry?.interactivePrompt).toBeUndefined()
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 4,
hostNow: T0 + 120_000,
includeAgentStatus: true
}),
T0 + 2_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({
state: 'working',
updatedAt: T0 + 1_500,
prompt: CLIENT_PROMPT
})
})
it('keeps live client status over a restored, unconfirmed host block', () => {
const store = seedPairedClientStore()
replayClientOscWorking(store, T0 + 500)
@@ -0,0 +1,434 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTerminalClientTab } from '../../../shared/runtime-types'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { resolveTerminalTabActivityStatus } from '../components/tab-bar/terminal-tab-activity-status'
import {
markRendererOwnedAgentStatusWrite,
registerRendererOwnedAgentStatusPane,
resetRendererOwnedAgentStatusPanesForTests
} from '../components/terminal-pane/renderer-owned-agent-status-registry'
import {
applyWebSessionTabsSnapshot,
applyWebSessionTabsSnapshots
} from './web-session-tabs-sync/snapshot-api'
import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { registerRuntimeTerminalTab } from './mounted-terminal-tab-registry'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import {
ENV,
HOST_SURFACE_ID,
LEAF_ID,
NOW,
SECOND_LEAF_ID,
THIRD_LEAF_ID,
WT,
makeSnapshot,
makeState,
resetWebSessionTabsSyncTestState
} from './web-session-tabs-sync-test-harness'
vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } }))
const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1')
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
function hostPane(state: 'done' | 'working' = 'done'): RuntimeMobileSessionTerminalClientTab & {
status: 'ready'
terminal: string
} {
return {
type: 'terminal',
id: HOST_SURFACE_ID,
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
title: state === 'done' ? 'Pi ready' : '⠋ Pi',
launchAgent: 'pi',
isActive: true,
status: 'ready',
terminal: 'terminal-1',
agentStatus: {
state,
agentType: 'pi',
paneKey: makePaneKey('host-tab-1', LEAF_ID),
worktreeId: WT,
prompt: '',
updatedAt: state === 'working' ? NOW - 1_000 : NOW,
stateStartedAt: state === 'working' ? NOW - 1_000 : NOW,
stateHistory: []
}
}
}
function restoredState(): WebSessionTabsSyncState & {
runtimePaneTitlesByTabId: NonNullable<WebSessionTabsSyncState['runtimePaneTitlesByTabId']>
} {
const initial = makeState()
const state = {
...initial,
...applyWebSessionTabsSnapshot(initial, makeSnapshot([hostPane('working')]), ENV, NOW)
}
const row = state.agentStatusByPaneKey[PANE_KEY]
if (!row) {
throw new Error('Missing mirrored status')
}
return {
...state,
activeWorktreeId: 'another-workspace',
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - saved session' } },
agentStatusByPaneKey: {
[PANE_KEY]: { ...row, mirroredEvidenceReceivedAt: NOW - 55 * 60 * 60 * 1000 }
}
}
}
beforeEach(() => {
resetWebSessionTabsSyncTestState()
resetRendererOwnedAgentStatusPanesForTests()
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
resetRendererOwnedAgentStatusPanesForTests()
vi.useRealTimers()
})
describe('remote completion repairs saved spinner titles', () => {
it('refreshes numeric runtime slots in an inactive workspace and removes the false spinner', () => {
const state = restoredState()
const updated = {
...state,
...applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW
)
}
expect(updated.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: 'Pi ready' })
expect(state.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: '⠋ Pi - saved session' })
expect(updated.agentStatusByPaneKey[PANE_KEY]?.state).toBe('done')
const tab = updated.tabsByWorktree[WT]?.[0]
if (!tab) {
throw new Error('Missing mirrored tab')
}
expect(
resolveTerminalTabActivityStatus({
tab,
runtimePaneTitlesByTabId: updated.runtimePaneTitlesByTabId,
agentStatusByPaneKey: updated.agentStatusByPaneKey,
agentStatusEpoch: updated.agentStatusEpoch,
ptyIdsByTabId: updated.ptyIdsByTabId,
terminalLayout: updated.terminalLayoutsByTabId[TAB_ID]
})
).toBe('done')
})
it('drops absent durable leaves while refreshing a sparse numeric slot', () => {
const state = restoredState()
const orphanTitles = { 2: '⠋ Pi', [SECOND_LEAF_ID]: '⠋ Pi' }
state.runtimePaneTitlesByTabId[TAB_ID] = orphanTitles
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 2: 'Pi ready' })
})
it('accepts host done over an expired row even when its clock is behind the client row', () => {
const state = restoredState()
const row = state.agentStatusByPaneKey[PANE_KEY]
if (!row) {
throw new Error('Missing mirrored status')
}
state.agentStatusByPaneKey[PANE_KEY] = { ...row, updatedAt: NOW + 60_000 }
registerRendererOwnedAgentStatusPane(PANE_KEY, ENV)
markRendererOwnedAgentStatusWrite(PANE_KEY)
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.agentStatusByPaneKey?.[PANE_KEY]).toMatchObject({ state: 'done', updatedAt: NOW })
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready' })
})
it('keeps a fresh byte-stream writer and its title during a lagging host publication', () => {
const state = restoredState()
const row = state.agentStatusByPaneKey[PANE_KEY]
if (!row) {
throw new Error('Missing mirrored status')
}
state.agentStatusByPaneKey[PANE_KEY] = {
...row,
updatedAt: NOW,
mirroredEvidenceReceivedAt: undefined
}
registerRendererOwnedAgentStatusPane(PANE_KEY, ENV)
markRendererOwnedAgentStatusWrite(PANE_KEY)
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
const nextStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey
expect(nextStatuses[PANE_KEY]?.state).toBe('working')
})
it('preserves title repairs across a batched all-workspace inventory', () => {
const state = restoredState()
const patch = applyWebSessionTabsSnapshots(
state,
[
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
makeSnapshot([], { worktree: 'another-workspace' })
],
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready' })
expect(patch.agentStatusByPaneKey?.[PANE_KEY]?.state).toBe('done')
})
it('keeps a genuinely working sibling spinning when another split pane completes', () => {
const state = restoredState()
const sibling = {
...hostPane('working'),
id: `host-tab-1::${SECOND_LEAF_ID}`,
leafId: SECOND_LEAF_ID,
isActive: false,
terminal: 'terminal-2'
}
state.runtimePaneTitlesByTabId[TAB_ID] = { 1: '⠋ Pi', 2: '⠋ Pi' }
const updated = {
...state,
...applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }),
ENV,
NOW
)
}
const tab = updated.tabsByWorktree[WT]?.[0]
if (!tab) {
throw new Error('Missing mirrored tab')
}
expect(updated.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: 'Pi ready', 2: '⠋ Pi' })
expect(
resolveTerminalTabActivityStatus({
tab,
agentStatusByPaneKey: updated.agentStatusByPaneKey,
agentStatusEpoch: updated.agentStatusEpoch,
runtimePaneTitlesByTabId: updated.runtimePaneTitlesByTabId,
ptyIdsByTabId: updated.ptyIdsByTabId,
terminalLayout: updated.terminalLayoutsByTabId[TAB_ID]
})
).toBe('working')
})
it('refreshes sparse runtime slots through their retained PTY bindings after a pane closes', () => {
const sibling = {
...hostPane('working'),
id: `host-tab-1::${SECOND_LEAF_ID}`,
leafId: SECOND_LEAF_ID,
isActive: false,
terminal: 'terminal-2'
}
const initial = makeState()
const state = {
...initial,
...applyWebSessionTabsSnapshot(
initial,
makeSnapshot([hostPane('working'), sibling]),
ENV,
NOW
),
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - first', 3: '⠋ Pi - sibling' } }
}
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready', 3: '⠋ Pi' })
})
it.each([false, true])(
'keeps moved pane titles attached to their PTY with a fresh writer: %s',
(freshFirstWriter) => {
const sibling = {
...hostPane(),
id: `host-tab-1::${SECOND_LEAF_ID}`,
leafId: SECOND_LEAF_ID,
isActive: false,
terminal: 'terminal-2',
title: 'Pi second ready'
}
const initial = makeState()
const mirrored = {
...initial,
...applyWebSessionTabsSnapshot(
initial,
makeSnapshot([hostPane('working'), sibling]),
ENV,
NOW
)
}
const layout: TerminalLayoutSnapshot = {
...mirrored.terminalLayoutsByTabId[TAB_ID],
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: SECOND_LEAF_ID },
second: { type: 'leaf', leafId: LEAF_ID }
}
}
const state = {
...mirrored,
terminalLayoutsByTabId: { [TAB_ID]: layout },
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - first', 2: '⠋ Pi - second' } }
}
if (freshFirstWriter) {
const row = state.agentStatusByPaneKey[PANE_KEY]
if (!row) {
throw new Error('Missing mirrored status')
}
state.agentStatusByPaneKey[PANE_KEY] = {
...row,
mirroredEvidenceReceivedAt: undefined
}
registerRendererOwnedAgentStatusPane(PANE_KEY, ENV)
markRendererOwnedAgentStatusWrite(PANE_KEY)
}
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({
1: freshFirstWriter ? '⠋ Pi - first' : 'Pi ready',
2: 'Pi second ready'
})
}
)
it('retains an unmapped numeric title until its pane ownership is known', () => {
const state = restoredState()
state.runtimePaneTitlesByTabId[TAB_ID] = { 3: '⠋ Pi', 7: '⠋ Pi' }
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
expect(state.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 3: '⠋ Pi', 7: '⠋ Pi' })
})
it('keeps titles of a client split the pre-split host snapshot does not have yet', () => {
const leafIdsBySlot = new Map([
[1, LEAF_ID],
[2, THIRD_LEAF_ID]
])
const manager = {
getLeafId: (paneId: number) => leafIdsBySlot.get(paneId) ?? null,
getNumericIdForLeaf: (leafId: string) =>
[...leafIdsBySlot].find(([, id]) => id === leafId)?.[0] ?? null
}
const unregister = registerRuntimeTerminalTab({
tabId: TAB_ID,
worktreeId: WT,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the title patch reads only getLeafId/getNumericIdForLeaf.
getManager: () => manager as unknown as PaneManager,
getContainer: () => null,
getPtyIdForPane: () => null,
getTabWideAgentHintLeafId: () => null
})
try {
const state = restoredState()
const clientSplitTitles = {
1: '⠋ Pi - saved session',
2: '⠋ Pi',
[THIRD_LEAF_ID]: '⠋ Pi'
}
state.runtimePaneTitlesByTabId[TAB_ID] = clientSplitTitles
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({
1: 'Pi ready',
2: '⠋ Pi',
[THIRD_LEAF_ID]: '⠋ Pi'
})
} finally {
unregister()
}
})
it('does not interpret a pending host placeholder as new title evidence', () => {
const state = restoredState()
const pending = {
...hostPane(),
title: 'Terminal',
status: 'pending-handle' as const,
terminal: null,
agentStatus: undefined
}
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([pending], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
})
it('does not rewrite local terminal runtime titles from a local session mirror', () => {
const state = restoredState()
const patch = applyWebSessionTabsSnapshot(
state,
makeSnapshot([hostPane()], { snapshotVersion: 2 }),
ENV,
NOW,
{ terminalPtyMode: 'local' }
)
expect(patch.runtimePaneTitlesByTabId).toBeUndefined()
})
it('ignores orphan titles before host sync and falls back to the current tab title', () => {
const state = restoredState()
const tab = state.tabsByWorktree[WT]?.[0]
if (!tab) {
throw new Error('Missing mirrored tab')
}
const orphanTitles = { [SECOND_LEAF_ID]: '⠋ Pi' }
const readyOrphanTitles = { [SECOND_LEAF_ID]: 'Pi ready' }
expect(
resolveTerminalTabActivityStatus({
tab: { ...tab, title: 'Pi ready' },
runtimePaneTitlesByTabId: { [TAB_ID]: orphanTitles },
ptyIdsByTabId: state.ptyIdsByTabId,
terminalLayout: state.terminalLayoutsByTabId[TAB_ID]
})
).toBe('active')
expect(
resolveTerminalTabActivityStatus({
tab: { ...tab, title: '⠋ Pi' },
runtimePaneTitlesByTabId: { [TAB_ID]: readyOrphanTitles },
ptyIdsByTabId: state.ptyIdsByTabId,
terminalLayout: state.terminalLayoutsByTabId[TAB_ID]
})
).toBe('working')
})
})
@@ -17,7 +17,7 @@ import type {
import {
isClientOwnedAgentStatus,
isMirroredAgentStatusOwnedBy,
isFencedClientAgentStatus,
clientAgentStatusFencesHost,
hostAgentStatusPiercesClientAuthority,
isMirroredAgentPaneKeyForTabs,
batchAgentPaneKeysForTabs,
@@ -93,11 +93,14 @@ export function buildMirroredAgentStatusPatch(
existing.state !== 'done' &&
existing.stateStartedAt > entry.stateStartedAt
// Cross-machine clocks cannot arbitrate live byte ownership; keep client state except host-only classes, adopting host identity below.
const clientOwnsEntry =
isFencedClientAgentStatus(entry.paneKey, existing, now) &&
!hostAgentStatusPiercesClientAuthority(entry)
const hostPiercesAuthority = hostAgentStatusPiercesClientAuthority(entry)
const clientOwnsEntry = clientAgentStatusFencesHost(entry.paneKey, existing, entry, now)
const nextEntry =
existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt)
existing &&
(clientOwnsEntry ||
(!hostPiercesAuthority &&
isAgentStatusFresh(existing, now) &&
existing.updatedAt > entry.updatedAt))
? {
...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType),
...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working'
@@ -72,12 +72,16 @@ export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry):
)
}
/** Byte-derived ownership requires transport claim plus a write; teardown releases it back to host. */
/** Only the current byte-derived row can fence host snapshots; adopting a host row yields authority. */
export function isClientOwnedAgentStatus(
paneKey: string,
existing: AgentStatusEntry | undefined
): existing is AgentStatusEntry {
return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey)
return (
existing !== undefined &&
existing.mirroredEvidenceReceivedAt === undefined &&
isClientAuthoritativeAgentStatusPane(paneKey)
)
}
/** When host and client compete, stale client status yields to host; ownership alone still protects against host omission. */
@@ -89,6 +93,19 @@ export function isFencedClientAgentStatus(
return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now)
}
/** A fresh client byte writer keeps the pane unless the host carries a host-only state class. */
export function clientAgentStatusFencesHost(
paneKey: string,
existing: AgentStatusEntry | undefined,
hostEntry: AgentStatusEntry | null | undefined,
now: number
): boolean {
return (
isFencedClientAgentStatus(paneKey, existing, now) &&
!(hostEntry && hostAgentStatusPiercesClientAuthority(hostEntry))
)
}
export function batchAgentPaneKeysForTabs(
state: WebSessionTabsSyncState,
tabIds: ReadonlySet<string>,
@@ -2,6 +2,7 @@ import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership'
import type { WebSessionTabsSyncState } from './state'
import type { applyActiveStateUpdates } from './apply-active-state'
import { buildMirroredAgentStatusPatch } from './agent-status-patch'
import { buildMirroredRuntimePaneTitlePatch } from './runtime-pane-title-patch'
import {
buildRemirroredClosedTabMarkerLiftPatch,
buildRetractedMirroredTabSweepPatch
@@ -65,6 +66,16 @@ export function buildWebSessionTabsFinalPatch(
worktreeId,
batchContext
)
const runtimePaneTitlePatch =
context.options?.terminalPtyMode === 'local'
? null
: buildMirroredRuntimePaneTitlePatch(
state,
mirroredTerminalTabs,
terminalSurfaceTabs,
now,
batchContext
)
const agentStatusPatch = buildMirroredAgentStatusPatch(
state,
currentTerminalTabs,
@@ -107,6 +118,7 @@ export function buildWebSessionTabsFinalPatch(
)
const patch: Partial<WebSessionTabsSyncState> = {
...runtimePaneTitlePatch,
...agentStatusPatch,
...retractedTabSweepPatch,
...remirroredClosedTabLiftPatch,
@@ -0,0 +1,138 @@
import {
collectRuntimePaneLeafIds,
isRuntimePaneTitleOrphan,
resolveRuntimePaneTitleLeafId,
resolveRuntimePaneTitleLeafIdFromSparseSlots
} from '../../../../shared/runtime-pane-title-leaf-id'
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner'
import { findRegisteredTerminalTab } from '../mounted-terminal-tab-registry'
import { clientAgentStatusFencesHost } from './agent-status-primitives'
import { writableWebSessionTabsRecord } from './state-equality-core'
import type {
MirroredTerminalTab,
TerminalSurface,
WebSessionTabsBatchContext,
WebSessionTabsSyncState
} from './state'
/** Reconcile saved pane titles with the accepted host inventory, including inactive tabs. */
export function buildMirroredRuntimePaneTitlePatch(
state: WebSessionTabsSyncState,
tabs: readonly MirroredTerminalTab[],
surfaces: readonly TerminalSurface[],
now: number,
batchContext?: WebSessionTabsBatchContext
): Partial<WebSessionTabsSyncState> | null {
const titles = state.runtimePaneTitlesByTabId
if (!titles) {
return null
}
const titledTabs = tabs.flatMap((entry) => {
const paneTitles = titles[entry.tab.id]
return paneTitles ? [{ ...entry, paneTitles }] : []
})
if (titledTabs.length === 0) {
return null
}
const surfacesByTab = new Map<string, Map<string, TerminalSurface>>()
for (const surface of surfaces) {
const byLeaf = surfacesByTab.get(surface.parentTabId) ?? new Map<string, TerminalSurface>()
byLeaf.set(surface.leafId, surface)
surfacesByTab.set(surface.parentTabId, byLeaf)
}
let nextTitles = titles
for (const { tab, hostTabId, layout, retainedSurfaceByPrunedLeafId, paneTitles } of titledTabs) {
const leaves = new Set(collectRuntimePaneLeafIds(layout.root))
const liveSlotIds = Object.keys(paneTitles)
.map(Number)
.filter((id) => Number.isInteger(id) && id >= FIRST_PANE_ID)
.sort((a, b) => a - b)
const liveSlotsAreDense =
liveSlotIds.length === leaves.size &&
liveSlotIds.every((id, index) => id === FIRST_PANE_ID + index)
const manager = findRegisteredTerminalTab(tab.id, tab.worktreeId)?.tab.getManager()
const hostSurfacesByLeaf = new Map(surfacesByTab.get(hostTabId))
for (const [hostLeafId, retainedSurface] of retainedSurfaceByPrunedLeafId ?? []) {
const surface = hostSurfacesByLeaf.get(hostLeafId)
if (surface) {
hostSurfacesByLeaf.set(retainedSurface.leafId, surface)
}
}
let nextPaneTitles: Record<string, string> = paneTitles
for (const [runtimePaneId, title] of Object.entries(paneTitles)) {
const leafId = isTerminalLeafId(runtimePaneId)
? runtimePaneId
: manager
? manager.getLeafId(Number(runtimePaneId))
: (resolveRuntimePaneTitleLeafIdFromSparseSlots({
layout: state.terminalLayoutsByTabId[tab.id],
paneId: Number(runtimePaneId),
liveSlotIds,
ptyIds: state.ptyIdsByTabId[tab.id] ?? []
}) ?? (liveSlotsAreDense ? resolveRuntimePaneTitleLeafId(layout, runtimePaneId) : null))
// Why: a slot the layout cannot place is not proof the pane was removed.
if (leafId === null) {
continue
}
const hostSurface = leaves.has(leafId) ? hostSurfacesByLeaf.get(leafId) : undefined
let hostTitle: string | undefined
if (hostSurface) {
// A pending handle's placeholder carries no new title evidence.
if (
!hostSurface.title.trim() ||
(hostSurface.status === 'pending-handle' && hostSurface.title === 'Terminal')
) {
continue
}
const paneKey = makePaneKey(tab.id, leafId)
// Keep the existing byte-stream ownership fence while that writer is fresh.
if (
clientAgentStatusFencesHost(
paneKey,
state.agentStatusByPaneKey[paneKey],
hostSurface.agentStatus,
now
)
) {
continue
}
const owner = resolvePaneAgentOwnerRecord({
launchAgent: hostSurface.launchAgent ?? tab.launchAgent,
hookAgent: hostSurface.agentStatus?.agentType
})
hostTitle = normalizeCompatibleAgentTitleForOwner(hostSurface.title, owner?.agent, {
ownerIsLaunch: owner?.ownerIsLaunch === true
})
if (title === hostTitle) {
continue
}
} else if (
!isRuntimePaneTitleOrphan(runtimePaneId, leaves) ||
manager?.getNumericIdForLeaf(runtimePaneId) != null
) {
// Only a durable leaf absent from both the host layout and local panes proves removal.
continue
}
if (nextPaneTitles === paneTitles) {
nextPaneTitles = { ...paneTitles }
}
// Runtime slots remain numeric until the pane manager migrates; refresh their values in place.
if (hostTitle !== undefined) {
nextPaneTitles[runtimePaneId] = hostTitle
} else {
delete nextPaneTitles[runtimePaneId]
}
}
if (nextPaneTitles === paneTitles) {
continue
}
if (nextTitles === titles) {
nextTitles = writableWebSessionTabsRecord(state, 'runtimePaneTitlesByTabId', batchContext)
}
nextTitles[tab.id] = nextPaneTitles
}
return nextTitles === titles ? null : { runtimePaneTitlesByTabId: nextTitles }
}
@@ -245,6 +245,7 @@ export type WebSessionTabsSyncState = Pick<
| 'recentlyRetiredAgentStatusPaneKeys'
| 'retainedAgentsByPaneKey'
| 'retentionSuppressedPaneKeys'
| 'runtimePaneTitlesByTabId'
>
>
@@ -265,6 +266,7 @@ export type WebSessionTabsBatchRecordKey =
| 'pendingStartupByTabId'
| 'ptyIdsByTabId'
| 'remoteBrowserPageHandlesByPageId'
| 'runtimePaneTitlesByTabId'
| 'tabBarOrderByWorktree'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { TerminalLayoutSnapshot } from './terminal-tab-types'
import {
isRuntimePaneTitleOrphan,
resolveRuntimePaneTitleForLeaf,
resolveRuntimePaneTitleLeafResolution
} from './runtime-pane-title-leaf-id'
@@ -60,3 +61,16 @@ describe('resolveRuntimePaneTitleLeafResolution', () => {
})
})
})
describe('isRuntimePaneTitleOrphan', () => {
it('never treats numeric slots as removed panes', () => {
for (const runtimePaneId of ['0', '-1', '2', '3']) {
expect(isRuntimePaneTitleOrphan(runtimePaneId, new Set([LEAF_A]))).toBe(false)
}
})
it('flags only a durable leaf id the layout no longer has', () => {
expect(isRuntimePaneTitleOrphan(LEAF_B, new Set([LEAF_A, LEAF_B]))).toBe(false)
expect(isRuntimePaneTitleOrphan(LEAF_B, new Set([LEAF_A]))).toBe(true)
})
})
+8
View File
@@ -125,6 +125,14 @@ export function resolveRuntimePaneTitleLeafIdFromRoot(
return leafIds[numericPaneId - FIRST_PANE_ID] ?? null
}
/** Numeric slots survive pane closes with gaps, so only absent durable leaf ids prove removal. */
export function isRuntimePaneTitleOrphan(
runtimePaneId: string,
layoutLeafIds: ReadonlySet<string>
): boolean {
return isTerminalLeafId(runtimePaneId) && !layoutLeafIds.has(runtimePaneId)
}
/**
* Resolve a sparse live runtime pane slot through the tab's current PTY bindings.
* PaneManager ids survive closes with gaps, while the live PTY list retains order.