Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b07bba6
fix(session): read ssh host partitions so stranded tabs stop round-tr…
robertnisipeanu Aug 5, 2026
954fe5b
fix(session): scope the partition adoption move to what each side sti…
robertnisipeanu Aug 6, 2026
7ff1d8d
fix(session): adopt ssh partitions atomically
Jinwoo-H Aug 10, 2026
68e0c22
fix(session): reconcile ssh partition provenance durably
Jinwoo-H Aug 10, 2026
d5e9f46
fix(session): preserve ssh partition authority bundles
Jinwoo-H Aug 10, 2026
a7b8caa
fix(session): fail closed across ssh authority domains
Jinwoo-H Aug 10, 2026
fc6a3ec
fix(ssh): retain durable pty authority during snapshot restore
Jinwoo-H Aug 10, 2026
d5286ff
fix(ssh): fence snapshot merge by provider authority
Jinwoo-H Aug 10, 2026
98a418a
fix(ssh): fence partition tab and queued patch authority
Jinwoo-H Aug 10, 2026
b0785b0
fix(ssh): fence snapshot authority and index adoption
Jinwoo-H Aug 10, 2026
3e464ae
fix(ssh): stop a one-sided topology revision from vetoing pty-bound p…
robertnisipeanu Aug 14, 2026
c4fc706
review: round 1 fixes
robertnisipeanu Aug 14, 2026
ccafe36
Merge remote-tracking branch 'origin/main' into fix/ssh-partition-ado…
robertnisipeanu Aug 14, 2026
ae23afb
review: round 2 fixes
robertnisipeanu Aug 14, 2026
6f89d85
review: round 3 fixes
robertnisipeanu Aug 14, 2026
187f2e7
review: round 4 fixes
robertnisipeanu Aug 14, 2026
56e9571
review: round 5 fixes
robertnisipeanu Aug 14, 2026
2b2855d
Merge remote-tracking branch 'origin/main' into fix/ssh-partition-ado…
robertnisipeanu Aug 15, 2026
7cb82cc
fix(ssh): graft live local tabs over a stale remote snapshot list
robertnisipeanu Aug 17, 2026
cfb7568
review: round 1 fixes
robertnisipeanu Aug 17, 2026
99e0067
review: round 2 fixes
robertnisipeanu Aug 17, 2026
5cebcb2
Merge upstream/main: keep partition adoption, drop the merge-side gra…
robertnisipeanu Aug 19, 2026
3d078a5
review: split the adoption tests to their own file and repoint the ga…
robertnisipeanu Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions config/reliability-gates.jsonc

Large diffs are not rendered by default.

44 changes: 34 additions & 10 deletions src/main/ipc/remote-workspace-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
RemoteWorkspaceSession,
RemoteWorkspaceSnapshot
} from '../../shared/remote-workspace-types'
import type { DirectSshAuthority, SshProviderEpoch } from '../../shared/ssh-types'
import {
_getRemoteWorkspaceCacheSizesForTests,
_resetRemoteWorkspaceCachesForTests
Expand Down Expand Up @@ -32,6 +33,14 @@ function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspac
}
}

function authority(targetId: string): DirectSshAuthority {
return {
targetId,
providerEpoch: `epoch-${targetId}` as SshProviderEpoch,
connectionGeneration: 1
}
}

describe('remote workspace snapshot cache', () => {
beforeEach(() => {
_resetRemoteWorkspaceCachesForTests()
Expand All @@ -40,7 +49,7 @@ describe('remote workspace snapshot cache', () => {
it('LRU-evicts old target snapshots', () => {
for (let i = 0; i <= REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) {
_rememberRemoteWorkspaceSnapshotForTests(
`target-${i}`,
authority(`target-${i}`),
snapshot({
activeWorktreePath: `/repo-${i}`,
activeTabId: null,
Expand All @@ -53,24 +62,39 @@ describe('remote workspace snapshot cache', () => {
expect(_getRemoteWorkspaceCacheSizesForTests().snapshots).toBe(
REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES
)
expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeUndefined()
expect(_getRemoteWorkspaceSnapshotForTests('target-1')?.session.activeWorktreePath).toBe(
'/repo-1'
)
expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeUndefined()
expect(
_getRemoteWorkspaceSnapshotForTests(authority('target-1'))?.session.activeWorktreePath
).toBe('/repo-1')
})

it('refreshes snapshot recency on cache reads', () => {
for (let i = 0; i < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) {
_rememberRemoteWorkspaceSnapshotForTests(
`target-${i}`,
authority(`target-${i}`),
snapshot(emptyRemoteWorkspaceSession())
)
}

expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined()
_rememberRemoteWorkspaceSnapshotForTests('target-new', snapshot(emptyRemoteWorkspaceSession()))
expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeDefined()
_rememberRemoteWorkspaceSnapshotForTests(
authority('target-new'),
snapshot(emptyRemoteWorkspaceSession())
)

expect(_getRemoteWorkspaceSnapshotForTests(authority('target-0'))).toBeDefined()
expect(_getRemoteWorkspaceSnapshotForTests(authority('target-1'))).toBeUndefined()
})

it('does not return a snapshot across provider authority rotation', () => {
const original = authority('target-1')
const replacement = {
...original,
providerEpoch: 'epoch-replacement' as SshProviderEpoch,
connectionGeneration: 2
}
_rememberRemoteWorkspaceSnapshotForTests(original, snapshot(emptyRemoteWorkspaceSession(), 99))

expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined()
expect(_getRemoteWorkspaceSnapshotForTests('target-1')).toBeUndefined()
expect(_getRemoteWorkspaceSnapshotForTests(replacement)).toBeUndefined()
})
})
10 changes: 7 additions & 3 deletions src/main/ipc/remote-workspace-events.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { DirectSshAuthority } from '../../shared/ssh-types'

type RemoteWorkspaceNotificationHandler = (
targetId: string,
method: string,
params: Record<string, unknown>
params: Record<string, unknown>,
authority: DirectSshAuthority
) => void

const handlers = new Set<RemoteWorkspaceNotificationHandler>()
Expand All @@ -16,9 +19,10 @@ export function registerRemoteWorkspaceNotificationHandler(
export function notifyRemoteWorkspaceHandlers(
targetId: string,
method: string,
params: Record<string, unknown>
params: Record<string, unknown>,
authority: DirectSshAuthority
): void {
for (const handler of handlers) {
handler(targetId, method, params)
handler(targetId, method, params, authority)
}
}
103 changes: 103 additions & 0 deletions src/main/ipc/remote-workspace-explicit-session-authority.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { isDeepStrictEqual } from 'node:util'
import type { Store } from '../persistence'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection'
import type { RemoteWorkspaceSession } from '../../shared/remote-workspace-types'
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
import type { DirectSshAuthority } from '../../shared/ssh-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'

type SessionAuthorityStore = Pick<Store, 'getRepo' | 'getWorkspaceSession'>

function targetForWorktree(
store: SessionAuthorityStore,
worktreeId: string,
targetId: string
): string | null {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return store.getRepo(repoId, toSshExecutionHostId(targetId))?.connectionId ?? null
}

export function exportSessionForTarget(
store: SessionAuthorityStore,
authority: DirectSshAuthority,
session: WorkspaceSessionState
): RemoteWorkspaceSession | null {
const isTargetWorktree = (worktreeId: string): boolean =>
targetForWorktree(store, worktreeId, authority.targetId) === authority.targetId
const ownerByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
for (const tab of tabs) {
const owner = ownerByTabId.get(tab.id)
if (
owner &&
owner !== worktreeId &&
(isTargetWorktree(owner) || isTargetWorktree(worktreeId))
) {
return null
}
ownerByTabId.set(tab.id, worktreeId)
}
}
const ownsPty = (ptyId: string): boolean =>
parseAppSshPtyId(ptyId)?.connectionId === authority.targetId
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
if (!isTargetWorktree(worktreeId)) {
continue
}
for (const tab of tabs) {
if (tab.worktreeId !== worktreeId || (tab.ptyId && !ownsPty(tab.ptyId))) {
return null
}
const layout = session.terminalLayoutsByTabId[tab.id]
if (Object.values(layout?.ptyIdsByLeafId ?? {}).some((ptyId) => !ownsPty(ptyId))) {
return null
}
const remoteSessionId = session.remoteSessionIdsByTabId?.[tab.id]
if (remoteSessionId && !ownsPty(remoteSessionId)) {
return null
}
}
}
return exportRemoteWorkspaceSession(session, { isTargetWorktree })
}

function hasWorkspaceTabs(session: WorkspaceSessionState): boolean {
return Object.values(session.tabsByWorktree).some((tabs) => tabs.length > 0)
}

function hasRemoteWorkspaceTabs(session: RemoteWorkspaceSession): boolean {
return Object.values(session.tabsByWorktreePath).some((tabs) => tabs.length > 0)
}

export function exportExplicitSessionForTarget(
store: SessionAuthorityStore,
authority: DirectSshAuthority,
session: WorkspaceSessionState
): RemoteWorkspaceSession | null {
const projected = exportSessionForTarget(store, authority, session)
if (!projected) {
return null
}
if (hasWorkspaceTabs(session) && !hasRemoteWorkspaceTabs(projected)) {
return null
}
const durableSessions = [
store.getWorkspaceSession(),
store.getWorkspaceSession(toSshExecutionHostId(authority.targetId))
]
for (const durableSession of durableSessions) {
const durableProjection = exportSessionForTarget(store, authority, durableSession)
if (!durableProjection) {
return null
}
if (
hasRemoteWorkspaceTabs(durableProjection) &&
!isDeepStrictEqual(durableProjection, projected)
) {
return null
}
}
return projected
}
Loading
Loading