Files
orca/tests/e2e/helpers/slept-workspace-probe.ts
20c56249d5 fix(terminal): keep a deliberately slept workspace cold until it is woken (#20075)
* fix(terminal): keep a deliberately slept workspace cold until it is woken

Sleeping a workspace kills its PTYs but keeps its panes mounted and keeps each
tab's session id as a wake hint. Any later remount of those panes (recovery,
parking, portals) reattached that dead id, and the daemon's create-or-attach
spawned a fresh shell, so slept workspaces revived on their own (#10205).

The existing sleep-intent marker now outlives teardown and gates the deferred
connect itself, so both the reattach and fresh-spawn arms stay cold. It is
released by activating the workspace, by any PTY binding to one of its tabs
(CLI, automation, client wake), and by purge. A queued startup still connects.

Reproduces the community root cause from gatsby74 in #13343; the regression
e2e remounts a slept hidden pane and fails on main.

Co-authored-by: gatsby74 <gatsby74@users.noreply.github.com>

Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>

* fix(terminal): let a slept pane wait for its wake instead of latching cold

A pane whose connect ran while its workspace was slept used to mark itself
connected and stop; nothing re-armed it, so a wake that produced a live PTY
before the user clicked (CLI create, background agent resume, split panes)
left panes stranded. The connect now waits on the sleep marker and resumes
when the marker clears, and a torn-down pane drops its listener.

Tabs created with a live PTY clear the marker too, the sleep flow marks each
workspace only when its own teardown starts, and purge forgets the marker
without waking anything.

* fix(terminal): wake a waiting pane once, in its remounted generation

Activation clears the sleep marker after the set() that bumps dead tabs'
generations, and the waiting pane only resumes its connect when its tab
generation is still current. Otherwise the stale pane and its remounted
successor both reattached the same session id on a deliberate wake.

* fix(terminal): resolve the waiting pane's tab by either id and re-arm after wake

The wake listener looked the tab up by the pane's render id, which can be a
unified id whose terminal tab lives under entityId, so the generation check
declined forever for those panes. Mount, fresh spawn, and the wake listener now
share one live resolver. The wait flag resets when the listener fires so a
second sleep can hold the pane again, listener dispatch is guarded, folder
activation clears after its own set(), and the sleep flow re-asserts the marker
after each teardown while releasing a workspace the user activated meanwhile.

* fix(terminal): ignore PTY binds that land inside the sleep teardown window

A spawn resolving while shutdown was still awaiting the host bound a PTY and
cleared the marker, waking every waiting pane mid-sleep; re-marking afterwards
could not un-connect them. The sleep flow now scopes each teardown so binds in
that window are not wakes. The e2e asserts a deliberate wake yields exactly one
PTY, and the dispose test proves the listener is gone.

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>
2026-09-11 01:45:57 -07:00

134 lines
4.7 KiB
TypeScript

/**
* Shared probes for GH #10205: a deliberately slept workspace must stay cold.
* Drives the shipping sleep path (sidebar context menu) and reads both the
* renderer's live PTY model and host truth.
*/
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
import { ensureTerminalVisible } from './store'
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal'
export type WorkspaceSample = {
livePtyCount: number
tabCount: number
tabIds: string[]
mountedTabIds: string[]
tabPtyHints: (string | null)[]
}
export function rowLocator(page: Page, worktreeId: string): Locator {
return page
.locator(
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]`
)
.first()
}
export async function readWorkspaceSample(
page: Page,
worktreeId: string
): Promise<WorkspaceSample> {
return page.evaluate((id) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('window.__store is not available')
}
const tabs = state.tabsByWorktree[id] ?? []
const tabIds = new Set(tabs.map((tab) => tab.id))
const managers = window.__paneManagers
return {
livePtyCount: tabs.reduce(
(count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0),
0
),
tabCount: tabs.length,
tabIds: tabs.map((tab) => tab.id),
mountedTabIds: managers
? Array.from(managers.keys()).filter((tabId) => tabIds.has(tabId))
: [],
tabPtyHints: tabs.map((tab) => tab.ptyId ?? null)
}
}, worktreeId)
}
/** Host-side truth: a revived workspace shows a freshly created live session here. */
export async function readHostLiveTerminalCount(page: Page, worktreeId: string): Promise<number> {
return (await page.evaluate(async (id) => {
const result = await window.api.runtime.call({
method: 'terminal.list',
params: { worktree: `id:${id}`, requireFreshPtyLiveness: true }
})
if (!result.ok) {
throw new Error(result.error.message)
}
return (result.result as { totalCount: number }).totalCount
}, worktreeId)) as number
}
/** Connect-verdict lines (REATTACH / ATTACH / FRESH SPAWN / SKIP SPAWN) for one workspace. */
export async function readConnectDiagnostics(page: Page, worktreeId: string): Promise<string[]> {
return page.evaluate((id) => {
const state = window.__store?.getState()
const target = globalThis as unknown as Record<string, unknown>
const diag = (target.__ptyConnectDiag as string[] | undefined) ?? []
const tabIds = new Set((state?.tabsByWorktree[id] ?? []).map((tab) => tab.id))
// Pane ids restart at 1 per worktree, so a verdict line is attributed to the
// tab named by the most recent connect line for that same pane id.
const tabByPaneId = new Map<string, string>()
const owned: string[] = []
for (const line of diag) {
const connect = /^pane=(\d+) tab=(\S+) /.exec(line)
if (connect) {
tabByPaneId.set(connect[1], connect[2])
if (tabIds.has(connect[2])) {
owned.push(line)
}
continue
}
const verdict = /^pane=(\d+) ->/.exec(line)
if (verdict) {
const tabId = tabByPaneId.get(verdict[1])
if (tabId && tabIds.has(tabId)) {
owned.push(line)
}
}
}
return owned
}, worktreeId)
}
export async function giveWorkspaceALivePty(page: Page, worktreeId: string): Promise<string> {
await page.evaluate((id) => {
window.__store?.getState().setActiveWorktree(id)
}, worktreeId)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
return waitForActivePanePtyId(page, 30_000)
}
/** The shipping sleep path: right-click the sidebar row, click "Sleep". */
export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string): Promise<void> {
const row = rowLocator(page, worktreeId)
await expect(row).toBeVisible()
await row.scrollIntoViewIfNeeded()
const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first()
const target = (await scope.count()) > 0 ? scope : row
await target.click({ button: 'right' })
const sleepItem = page.getByRole('menuitem', { name: 'Sleep', exact: true }).first()
await expect(sleepItem).toBeVisible()
await sleepItem.click()
}
export async function activateWorkspaceByClick(page: Page, worktreeId: string): Promise<void> {
const row = rowLocator(page, worktreeId)
await expect(row).toBeVisible()
await row.scrollIntoViewIfNeeded()
await row.click()
await expect
.poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), {
timeout: 10_000,
message: `sidebar click did not activate ${worktreeId}`
})
.toBe(worktreeId)
}