mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
fix(session-tabs): a held paired-tab close survives an undecided re-pair, and a dropped one shows its tab at once (#30843)
* fix(session-tabs): a held paired-tab close keeps hiding through an undecided re-pair, and its expiry redraws the tab * fix(session-tabs): dropping a held close also clears its hide under the current pairing * fix(session-tabs): redraw only the worktree a dropped close un-hid; inventory frames see the redraw mark * fix(session-tabs): a re-pair proven the same machine asks for the snapshot its moved closes wait on * fix(session-tabs): a dropped close clears only the current-pairing hide it laid * fix(session-tabs): every un-hide of a held close lets its worktree's current snapshot redraw
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { PairedTerminalTabCloseIntent } from '../../../shared/paired-terminal-tab-close-intent'
|
||||
import {
|
||||
PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS,
|
||||
type PairedTerminalTabCloseIntent
|
||||
} from '../../../shared/paired-terminal-tab-close-intent'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { resetDeferredPeerChecksForTests } from '@/store/slices/runtime-environment-peer-replacement'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
@@ -13,12 +16,19 @@ import {
|
||||
resetWebSessionCloseReplaysForTests,
|
||||
takeDueWebSessionCloseReplays
|
||||
} from './web-session-close-replay'
|
||||
import { isWebSessionCloseIntentPending } from './web-session-close-intent'
|
||||
import {
|
||||
isWebSessionCloseIntentPending,
|
||||
recordWebSessionCloseIntent
|
||||
} from './web-session-close-intent'
|
||||
import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness'
|
||||
|
||||
vi.mock('@/hooks/agent-hook-completion-notifications', () => ({
|
||||
observeAgentHookCompletionForNotification: vi.fn()
|
||||
}))
|
||||
const refreshSnapshot = vi.hoisted(() => vi.fn(async () => {}))
|
||||
vi.mock('./web-runtime-session-snapshot', () => ({
|
||||
refreshWebRuntimeSessionTabsSnapshot: refreshSnapshot
|
||||
}))
|
||||
|
||||
const WT = 'repo::/wt'
|
||||
const TAB = 'host-tab-1'
|
||||
@@ -41,7 +51,7 @@ function environment(
|
||||
}
|
||||
}
|
||||
|
||||
function managed(id: string, pairingRevision: number, hostKeyFingerprint: string) {
|
||||
function managed(id: string, pairingRevision: number, hostKeyFingerprint?: string) {
|
||||
return environment(id, {
|
||||
pairingRevision,
|
||||
hostKeyFingerprint,
|
||||
@@ -81,6 +91,7 @@ beforeEach(() => {
|
||||
initialState = useAppStore.getState()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initialState, true)
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
resetDeferredPeerChecksForTests()
|
||||
@@ -129,6 +140,91 @@ describe('held paired-host closes when a server is removed', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("holds a managed re-pair's closes, unsent, until its host key decides the machine", async () => {
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')])
|
||||
hold('env-a')
|
||||
// The re-pair lands before the catalog carries the host's key.
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)])
|
||||
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
expect(heldRevisions('env-a')).toEqual([1])
|
||||
expect(hidden('env-a', 2)).toBe(true)
|
||||
|
||||
refreshSnapshot.mockClear()
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2, 'key-a')])
|
||||
|
||||
expect(heldRevisions('env-a')).toEqual([2])
|
||||
// An idle host may publish nothing more, so the decision itself asks for the snapshot.
|
||||
await vi.waitFor(() =>
|
||||
expect(refreshSnapshot).toHaveBeenCalledWith('env-a', WT, {
|
||||
expectedEnvironmentPairingRevision: 2
|
||||
})
|
||||
)
|
||||
const due = takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))
|
||||
expect(due.map((replay) => replay.owner)).toEqual([
|
||||
{ environmentId: 'env-a', pairingRevision: 2 }
|
||||
])
|
||||
// Once moved, a later catalog has nothing to move and asks for nothing.
|
||||
refreshSnapshot.mockClear()
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2, 'key-a')])
|
||||
await Promise.resolve()
|
||||
expect(refreshSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps holding through a second undecided re-pair and moves them to the newest one', () => {
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')])
|
||||
hold('env-a')
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)])
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 3)])
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 3, 'key-a')])
|
||||
|
||||
expect(heldRevisions('env-a')).toEqual([3])
|
||||
})
|
||||
|
||||
it('drops the held closes once the late host key proves another machine', () => {
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')])
|
||||
hold('env-a')
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)])
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
|
||||
expect(hidden('env-a', 2)).toBe(true)
|
||||
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2, 'key-reinstalled')])
|
||||
|
||||
expect(heldRevisions('env-a')).toBeUndefined()
|
||||
expect(hidden('env-a', 2)).toBe(false)
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the tab again when a close held through an undecided re-pair expires', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')])
|
||||
hold('env-a')
|
||||
useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)])
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
vi.setSystemTime(Date.now() + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS + 1)
|
||||
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
|
||||
expect(heldRevisions('env-a')).toBeUndefined()
|
||||
expect(hidden('env-a', 2)).toBe(false)
|
||||
})
|
||||
|
||||
it("dropping an old pairing's close leaves a fresh close of the same tab hidden", () => {
|
||||
useAppStore.getState().setRuntimeEnvironments([environment('env-a')])
|
||||
hold('env-a')
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'env-a', createdAt: 1, pairingRevision: 2 }])
|
||||
// The user closes the tab again under the new pairing; that close is still in flight.
|
||||
recordWebSessionCloseIntent({ environmentId: 'env-a', pairingRevision: 2 }, WT, TAB, Date.now())
|
||||
|
||||
expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([])
|
||||
|
||||
expect(heldRevisions('env-a')).toBeUndefined()
|
||||
expect(hidden('env-a', 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('drops, at the first catalog, the closes of a server removed while the app was closed', () => {
|
||||
useAppStore.getState().hydrateWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// A held close that expires is dropped when the host's snapshot arrives. The tab it hid must come
|
||||
// back on that snapshot even when it is unchanged, not wait for the host to publish a change.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS } from '../../../shared/paired-terminal-tab-close-intent'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { appPairedTerminalTabCloseIntentStore as STORE } from './paired-terminal-tab-close-intent-store'
|
||||
import {
|
||||
holdWebSessionCloseForReplay,
|
||||
resetWebSessionCloseReplaysForTests
|
||||
} from './web-session-close-replay'
|
||||
import { isWebSessionCloseIntentPending } from './web-session-close-intent'
|
||||
import { recordReceivedWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking'
|
||||
import { shouldApplyWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking-decisions'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
import {
|
||||
ENV,
|
||||
HOST_SURFACE_ID,
|
||||
LEAF_ID,
|
||||
NOW,
|
||||
makeSnapshot,
|
||||
resetWebSessionTabsSyncTestState,
|
||||
WT
|
||||
} from './web-session-tabs-sync-test-harness'
|
||||
|
||||
vi.mock('@/hooks/agent-hook-completion-notifications', () => ({
|
||||
observeAgentHookCompletionForNotification: vi.fn()
|
||||
}))
|
||||
|
||||
const HOST_TAB = 'host-tab-1'
|
||||
const SNAPSHOT = makeSnapshot([
|
||||
{
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
parentTabId: HOST_TAB,
|
||||
leafId: LEAF_ID,
|
||||
title: 'Terminal',
|
||||
status: 'ready',
|
||||
terminal: 'term_host',
|
||||
isActive: true
|
||||
}
|
||||
])
|
||||
const OWNER = { environmentId: ENV, pairingRevision: 1 }
|
||||
|
||||
let initialState: AppState
|
||||
|
||||
beforeEach(() => {
|
||||
initialState = useAppStore.getState()
|
||||
vi.useFakeTimers({ now: NOW })
|
||||
resetWebSessionTabsSyncTestState()
|
||||
replaceRuntimeEnvironmentRevisions([{ id: ENV, createdAt: 0, pairingRevision: 1 }])
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initialState, true)
|
||||
resetWebSessionCloseReplaysForTests()
|
||||
resetWebSessionTabsSyncTestState()
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
})
|
||||
|
||||
describe('an expired held paired-host close', () => {
|
||||
it('lets the unchanged snapshot that dropped it redraw the tab', () => {
|
||||
holdWebSessionCloseForReplay(STORE, OWNER, WT, [HOST_TAB])
|
||||
// The tab is hidden when this snapshot applies.
|
||||
expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true)
|
||||
expect(isWebSessionCloseIntentPending(OWNER, WT, HOST_TAB, Date.now())).toBe(true)
|
||||
|
||||
vi.setSystemTime(NOW + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS + 1)
|
||||
recordReceivedWebSessionTabsSnapshot(ENV, SNAPSHOT)
|
||||
|
||||
expect(useAppStore.getState().pairedTerminalTabCloseIntentsByEnvironment[ENV]).toBeUndefined()
|
||||
expect(isWebSessionCloseIntentPending(OWNER, WT, HOST_TAB, Date.now())).toBe(false)
|
||||
expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true)
|
||||
})
|
||||
|
||||
it('redraws another worktree whose expired close the same drop pruned', () => {
|
||||
const otherWorktree = 'repo::/other'
|
||||
const otherSnapshot = { ...SNAPSHOT, worktree: otherWorktree }
|
||||
holdWebSessionCloseForReplay(STORE, OWNER, WT, [HOST_TAB])
|
||||
holdWebSessionCloseForReplay(STORE, OWNER, otherWorktree, [HOST_TAB])
|
||||
expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true)
|
||||
expect(shouldApplyWebSessionTabsSnapshot(otherSnapshot, ENV)).toBe(true)
|
||||
|
||||
vi.setSystemTime(NOW + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS + 1)
|
||||
recordReceivedWebSessionTabsSnapshot(ENV, SNAPSHOT)
|
||||
|
||||
expect(useAppStore.getState().pairedTerminalTabCloseIntentsByEnvironment[ENV]).toBeUndefined()
|
||||
expect(isWebSessionCloseIntentPending(OWNER, otherWorktree, HOST_TAB, Date.now())).toBe(false)
|
||||
expect(shouldApplyWebSessionTabsSnapshot(otherSnapshot, ENV)).toBe(true)
|
||||
})
|
||||
|
||||
it('still refuses an unchanged snapshot when nothing was un-hidden', () => {
|
||||
expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true)
|
||||
recordReceivedWebSessionTabsSnapshot(ENV, SNAPSHOT)
|
||||
expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
const revisionByEnvironmentId = new Map<string, number>()
|
||||
// Pairings proven to continue the same machine: old revision → the revision that replaced it.
|
||||
const continuedRevisionsByEnvironmentId = new Map<string, Map<number, number>>()
|
||||
// Re-pairs whose host identity is not decided yet: id → the pairing they may continue.
|
||||
const pendingContinuationByEnvironmentId = new Map<string, number>()
|
||||
const retirementListeners = new Set<(environmentIds: readonly string[]) => void>()
|
||||
const revisionChangeListeners = new Set<(environmentIds: readonly string[]) => void>()
|
||||
|
||||
@@ -9,6 +11,8 @@ export type RuntimeEnvironmentContinuity = {
|
||||
retired: readonly string[]
|
||||
/** Re-pairs proven to be the same machine (same registration and host key). */
|
||||
sameHost: readonly { id: string; fromRevision: number; toRevision: number }[]
|
||||
/** Re-pairs not yet proven either way, with the pairing they may continue. */
|
||||
pending?: readonly { id: string; fromRevision: number }[]
|
||||
}
|
||||
|
||||
export function replaceRuntimeEnvironmentRevisions(
|
||||
@@ -39,6 +43,12 @@ export function replaceRuntimeEnvironmentRevisions(
|
||||
continued.set(fromRevision, toRevision)
|
||||
continuedRevisionsByEnvironmentId.set(id, continued)
|
||||
}
|
||||
pendingContinuationByEnvironmentId.clear()
|
||||
for (const { id, fromRevision } of continuity.pending ?? []) {
|
||||
if (nextIds.has(id) && !retired.has(id)) {
|
||||
pendingContinuationByEnvironmentId.set(id, fromRevision)
|
||||
}
|
||||
}
|
||||
revisionByEnvironmentId.clear()
|
||||
for (const environment of environments) {
|
||||
revisionByEnvironmentId.set(
|
||||
@@ -97,6 +107,16 @@ export function resolveContinuedRuntimeEnvironmentRevision(
|
||||
return current
|
||||
}
|
||||
|
||||
/** True while a re-pair that may continue `revision` is still waiting for its host identity. */
|
||||
export function isRuntimeEnvironmentContinuationPending(
|
||||
environmentId: string,
|
||||
revision: number | undefined
|
||||
): boolean {
|
||||
return (
|
||||
revision !== undefined && pendingContinuationByEnvironmentId.get(environmentId) === revision
|
||||
)
|
||||
}
|
||||
|
||||
export function captureRuntimeEnvironmentRequestRevision(
|
||||
environmentId: string,
|
||||
expectedRevision?: number
|
||||
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
type PairedTerminalTabCloseIntent
|
||||
} from '../../../shared/paired-terminal-tab-close-intent'
|
||||
import type { PairedTerminalTabCloseIntentsByEnvironment } from './paired-terminal-tab-close-intents'
|
||||
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
|
||||
import {
|
||||
getRuntimeEnvironmentRevision,
|
||||
isRuntimeEnvironmentContinuationPending
|
||||
} from './runtime-environment-revision'
|
||||
import {
|
||||
clearWebSessionCloseIntent,
|
||||
makeWebSessionCloseIntentDurable,
|
||||
recordWebSessionCloseIntent
|
||||
} from './web-session-close-intent'
|
||||
import type { WebSessionIntentOwner } from './web-session-intent-owner'
|
||||
import { acceptReplayedWebSessionTabsSnapshot } from './web-session-tabs-sync/state'
|
||||
|
||||
/** Bounded so a host that keeps failing the close hands the tab back instead of hiding it forever. */
|
||||
export const MAX_WEB_SESSION_CLOSE_REPLAY_ATTEMPTS = 3
|
||||
@@ -38,6 +42,8 @@ export type WebSessionCloseReplay = {
|
||||
|
||||
// Session-scoped on purpose: after a reload a held close starts a fresh attempt count.
|
||||
const attemptsByIntent = new Map<string, { attempts: number; inFlight: boolean }>()
|
||||
// Held closes also hidden under the current pairing while their own was unknown or undecided.
|
||||
const hiddenUnderCurrentPairing = new Set<string>()
|
||||
|
||||
function attemptKey(environmentId: string, intentKey: string): string {
|
||||
return `${environmentId}\0${intentKey}`
|
||||
@@ -84,6 +90,8 @@ function revealHeldClose(
|
||||
* The one way a held close leaves the store. Whatever the change removed -- the named intents, a
|
||||
* cap eviction, or anything past the age bound that the store prunes on the way -- is never sent
|
||||
* again, so it stops hiding its tab, except a delivered close the host's next snapshot will settle.
|
||||
* The tab was hidden when its worktree's current snapshot applied, so that snapshot may apply
|
||||
* again unchanged; otherwise the tab stays hidden until the host publishes a change.
|
||||
*/
|
||||
function changeHeldIntents(
|
||||
store: Pick<PairedTerminalTabCloseIntentStore, 'getIntents'>,
|
||||
@@ -93,17 +101,35 @@ function changeHeldIntents(
|
||||
): void {
|
||||
const before = listHeldIntents(store, environmentId)
|
||||
change()
|
||||
const kept = new Set(listHeldIntents(store, environmentId).map(pairedTerminalTabCloseIntentKey))
|
||||
const after = listHeldIntents(store, environmentId)
|
||||
const kept = new Set(after.map(pairedTerminalTabCloseIntentKey))
|
||||
const stillHeldTabs = new Set(
|
||||
after.flatMap((intent) => intent.hostTabIds.map((id) => `${intent.worktreeId}\0${id}`))
|
||||
)
|
||||
const revealedWorktreeIds = new Set<string>()
|
||||
for (const intent of before) {
|
||||
const key = pairedTerminalTabCloseIntentKey(intent)
|
||||
if (kept.has(key)) {
|
||||
continue
|
||||
}
|
||||
attemptsByIntent.delete(attemptKey(environmentId, key))
|
||||
const hidUnderCurrent = hiddenUnderCurrentPairing.delete(attemptKey(environmentId, key))
|
||||
if (!stillHidden.has(key)) {
|
||||
revealHeldClose(intentOwner(environmentId, intent), intent.worktreeId, intent.hostTabIds)
|
||||
// Only the hide this close laid: a fresh close of the same tab may hide there too.
|
||||
if (hidUnderCurrent) {
|
||||
revealHeldClose(
|
||||
{ environmentId },
|
||||
intent.worktreeId,
|
||||
intent.hostTabIds.filter((id) => !stillHeldTabs.has(`${intent.worktreeId}\0${id}`))
|
||||
)
|
||||
}
|
||||
revealedWorktreeIds.add(intent.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const worktreeId of revealedWorktreeIds) {
|
||||
acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps an unsent user close, hiding its tab; a repeat for the same tab keeps its attempt count. */
|
||||
@@ -156,9 +182,18 @@ export function takeDueWebSessionCloseReplays(
|
||||
// Why: after a reload a snapshot can land before the environment list publishes revisions.
|
||||
// Unknown is not a mismatch: keep hiding the tab (under the key the snapshot apply reads now)
|
||||
// and send nothing until the pairing can be checked.
|
||||
// A re-pair still waiting for its host identity is unknown in the same way: it may yet prove to
|
||||
// continue this close's pairing (the catalog then moves it) or to be another machine (dropped).
|
||||
// A key that never arrives holds it until the age bound; safe, since nothing is sent meanwhile.
|
||||
const expired = now - intent.closedAt > PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS
|
||||
if (!expired && currentRevision === undefined && intent.pairingRevision !== undefined) {
|
||||
if (
|
||||
!expired &&
|
||||
intent.pairingRevision !== undefined &&
|
||||
(currentRevision === undefined ||
|
||||
isRuntimeEnvironmentContinuationPending(environmentId, intent.pairingRevision))
|
||||
) {
|
||||
hideHeldClose({ environmentId }, intent.worktreeId, intent.hostTabIds)
|
||||
hiddenUnderCurrentPairing.add(key)
|
||||
continue
|
||||
}
|
||||
// Why: the close was the old pairing's, so a replacement host must never receive it; and past
|
||||
@@ -271,6 +306,18 @@ export function reconcileWebSessionCloseReplaysWithCatalog(
|
||||
for (const intent of rebased) {
|
||||
hideHeldClose(intentOwner(environmentId, intent), intent.worktreeId, intent.hostTabIds)
|
||||
}
|
||||
// Why: a close held while the re-pair was undecided waits for a snapshot, and an idle host may
|
||||
// publish none; ask for one so the moved close is sent now.
|
||||
const worktreeIds = [...new Set(rebased.map((intent) => intent.worktreeId))]
|
||||
// Lazy: the snapshot module reaches the store, which loads this module.
|
||||
void import('./web-runtime-session-snapshot').then(({ refreshWebRuntimeSessionTabsSnapshot }) =>
|
||||
worktreeIds.forEach(
|
||||
(worktreeId) =>
|
||||
void refreshWebRuntimeSessionTabsSnapshot(environmentId, worktreeId, {
|
||||
expectedEnvironmentPairingRevision: toRevision
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,4 +350,5 @@ export function replayWebSessionClosesDueForSnapshot(
|
||||
|
||||
export function resetWebSessionCloseReplaysForTests(): void {
|
||||
attemptsByIntent.clear()
|
||||
hiddenUnderCurrentPairing.clear()
|
||||
}
|
||||
|
||||
@@ -53,16 +53,6 @@ export function handleGlobalSessionInventoryEvent({
|
||||
}: GlobalSessionInventoryEventArgs): void {
|
||||
const skipUnchangedResumeWork = awaitingVisibilityResumeInventory.value && !replayed
|
||||
awaitingVisibilityResumeInventory.value = false
|
||||
const unchanged = event.snapshots.map((snapshot) => {
|
||||
const key = `${environmentId}:${snapshot.worktree}`
|
||||
const freshness = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
return Boolean(
|
||||
skipUnchangedResumeWork &&
|
||||
!replayableSessionTabsSnapshotByWorktree.has(key) &&
|
||||
freshness?.publicationEpoch === snapshot.publicationEpoch &&
|
||||
freshness.snapshotVersion === snapshot.snapshotVersion
|
||||
)
|
||||
})
|
||||
const receivedFrames = event.snapshots.map((snapshot) => {
|
||||
const frame = recordReceivedWebSessionTabsSnapshot(
|
||||
environmentId,
|
||||
@@ -73,6 +63,17 @@ export function handleGlobalSessionInventoryEvent({
|
||||
coordinator.recordSnapshotReceipt(environmentId, snapshot, frame, runtimeId)
|
||||
return frame
|
||||
})
|
||||
// After receipt: a held close dropped at receipt marks its unchanged frame for redraw.
|
||||
const unchanged = event.snapshots.map((snapshot) => {
|
||||
const key = `${environmentId}:${snapshot.worktree}`
|
||||
const freshness = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
return Boolean(
|
||||
skipUnchangedResumeWork &&
|
||||
!replayableSessionTabsSnapshotByWorktree.has(key) &&
|
||||
freshness?.publicationEpoch === snapshot.publicationEpoch &&
|
||||
freshness.snapshotVersion === snapshot.snapshotVersion
|
||||
)
|
||||
})
|
||||
const inventoryFrame = recordReceivedWebSessionTabsInventory(environmentId)
|
||||
const missing = coordinator.recordInventoryReceipt(
|
||||
environmentId,
|
||||
|
||||
@@ -89,6 +89,23 @@ export type VisibilityResumeOmission = {
|
||||
|
||||
export const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
export const replayableSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
|
||||
export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string {
|
||||
return `${environmentId}:${worktreeId}`
|
||||
}
|
||||
|
||||
// Why: a replay may repeat the current epoch/version; permit only that exact
|
||||
// identity once so an older concurrent frame cannot bypass monotonic ordering.
|
||||
export function acceptReplayedWebSessionTabsSnapshot(
|
||||
environmentId: string,
|
||||
worktreeId: string
|
||||
): void {
|
||||
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
|
||||
const current = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
if (current) {
|
||||
replayableSessionTabsSnapshotByWorktree.set(key, current)
|
||||
}
|
||||
}
|
||||
export const latestReceivedSessionTabsSnapshotByWorktree = new Map<
|
||||
string,
|
||||
ReceivedSessionTabsSnapshot
|
||||
|
||||
@@ -76,18 +76,7 @@ export function getLatestWebSessionTabsPublicationEpoch(
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a replay may repeat the current epoch/version; permit only that exact
|
||||
// identity once so an older concurrent frame cannot bypass monotonic ordering.
|
||||
export function acceptReplayedWebSessionTabsSnapshot(
|
||||
environmentId: string,
|
||||
worktreeId: string
|
||||
): void {
|
||||
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
|
||||
const current = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
if (current) {
|
||||
replayableSessionTabsSnapshotByWorktree.set(key, current)
|
||||
}
|
||||
}
|
||||
export { acceptReplayedWebSessionTabsSnapshot } from './state'
|
||||
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
|
||||
latestSessionTabsSnapshotByWorktree.clear()
|
||||
replayableSessionTabsSnapshotByWorktree.clear()
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setBoundedSessionTabsReceipt,
|
||||
trackedSessionTabsWorktreeIdsByEnvironment,
|
||||
nextReceivedSessionTabsFrame,
|
||||
sessionTabsFreshnessKey,
|
||||
type SnapshotFreshness,
|
||||
type SessionTabsListAllResult,
|
||||
type TrackedWebSessionTabsWorktree
|
||||
@@ -34,9 +35,7 @@ export function isSessionTabsListAllResult(value: unknown): value is SessionTabs
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string {
|
||||
return `${environmentId}:${worktreeId}`
|
||||
}
|
||||
export { sessionTabsFreshnessKey } from './state'
|
||||
|
||||
export function advancesSessionTabsFreshness(
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('which re-pairs a live pane may follow', () => {
|
||||
it('reports a same-key update as a same-host rotation from the old pairing', () => {
|
||||
expect(classify(managed(), managed({ pairingRevision: 2 }))).toEqual({
|
||||
retired: [],
|
||||
sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }]
|
||||
sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }],
|
||||
pending: []
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,10 +89,17 @@ describe('which re-pairs a live pane may follow', () => {
|
||||
expect(classify(managed(), managed({ generation: 2, pairingRevision: 2 })).sameHost).toEqual([])
|
||||
})
|
||||
|
||||
it('reports no pending re-pair when the old pairing had no key to prove it by', () => {
|
||||
expect(
|
||||
classify(managed({ hostKey: null }), managed({ pairingRevision: 2, hostKey: null })).pending
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a deferred re-pair only once its key proves the same host, from the original pairing', () => {
|
||||
expect(classify(managed(), managed({ pairingRevision: 2, hostKey: null }))).toEqual({
|
||||
retired: [],
|
||||
sameHost: []
|
||||
sameHost: [],
|
||||
pending: [{ id: 'env-1', fromRevision: 1 }]
|
||||
})
|
||||
expect(
|
||||
classifyPeerReplacements(
|
||||
@@ -99,6 +107,10 @@ describe('which re-pairs a live pane may follow', () => {
|
||||
[managed({ pairingRevision: 2 })],
|
||||
[]
|
||||
)
|
||||
).toEqual({ retired: [], sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] })
|
||||
).toEqual({
|
||||
retired: [],
|
||||
sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }],
|
||||
pending: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ function pairingRevisionOf(environment: CatalogEnvironment | undefined): number
|
||||
}
|
||||
|
||||
export type SameHostPairingRotation = { id: string; fromRevision: number; toRevision: number }
|
||||
export type PendingPairingRotation = { id: string; fromRevision: number }
|
||||
|
||||
/**
|
||||
* `retired`: ids that now name a different machine, whose workspaces and tabs are retired. A managed
|
||||
@@ -56,14 +57,18 @@ export type SameHostPairingRotation = { id: string; fromRevision: number; toRevi
|
||||
* pairing handshake proves, is unchanged under the same SSH target registration. A registration
|
||||
* alone is no proof (a reinstall or a target that resolves elsewhere keeps it), so a re-pair whose
|
||||
* key is not known yet is decided later, once a catalog carries it, rather than purged on a guess.
|
||||
* `sameHost`: re-pairs proven to be the same machine, with the pairing they continue. Re-pairs in
|
||||
* neither list are still unresolved.
|
||||
* `sameHost`: re-pairs proven to be the same machine, with the pairing they continue. `pending`:
|
||||
* unresolved re-pairs that can still prove the same machine, with the pairing they would continue.
|
||||
*/
|
||||
export function classifyPeerReplacements(
|
||||
previous: readonly CatalogEnvironment[],
|
||||
next: readonly CatalogEnvironment[],
|
||||
replacedIds: readonly string[]
|
||||
): { retired: string[]; sameHost: SameHostPairingRotation[] } {
|
||||
): {
|
||||
retired: string[]
|
||||
sameHost: SameHostPairingRotation[]
|
||||
pending: PendingPairingRotation[]
|
||||
} {
|
||||
const nextById = new Map(next.map((environment) => [environment.id, environment]))
|
||||
const retired: string[] = []
|
||||
const sameHost: SameHostPairingRotation[] = []
|
||||
@@ -115,5 +120,11 @@ export function classifyPeerReplacements(
|
||||
recordSameHost(id, deferred.revision)
|
||||
}
|
||||
}
|
||||
return { retired, sameHost }
|
||||
// Why a known prior key only: without one the re-pair can never be proven, so nothing waits on it.
|
||||
const pending = [...deferredHostKeyById].flatMap(([id, deferred]) =>
|
||||
deferred.hostKey !== null && deferred.revision !== null
|
||||
? [{ id, fromRevision: deferred.revision }]
|
||||
: []
|
||||
)
|
||||
return { retired, sameHost, pending }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user