mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
* 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>
134 lines
4.7 KiB
TypeScript
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)
|
|
}
|