From 550f17b9be0ac5e90e0b35040dd2f2c070a1d886 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 14:03:56 -0400 Subject: [PATCH 1/2] fix(pty): answer pty:hasPty from the daemon readback on a cache miss A retained orchestration worker's pane re-attaches renderer-only after an app restart, so the daemon adapter's hasPty cache never learns the session. The visibility reconciler tore the tab down on that fabricated false while the daemon still ran the process (#16904 regression: settled workers now enter the retained path). pty:hasPty now asks the provider's readback before answering absent; a cache hit stays a cheap sync answer. --- ...pty-adapter-protocol-compatibility.test.ts | 8 +- ...on-pty-adapter-unattached-presence.test.ts | 45 ++++++++ src/main/daemon/daemon-pty-session-control.ts | 5 + .../pty-has-pty-cache-miss-presence.test.ts | 105 ++++++++++++++++++ src/main/ipc/pty/ipc/inspect.ts | 8 +- src/main/ipc/pty/provider/liveness.ts | 19 ++++ 6 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 src/main/daemon/daemon-pty-adapter-unattached-presence.test.ts create mode 100644 src/main/ipc/pty-has-pty-cache-miss-presence.test.ts diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index a2f2cbf3497e..844a04759276 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts @@ -486,9 +486,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const probeAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion }) ;( probeAdapter as unknown as { - client: { request: ReturnType; disconnect: ReturnType } + client: { + request: ReturnType + disconnect: ReturnType + isConnected: () => boolean + } } - ).client = { request, disconnect: vi.fn() } + ).client = { request, disconnect: vi.fn(), isConnected: () => true } return probeAdapter } diff --git a/src/main/daemon/daemon-pty-adapter-unattached-presence.test.ts b/src/main/daemon/daemon-pty-adapter-unattached-presence.test.ts new file mode 100644 index 000000000000..0c22f57980e9 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-unattached-presence.test.ts @@ -0,0 +1,45 @@ +/* A fresh adapter (new app process) answering presence for a session an older process spawned. */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { rmSync } from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { resolveProviderPtyPresence } from '../ipc/pty/provider/liveness' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' + +describe('DaemonPtyAdapter presence for a session this process never attached', () => { + let harness: Awaited> + let restoredAdapter: DaemonPtyAdapter + + beforeEach(async () => { + harness = await startDaemonAdapterHarness(() => createMockSubprocess()) + restoredAdapter = new DaemonPtyAdapter({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath + }) + }) + afterEach(async () => { + restoredAdapter.dispose() + harness.adapter.dispose() + await harness.server.shutdown() + rmSync(harness.dir, { recursive: true, force: true }) + }) + + it('reads the daemon instead of trusting its own empty cache', async () => { + const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 }) + + // The cache is what `hasPty` alone answers; it is empty because nothing in this process + // attached the session. The daemon still owns a live process under that id. + expect(restoredAdapter.hasPty(id)).toBe(false) + await expect(resolveProviderPtyPresence(restoredAdapter, id)).resolves.toBe(true) + }) + + it('still answers absent for an id the daemon never had', async () => { + await expect(resolveProviderPtyPresence(restoredAdapter, 'never-spawned')).resolves.toBe(false) + }) + + it('answers absent after the session exits', async () => { + const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 }) + await harness.adapter.shutdown(id, { immediate: true }) + + await expect(resolveProviderPtyPresence(restoredAdapter, id)).resolves.toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index c10be5739263..df935c85bc31 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -49,6 +49,11 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { async probePtyLiveness(id: string): Promise { try { + // Why: a fresh adapter (new app process) has never connected; without this the readback + // answers `null` for every session an older process spawned, and the cache miss stands. + if (!this.client.isConnected()) { + await this.ensureConnected(Date.now() + LIVENESS_PROBE_TIMEOUT_MS) + } if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) { try { const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( diff --git a/src/main/ipc/pty-has-pty-cache-miss-presence.test.ts b/src/main/ipc/pty-has-pty-cache-miss-presence.test.ts new file mode 100644 index 000000000000..663f751e2402 --- /dev/null +++ b/src/main/ipc/pty-has-pty-cache-miss-presence.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +// A retained orchestration worker's pane re-attaches renderer-only after an app restart, so the +// daemon adapter's `hasPty` cache never learns the session. The renderer's visibility reconciler +// closes a pane on exactly the `false` that cache miss used to produce, even though the daemon +// still runs the process (Slack thread "disappearing orchestrator-worker tabs", #16904 regression). +describe('pty:hasPty on a daemon-adapter cache miss', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + it('asks the provider readback before answering absent', async () => { + const probePtyLiveness = vi.fn(async (id: string) => id === 'retained-worker-pty') + installDaemonTestProvider({ hasPty: () => false, probePtyLiveness }) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'retained-worker-pty' })).resolves.toBe( + true + ) + expect(probePtyLiveness).toHaveBeenCalledWith('retained-worker-pty') + }) + + it('keeps the readback verdict when the daemon really has no such session', async () => { + installDaemonTestProvider({ + hasPty: () => false, + probePtyLiveness: vi.fn(async () => false) + }) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'reaped-pty' })).resolves.toBe(false) + }) + + it('answers unverifiable when the readback cannot reach the daemon', async () => { + installDaemonTestProvider({ + hasPty: () => false, + probePtyLiveness: vi.fn(async () => null) + }) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'unreachable-pty' })).resolves.toBe(null) + }) + + it('does not spend a readback on a cache hit', async () => { + const probePtyLiveness = vi.fn(async () => false) + installDaemonTestProvider({ hasPty: (id: string) => id === 'attached-pty', probePtyLiveness }) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'attached-pty' })).resolves.toBe(true) + expect(probePtyLiveness).not.toHaveBeenCalled() + }) + + it('keeps the sole in-process provider authoritative when it has no readback', async () => { + // The in-process LocalPtyProvider is its own only owner (#12393): its cache IS the process table. + installDaemonTestProvider({ hasPty: () => false }) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'never-spawned-pty' })).resolves.toBe( + false + ) + }) +}) diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 5a6d03d8855b..5e8d302c2b19 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -8,6 +8,7 @@ import { } from '../../../providers/pty-process-list-admission' import type { PtyListedSession } from '../../../../shared/pty-listed-session' import { ptyOwnership } from '../provider/ownership-state' +import { resolveProviderPtyPresence } from '../provider/liveness' import { getProviderForPty, hasPtyProviderForInspection, @@ -137,13 +138,14 @@ export function installPtyInspectIpcHandlers(deps: { const provider = parsedSshId ? sshProviders.get(parsedSshId.connectionId) : tryGetProviderForPty(args.id) - if (!provider?.hasPty) { + if (!provider) { return null } try { - return provider.hasPty(args.id) + // Why: liveness is only allowed to close panes on an authoritative false, and the daemon + // adapter's `hasPty` is a cache of what THIS process attached, not what the daemon runs. + return await resolveProviderPtyPresence(provider, args.id) } catch { - // Why: liveness is only allowed to close panes on an authoritative false. return null } }) diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 8a362b74aaf2..ff6094b78bdd 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -104,6 +104,25 @@ export function delay(ms: number): Promise { }) } +/** + * The presence answer a renderer may tear a pane down on. `hasPty` on the daemon adapter is an + * in-process cache of the sessions THIS process attached; a pane that attached renderer-only (a + * retained orchestration worker after an app restart) never warms it, so a cache miss is not an + * observation. Only the owner's readback may answer `false` (docs/reference/ssh-execution-boundary.md). + */ +export async function resolveProviderPtyPresence( + provider: IPtyProvider, + ptyId: string +): Promise { + if (!provider.hasPty) { + return null + } + if (provider.hasPty(ptyId)) { + return true + } + return provider.probePtyLiveness ? await provider.probePtyLiveness(ptyId) : false +} + export async function isProviderPtyLive( provider: IPtyProvider, ptyId: string, From a26496e48fb38f2399f1a231c2c2999a16515538 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 14:15:28 -0400 Subject: [PATCH 2/2] test(e2e): keep a settled worker tab across app restart and reveal --- ...ettled-worker-tab-survives-restart.spec.ts | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 tests/e2e/settled-worker-tab-survives-restart.spec.ts diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts new file mode 100644 index 000000000000..50ca7a749084 --- /dev/null +++ b/tests/e2e/settled-worker-tab-survives-restart.spec.ts @@ -0,0 +1,384 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override' +import { + cleanupCompletedWorkerFixture, + clearCompletedWorkerLedger, + completedWorkerFakeCodexCommand, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + readPersistedWorkerRecoveryRecord, + seedCurrentCodexTranscript +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree/id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dd' + +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + cleanupCompletedWorkerFixture() +}) + +async function findSecondaryWorktree( + page: Page, + client: RuntimeClient, + coordinatorWorktreeId: string +): Promise { + let targetWorktreeId: string | null = null + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + // The restart fixture only waits for the primary; refetch until the seeded secondary lands. + const rendererWorktreeIds = await page.evaluate(async () => { + const store = window.__store + if (!store) { + return [] + } + await Promise.all( + store.getState().repos.map((repo) => store.getState().fetchWorktrees(repo.id)) + ) + return Object.values(store.getState().worktreesByRepo) + .flat() + .map((worktree) => worktree.id) + }) + targetWorktreeId = + listed.result.worktrees.find( + (worktree) => + worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id) + )?.id ?? null + return targetWorktreeId + }, + { timeout: 60_000, message: 'runtime never registered the secondary worktree' } + ) + .not.toBeNull() + if (!targetWorktreeId) { + throw new Error('The seeded repository did not expose its secondary worktree') + } + return targetWorktreeId +} + +async function backgroundMountTab(page: Page, worktreeId: string, tabId: string): Promise { + await page.evaluate( + ({ tabId, worktreeId }) => { + window.dispatchEvent( + new CustomEvent('orca-background-mount-terminal-worktree', { + detail: { worktreeId, tabIds: [tabId] } + }) + ) + }, + { tabId, worktreeId } + ) + await expect + .poll(() => page.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), tabId)) + .toBe(true) +} + +// A worker that reported done while its terminal stays open is fenced from auto-resume, so after an +// app restart its pane re-attaches renderer-only. Revealing that pane used to tear the tab down on a +// fabricated "no PTY" while the daemon still ran the process (#16904 regression). +test('a settled orchestration worker tab survives reveal after an app restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + clearCompletedWorkerLedger() + + const session = createRestartSession(testInfo, completedWorkerLaunchEnv) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + const coordinatorWorktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page) + await waitForActivePanePtyId(first.page) + await first.page.evaluate( + async ({ agentCommand, terminalWindowsShell }) => { + await window.__store?.getState().updateSettings({ + agentCmdOverrides: { codex: agentCommand }, + terminalWindowsShell, + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }, + { + agentCommand: completedWorkerFakeCodexCommand, + terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL + } + ) + const isolatedHome = await firstApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(session.userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(first.page) + const coordinatorHandle = ( + await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey: coordinatorPane.paneKey + }) + ).result.terminal.handle + const targetWorktreeId = await findSecondaryWorktree(first.page, client, coordinatorWorktreeId) + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Keep one settled worker tab across restart', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion and stay open', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${targetWorktreeId}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId) { + throw new Error('Background worker did not publish its PTY') + } + const workerPtyId = worker.ptyId + const workerTabId = worker.tabId + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + await backgroundMountTab(first.page, targetWorktreeId, workerTabId) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + await first.page.evaluate( + ({ + agentCommand, + paneKey, + providerSessionId, + tabId, + terminalHandle, + transcriptPath, + worktreeId + }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession: { key: 'session_id' as const, id: providerSessionId, transcriptPath }, + launchConfig: { + agentCommand, + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + for (const agentState of ['working', 'done'] as const) { + state.setAgentStatus( + paneKey, + { state: agentState, prompt: 'Report completion and stay open', agentType: 'codex' }, + 'Settled background worker', + undefined, + metadata, + recovery + ) + } + }, + { + agentCommand: completedWorkerFakeCodexCommand, + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: workerTabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed and stays open for inspection.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + // The settlement sweep stamps the resume fence on the renderer's record before the tab closes. + await expect + .poll( + () => + first.page.evaluate( + (paneKey) => + window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + ?.automaticResumeBlockedBy ?? null, + workerPaneKey + ), + { timeout: 30_000, message: 'settled worker pane was never fenced' } + ) + .toBe('legacy-orchestration-worker') + + await session.close(firstApp) + firstApp = null + expect(readPersistedWorkerRecoveryRecord(session.userDataDir, workerPaneKey)).toMatchObject({ + automaticResumeBlockedBy: 'legacy-orchestration-worker' + }) + expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([]) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + // The daemon kept the worker alive across the app restart; the new main process never attached it. + await expect + .poll( + async () => + (await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId) + ?.connected ?? null, + { timeout: 60_000, message: 'restarted runtime never rediscovered the worker PTY' } + ) + .toBe(true) + expect( + await second.page.evaluate( + ({ tabId, worktreeId }) => + Boolean( + window.__store?.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + { tabId: workerTabId, worktreeId: targetWorktreeId } + ) + ).toBe(true) + + // Hidden mount, then reveal: the reveal is what runs the missing-session reconciler. + await backgroundMountTab(second.page, targetWorktreeId, workerTabId) + expect + .soft( + await second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), workerPtyId), + 'liveness before reveal' + ) + .toBe(true) + await second.page.evaluate( + ({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + type Transition = { + activeWorktreeId: string | null + tabPresent: boolean + leafPtyIds: string[] + activeTabId: string | null + } + const snapshot = (state: ReturnType): Transition => ({ + activeWorktreeId: state.activeWorktreeId ?? null, + tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + leafPtyIds: Object.values(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}), + activeTabId: state.activeTabIdByWorktree[worktreeId] ?? null + }) + const transitions: Transition[] = [snapshot(store.getState())] + const e2eWindow = window as typeof window & { __orcaRevealTransitions?: Transition[] } + e2eWindow.__orcaRevealTransitions = transitions + store.subscribe((state) => { + const next = snapshot(state) + if (JSON.stringify(next) !== JSON.stringify(transitions.at(-1))) { + transitions.push(next) + } + }) + store.getState().setActiveWorktree(worktreeId) + }, + { tabId: workerTabId, worktreeId: targetWorktreeId } + ) + // Give the reconciler's async verdict time to land; the tab must never have left. + await second.page.waitForTimeout(3_000) + const transitions = await second.page.evaluate( + () => + ( + window as typeof window & { + __orcaRevealTransitions?: { + activeWorktreeId: string | null + tabPresent: boolean + leafPtyIds: string[] + }[] + } + ).__orcaRevealTransitions ?? [] + ) + // Pre-fix this read: leaf binding cleared -> tab removed -> worktree deselected -> tab re-added by graph sync. + expect( + transitions.filter((step) => !step.tabPresent || step.leafPtyIds.length === 0), + 'reveal must not tear the settled worker tab down' + ).toEqual([]) + expect(transitions.at(-1)?.activeWorktreeId).toBe(targetWorktreeId) + expect( + await second.page.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), workerTabId) + ).toBe(true) + expect( + (await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId) + ?.connected + ).toBe(true) + expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([]) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})