From 69236564deb6e9dcc9cf8e43ad655e00ca4c78bd Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:35:04 +0000 Subject: [PATCH] fix(ssh): let a placed worktree seed while its host is in conflict One host tab on a folder this client lacks marked every worktree on the host unverifiable, so reopening an emptied one never got a terminal. Fixes #22015 Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> --- .github/workflows/e2e.yml | 2 + config/scripts/ci-e2e-job-selection.mjs | 1 + config/scripts/run-ssh-docker-e2e.mjs | 1 + ...mote-workspace-deferred-placement-retry.ts | 3 +- .../hooks/remote-workspace-snapshot-apply.ts | 6 +- ...ace-snapshot-unplaced-tab-adoption.test.ts | 13 ++ ...t-session-direct-ssh-hydration-gap.test.ts | 15 +++ .../workspace-terminal-host-authority.test.ts | 78 +++++++++++ .../lib/workspace-terminal-host-authority.ts | 27 +++- src/renderer/src/store/slices/ssh.ts | 3 + .../docker-ssh-relay-workspace-snapshot.ts | 41 ++++++ ...ssh-cold-hydration-gap-tab-seeding.spec.ts | 35 +---- .../ssh-emptied-worktree-reactivation.spec.ts | 123 ++++++++++++++++++ 13 files changed, 308 insertions(+), 40 deletions(-) create mode 100644 tests/e2e/helpers/docker-ssh-relay-workspace-snapshot.ts create mode 100644 tests/e2e/ssh-emptied-worktree-reactivation.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2957c8eaa6ec..a862d0d93958 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -315,6 +315,7 @@ jobs: . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and + . != "tests/e2e/ssh-emptied-worktree-reactivation.spec.ts" and . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and @@ -386,6 +387,7 @@ jobs: contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') || diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 6126e753b5dc..cb9d07aec258 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -8,6 +8,7 @@ export const DOCKER_SSH_E2E_SPECS = [ 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', 'tests/e2e/ssh-docker-half-open-link.spec.ts', diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 64709cdbb43d..72fbbb9d44f2 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -60,6 +60,7 @@ const result = spawnSync( 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', 'tests/e2e/ssh-docker-half-open-link.spec.ts', diff --git a/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts b/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts index 97534955f696..c3139f85f47e 100644 --- a/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts +++ b/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts @@ -9,7 +9,8 @@ import { /** How long a conflicted target keeps watching the catalog for the rows it could not place. The * catalog for a remote host often only fills in when the user opens the worktree, which is minutes * after connect, and `conflict` has no other exit: it suppresses uploads and holds terminal - * authority at `unverifiable` until a reconnect or an unsolicited host push happens to arrive. */ + * authority for those paths at `unverifiable` until a reconnect or an unsolicited host push + * happens to arrive. */ const DEFERRED_SNAPSHOT_PLACEMENT_TIMEOUT_MS = 600_000 /** A retry's own apply can report paths it still could not place, which arms the next watch. If the diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 5ca493251ec0..9dbe57cf0bc8 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -229,7 +229,8 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ // replaces the host snapshot - it would delete the very tabs we failed to adopt; // - workspace-terminal-host-authority.ts treats `offline`/`error` on an un-hydrated target // as `none`, its bounded floor, which authorises seeding AND sleeping-agent resume. - // `conflict` is deliberately not in that set, so authority stays `unverifiable`. + // `conflict` is deliberately not in that set: the unplaced paths stay `unverifiable`, + // and only the worktrees this apply did place read `none`. // Hydration is cleared, not merely withheld: the set is add-only, so a target that synced // cleanly before would otherwise keep uploading from this incomplete picture (STA-3593). currentStore.clearRemoteWorkspaceHydrated(authority.targetId) @@ -238,7 +239,8 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ direction: 'pull', revision: snapshot.revision, updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken + hostObservationToken: snapshot.hostObservationToken, + unplacedTabWorktreePaths: [...unplacedTabWorktreePaths] }) } const reconnectAbort = new AbortController() diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts index 391bdf106931..7d8fc937cc91 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts @@ -330,6 +330,19 @@ describe('a host snapshot whose terminal tabs cannot be placed locally', () => { ).toBe('unverifiable') }) + it('answers for the worktrees it placed when the host names another path it cannot place', async () => { + const store = createStore() + // A second machine left a tab on beta; this client only has alpha. + landAlphaLineage(store) + await applySnapshot(store, snapshot(1)) + + expect(syncPhase(store)).toBe('conflict') + expect(isHydrated(store)).toBe(false) + expect(adoptedTabIds(store)).toEqual(['T1', 'T2']) + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), ALPHA_ID)).toBe('none') + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), BETA_ID)).toBe('unverifiable') + }) + it('adopts every host tab and declares the target hydrated once the catalog is present', async () => { const store = createStore() landHostLineage(store) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts index be73993c49c9..0b9404d1da48 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts @@ -117,6 +117,21 @@ describe('sleeping-agent resume across the direct-SSH hydration gap', () => { expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) + it.each([ + ['wakes it when the host named tabs only on another path', '/srv/proj/another-machines', 1], + ['keeps waiting when the host named tabs here it could not place', PATH, 0] + ])('after a snapshot that could not place every tab, %s', (_label, unplacedPath, expected) => { + seedColdDirectSshStart() + useAppStore.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { + phase: 'conflict', + direction: 'pull', + unplacedTabWorktreePaths: [unplacedPath] + }) + + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(expected) + expect(resumedTabCount(WORKTREE_ID)).toBe(expected) + }) + it('leaves a purely local workspace resuming with no added latency', () => { seedColdDirectSshStart() const localRecord = { diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts index c7c5bc60f7e9..806b7840ce5a 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts @@ -160,6 +160,84 @@ describe('workspace terminal seeding authority', () => { expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0) }) + it('seeds a worktree the conflicting snapshot placed, and only that one', () => { + const store = createTestStore() + seedDirectSsh(store) + store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { + phase: 'conflict', + direction: 'pull', + unplacedTabWorktreePaths: ['/srv/proj/another-machines-worktree'] + }) + + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') + expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy() + expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) + }) + + it('leaves a worktree emptied on purpose empty at startup, even once the host answered for it', () => { + const store = createTestStore() + seedDirectSsh(store) + store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { + phase: 'conflict', + direction: 'pull', + unplacedTabWorktreePaths: ['/srv/proj/another-machines-worktree'] + }) + store.setState({ + tabsByWorktree: { [SSH_WORKTREE_ID]: [] }, + closedTerminalTabTombstonesByTabId: { + closed: { closedAt: Date.now(), worktreeId: SSH_WORKTREE_ID } + } + }) + + // Startup restore passes no reseed option; only an explicit activation does. + expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeNull() + expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0) + expect( + ensureWorktreeHasInitialTerminal( + store.getState(), + SSH_WORKTREE_ID, + undefined, + undefined, + undefined, + undefined, + { reseedEmptiedWorkspace: true } + ) + ).toBeTruthy() + }) + + it.each([ + ['its own path', '/srv/proj/feature'], + ['another spelling of its path', '/srv/proj//feature/'] + ])('keeps waiting when the host named tabs it could not place on %s', (_label, unplacedPath) => { + const store = createTestStore() + seedDirectSsh(store) + store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { + phase: 'conflict', + direction: 'pull', + unplacedTabWorktreePaths: [unplacedPath] + }) + + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( + 'unverifiable' + ) + }) + + it('stops trusting a placed worktree once a later sync event replaces the conflict', () => { + const store = createTestStore() + seedDirectSsh(store) + store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { + phase: 'conflict', + direction: 'pull', + unplacedTabWorktreePaths: [] + }) + // What a snapshot that could not be applied, or a rejected upload, writes. + store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'conflict' }) + + expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( + 'unverifiable' + ) + }) + it('leaves terminal creation to the host of a paired runtime workspace', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.ts b/src/renderer/src/lib/workspace-terminal-host-authority.ts index ad535e94563f..9068280c4379 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.ts @@ -1,6 +1,8 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseExecutionHostId } from '../../../shared/execution-host' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { splitWorktreeId } from '../../../shared/worktree/id' import type { HostLiveTerminalProbeVerdict } from '@/runtime/host-live-terminal-probe' import type { RemoteWorkspaceSyncStatus } from '@/store/slices/ssh' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' @@ -16,9 +18,10 @@ import { * than restated so the two cannot drift: * * - `live` — a remote execution host owns terminal creation here. It supplies the surface itself. - * - `unverifiable` — the workspace has a remote execution host with a sync still in flight or not - * yet attempted. It is bounded: a sync that terminates without an answer resolves `none` rather - * than refusing forever (see resolveDirectSshAuthority). + * - `unverifiable` — the workspace has a remote execution host that has not answered for it: a sync + * still in flight or not yet attempted, or a snapshot naming tabs here that could not be placed. + * It is bounded: a sync that terminates without an answer resolves `none` rather than refusing + * forever (see resolveDirectSshAuthority). * - `none` — no remote host holds terminals here: either the workspace is local (this client *is* * the execution host, and its own tab rows are the whole truth) or the host answered and holds * nothing. @@ -57,9 +60,11 @@ const TERMINATED_WITHOUT_ANSWER_PHASES = new Set(['offline', 'error']) function resolveDirectSshAuthority( state: WorkspaceTerminalHostAuthorityState, - targetId: string + targetId: string, + worktreeId: string ): WorkspaceTerminalHostAuthority { - const phase = state.remoteWorkspaceSyncStatusByTargetId?.[targetId]?.phase + const status = state.remoteWorkspaceSyncStatusByTargetId?.[targetId] + const phase = status?.phase if (state.remoteWorkspaceHydratedTargetIds?.has(targetId)) { // Why: the same pair use-app-session-persistence.ts gates uploads on. A conflicting snapshot // means the client's picture is not the host's, so it is no basis for deciding the host holds @@ -73,6 +78,16 @@ function resolveDirectSshAuthority( // seed is meant to be a wait, not a permanent refusal. return 'none' } + const unplacedPaths = status?.unplacedTabWorktreePaths + const worktreePath = splitWorktreeId(worktreeId)?.worktreePath + if (phase === 'conflict' && unplacedPaths && worktreePath) { + // Why: that snapshot was applied to every worktree it could place, so the host has answered for + // this one. Refusing the whole target strands it on one path this client never had (#22015). + const comparablePath = normalizeRuntimePathForComparison(worktreePath) + return unplacedPaths.some((path) => normalizeRuntimePathForComparison(path) === comparablePath) + ? 'unverifiable' + : 'none' + } // Not connected, still pulling, or not yet attempted — "we could not ask", never "nothing there". return 'unverifiable' } @@ -104,7 +119,7 @@ export function resolveWorkspaceTerminalHostAuthority( // Why the git-worktree narrowing: the snapshot replaces exactly DirectSshTargetScope.gitWorktreeIds // (remote-workspace-snapshot-apply.ts). A folder workspace's rows are never replaced by the host, // so waiting on an answer that will never name them would leave it terminal-less for good. - return resolveDirectSshAuthority(state, host.targetId) + return resolveDirectSshAuthority(state, host.targetId, worktreeId) } // Local, or outside the host's replace scope: this client is the execution host and its own rows are // the whole truth. Absence of a catalog row is not evidence of a remote owner, and refusing to act diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index ddeaf415fd50..640d95f79d98 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -22,6 +22,9 @@ export type RemoteWorkspaceSyncStatus = { hostObservationToken?: string lastSyncedAt?: number message?: string + /** Set only by an applied pull that ends in `conflict`: the host paths whose tabs this client + * could not place. Every other worktree on the target holds the host's rows from that snapshot. */ + unplacedTabWorktreePaths?: readonly string[] } export type SshCredentialRequest = { diff --git a/tests/e2e/helpers/docker-ssh-relay-workspace-snapshot.ts b/tests/e2e/helpers/docker-ssh-relay-workspace-snapshot.ts new file mode 100644 index 000000000000..872abd0f02b8 --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-relay-workspace-snapshot.ts @@ -0,0 +1,41 @@ +import { expect, type Page } from '@stablyai/playwright-test' + +import { + execDockerSshRelayTargetControlCommand, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +/** Where the relay persists a target's workspace snapshot inside the fixture container. */ +const REMOTE_SNAPSHOT_DIR = '/root/.orca/sessions' + +export async function readTargetSyncPhase( + page: Page, + targetId: string +): Promise { + return page.evaluate( + (id) => window.__store?.getState().remoteWorkspaceSyncStatusByTargetId[id]?.phase, + targetId + ) +} + +function findRemoteSnapshotPath(target: DockerSshRelayTarget): string | null { + const listing = execDockerSshRelayTargetControlCommand( + target, + `ls -1 ${REMOTE_SNAPSHOT_DIR}/*.json 2>/dev/null || true` + ).trim() + return listing.split('\n').find((line) => line.endsWith('.json')) ?? null +} + +export async function waitForUploadedRemoteSnapshot(target: DockerSshRelayTarget): Promise { + let snapshotPath: string | null = null + await expect + .poll( + () => { + snapshotPath = findRemoteSnapshotPath(target) + return snapshotPath + }, + { timeout: 60_000, message: 'the relay never persisted a workspace snapshot' } + ) + .not.toBeNull() + return snapshotPath! +} diff --git a/tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts b/tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts index 93b16df0a117..22ba97c488d9 100644 --- a/tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts +++ b/tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts @@ -12,12 +12,14 @@ import { type DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + readTargetSyncPhase, + waitForUploadedRemoteSnapshot +} from './helpers/docker-ssh-relay-workspace-snapshot' import { createRestartSession } from './helpers/orca-restart' const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const BASELINE_TAB_COUNT = 3 -/** Where the relay persists a target's workspace snapshot inside the fixture container. */ -const REMOTE_SNAPSHOT_DIR = '/root/.orca/sessions' test.use({ seedTestRepo: false }) @@ -35,13 +37,6 @@ async function isTargetHydrated(page: Page, targetId: string): Promise ) } -async function readTargetSyncPhase(page: Page, targetId: string): Promise { - return page.evaluate( - (id) => window.__store?.getState().remoteWorkspaceSyncStatusByTargetId[id]?.phase, - targetId - ) -} - /** Poll until the worktree's tabs stop changing, so a late seed cannot slip past the sample. */ async function waitForSettledTabIds(page: Page, worktreeId: string): Promise { let latest: string[] = [] @@ -62,28 +57,6 @@ async function waitForSettledTabIds(page: Page, worktreeId: string): Promise/dev/null || true` - ).trim() - return listing.split('\n').find((line) => line.endsWith('.json')) ?? null -} - -async function waitForUploadedRemoteSnapshot(target: DockerSshRelayTarget): Promise { - let snapshotPath: string | null = null - await expect - .poll( - () => { - snapshotPath = findRemoteSnapshotPath(target) - return snapshotPath - }, - { timeout: 60_000, message: 'the relay never persisted a workspace snapshot' } - ) - .not.toBeNull() - return snapshotPath! -} - /** * Replace the relay's snapshot file with a FIFO so `workspace.get` blocks on open. * diff --git a/tests/e2e/ssh-emptied-worktree-reactivation.spec.ts b/tests/e2e/ssh-emptied-worktree-reactivation.spec.ts new file mode 100644 index 000000000000..739826552567 --- /dev/null +++ b/tests/e2e/ssh-emptied-worktree-reactivation.spec.ts @@ -0,0 +1,123 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetControlCommand, + shellQuote, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + connectDockerSshRelayTarget, + disconnectDockerSshRelayTarget, + reconnectDisconnectedDockerSshRelayTarget +} from './helpers/docker-ssh-relay-connection' +import { + readTargetSyncPhase, + waitForUploadedRemoteSnapshot +} from './helpers/docker-ssh-relay-workspace-snapshot' +import { worktreeRowSurface } from './worktree-row-locators' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const FOREIGN_WORKTREE_PATH = '/tmp/another-machines-worktree' + +test.use({ seedTestRepo: false }) + +type HostSnapshot = { + revision: number + updatedAt: number + session: { tabsByWorktreePath: Record } +} + +async function readTabCount(page: Page, worktreeId: string): Promise { + return page.evaluate( + (id) => (window.__store?.getState().tabsByWorktree[id] ?? []).length, + worktreeId + ) +} + +/** Close the worktree's only tab the way a user does, then reopen the worktree from the sidebar. */ +async function closeLastTabAndClickWorktree(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate( + (id) => window.__store?.getState().tabsByWorktree[id]?.[0]?.id ?? null, + worktreeId + ) + expect(tabId, 'the worktree has no tab to close').not.toBeNull() + const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`).first() + await tab.hover() + await tab.locator('button[aria-label^="Close tab"]').first().click() + await expect.poll(() => readTabCount(page, worktreeId), { timeout: 15_000 }).toBe(0) + await worktreeRowSurface(page, worktreeId).click() +} + +/** + * Give the host one tab on a folder this client has no worktree for — what a second machine using + * the same host leaves behind. Edits the relay's own snapshot file, so the next `workspace.get` + * serves it through the real path. + */ +function addForeignTabToHostSnapshot(target: DockerSshRelayTarget, snapshotPath: string): void { + const snapshot: HostSnapshot = JSON.parse( + execDockerSshRelayTargetControlCommand(target, `cat ${shellQuote(snapshotPath)}`) + ) + const ownTab = Object.values(snapshot.session.tabsByWorktreePath).flat()[0] + expect(ownTab, 'the host snapshot never recorded this client tab').toBeTruthy() + snapshot.session.tabsByWorktreePath[FOREIGN_WORKTREE_PATH] = [ + { ...ownTab, id: 'foreign-tab-from-another-machine' } + ] + snapshot.revision += 1 + snapshot.updatedAt = Date.now() + execDockerSshRelayTargetControlCommand( + target, + `printf '%s' ${shellQuote(JSON.stringify(snapshot))} > ${shellQuote(snapshotPath)}` + ) +} + +test.describe('SSH emptied worktree reactivation', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH uses POSIX SSH tooling.') + + // #22015: one host tab this client cannot place put the whole target in `conflict`, and every + // worktree on it then refused a terminal — including ones whose own tabs had been applied. + test('clicking an emptied worktree opens a terminal while the host holds a tab this client cannot place', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(600_000) + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target) + await expect + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 }) + .toBe(remote.worktreeId) + await waitForActiveTerminalManager(orcaPage, 60_000) + await waitForActivePanePtyId(orcaPage, 60_000) + const snapshotPath = await waitForUploadedRemoteSnapshot(target) + + await disconnectDockerSshRelayTarget(orcaPage, remote.targetId) + addForeignTabToHostSnapshot(target, snapshotPath) + await reconnectDisconnectedDockerSshRelayTarget(orcaPage, remote.targetId) + // Proves the intended branch was taken: the placement wait has ended in a pull conflict. + await expect + .poll(() => readTargetSyncPhase(orcaPage, remote.targetId), { + timeout: 60_000, + message: 'the unplaceable host tab never put the target in conflict' + }) + .toBe('conflict') + + await closeLastTabAndClickWorktree(orcaPage, remote.worktreeId) + + await expect + .poll(() => readTabCount(orcaPage, remote.worktreeId), { + timeout: 20_000, + message: 'no terminal came back after clicking the emptied worktree' + }) + .toBe(1) + await waitForActivePanePtyId(orcaPage, 60_000) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +})