mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(runtime): a slept remote workspace stays asleep through terminal orphan recovery (#30118)
Orphan recovery republished a pending host row as ready on the strength of the client's retained wake binding alone, even when the host's terminal inventory omitted the handle. The mirror then counted the dead handle as live, so a slept workspace came back awake in the sidebar. A pending row now stays pending unless the host's inventory lists its handle; the wake binding is still kept. Fixes #30108
This commit is contained in:
@@ -117,8 +117,14 @@ describe('web session terminal orphan inventory retries', () => {
|
||||
{ call: call as never }
|
||||
)
|
||||
|
||||
// An unlisted pending row stays pending; the live binding survives the apply either way.
|
||||
const unlistedPending = incoming === 'pending' && firstInventory.length === 0
|
||||
expect(first?.tabs).toEqual([
|
||||
expect.objectContaining({ leafId: LEAF_ID, status: 'ready', terminal: HANDLE })
|
||||
expect.objectContaining(
|
||||
unlistedPending
|
||||
? { leafId: LEAF_ID, status: 'pending-handle', terminal: null }
|
||||
: { leafId: LEAF_ID, status: 'ready', terminal: HANDLE }
|
||||
)
|
||||
])
|
||||
expect(appliedState.ptyIdsByTabId[localTabId]).toEqual(state.ptyIdsByTabId[localTabId])
|
||||
expect(appliedState.terminalLayoutsByTabId[localTabId]?.ptyIdsByLeafId).toEqual(
|
||||
@@ -248,7 +254,7 @@ describe('web session terminal orphan inventory retries', () => {
|
||||
)
|
||||
|
||||
expect(recovered?.tabs).toEqual([
|
||||
expect.objectContaining({ leafId: LEAF_ID, status: 'ready', terminal: HANDLE })
|
||||
expect.objectContaining({ leafId: LEAF_ID, status: 'pending-handle', terminal: null })
|
||||
])
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
+1
-4
@@ -607,10 +607,7 @@ describe('web session terminal orphan adoption regressions', () => {
|
||||
verdict === 'pending-without-proof'
|
||||
? {
|
||||
...projected,
|
||||
tabs: [
|
||||
projected.tabs[0],
|
||||
{ ...snapshot.tabs[1], status: 'ready', terminal: 'term-hold' }
|
||||
]
|
||||
tabs: [projected.tabs[0], snapshot.tabs[1]]
|
||||
}
|
||||
: projected
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
hasExactTerminalRetirementProof,
|
||||
hasStrongOrphanIdentity,
|
||||
withoutHostAttestation,
|
||||
type RecoveryDisposition,
|
||||
type RecoverySurface
|
||||
} from './web-session-terminal-orphan-recovery-surface'
|
||||
@@ -41,7 +42,7 @@ function fallback(
|
||||
removed: ReadonlySet<string>
|
||||
): TerminalOrphanInventoryResolution {
|
||||
return {
|
||||
retained: [...retainedBeforeListing, ...inventorySurfaces],
|
||||
retained: [...retainedBeforeListing, ...inventorySurfaces.map(withoutHostAttestation)],
|
||||
removed: new Set(removed),
|
||||
claims: [],
|
||||
topologyRevision: 0
|
||||
@@ -104,7 +105,7 @@ export async function resolveTerminalOrphanInventory(args: {
|
||||
const retainedBeforeListing = [...duplicateSurfaces, ...stableRetained]
|
||||
if (inventorySurfaces.length > 64) {
|
||||
return {
|
||||
retained: [...retainedBeforeListing, ...inventorySurfaces],
|
||||
retained: [...retainedBeforeListing, ...inventorySurfaces.map(withoutHostAttestation)],
|
||||
removed: provenRemoved,
|
||||
claims: [],
|
||||
topologyRevision: 0
|
||||
@@ -238,9 +239,11 @@ export async function resolveTerminalOrphanInventory(args: {
|
||||
}
|
||||
dispositions.set(surface.surfaceKey, disposition)
|
||||
}
|
||||
const retainedAfterListing = inventorySurfaces.filter(
|
||||
(surface) => dispositions.get(surface.surfaceKey) === 'retain'
|
||||
)
|
||||
const retainedAfterListing = inventorySurfaces
|
||||
.filter((surface) => dispositions.get(surface.surfaceKey) === 'retain')
|
||||
.map((surface) =>
|
||||
listedByHandle.has(surface.handle) ? surface : withoutHostAttestation(surface)
|
||||
)
|
||||
const removed = new Set(
|
||||
[
|
||||
...retiredSurfaces,
|
||||
|
||||
@@ -88,8 +88,8 @@ describe('web session terminal orphan recovery regressions', () => {
|
||||
expect.objectContaining({
|
||||
parentTabId: tabId,
|
||||
leafId: 'leaf-hold',
|
||||
status: 'ready',
|
||||
terminal: 'term-hold'
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
])
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
@@ -329,8 +329,8 @@ describe('web session terminal orphan recovery regressions', () => {
|
||||
expect.objectContaining({
|
||||
parentTabId: 'host-tab',
|
||||
leafId: 'leaf-1',
|
||||
status: 'ready',
|
||||
terminal: 'term-live'
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
}),
|
||||
editor
|
||||
])
|
||||
@@ -362,8 +362,8 @@ describe('web session terminal orphan recovery regressions', () => {
|
||||
expect(recovered?.tabs.slice(0, 2)).toEqual(snapshot.tabs.slice(0, 2))
|
||||
expect(recovered?.tabs[2]).toMatchObject({
|
||||
leafId: 'leaf-1',
|
||||
status: 'ready',
|
||||
terminal: 'term-live'
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -569,7 +569,7 @@ describe('web session terminal orphan recovery regressions', () => {
|
||||
expect(recovered?.tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ leafId: 'leaf-claim', terminal: 'term-claim', status: 'ready' }),
|
||||
expect.objectContaining({ leafId: 'leaf-hold', terminal: 'term-hold', status: 'ready' })
|
||||
expect.objectContaining({ leafId: 'leaf-hold', terminal: null, status: 'pending-handle' })
|
||||
])
|
||||
)
|
||||
expect(call).toHaveBeenNthCalledWith(
|
||||
@@ -669,7 +669,8 @@ describe('web session terminal orphan recovery regressions', () => {
|
||||
expect.objectContaining({
|
||||
parentTabId: 'hold-tab',
|
||||
leafId: 'leaf-hold',
|
||||
terminal: 'term-hold'
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import { isInactiveWorkspace } from '../lib/worktree-activity-state'
|
||||
import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '../lib/worktree-sleep-intent'
|
||||
import { listResult } from './__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures'
|
||||
import {
|
||||
clearWebSessionTerminalOrphanRecoveryForTests,
|
||||
recoverWebSessionTerminalOrphansBeforeApply
|
||||
} from './web-session-terminal-orphan-recovery'
|
||||
import type { TerminalOrphanRecoveryCall } from './web-session-terminal-orphan-recovery-adoption'
|
||||
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
|
||||
import {
|
||||
ENV,
|
||||
HOST_SURFACE_ID,
|
||||
LEAF_ID,
|
||||
NOW,
|
||||
WT,
|
||||
makeSnapshot,
|
||||
makeState,
|
||||
resetWebSessionTabsSyncTestState
|
||||
} from './web-session-tabs-sync-test-harness'
|
||||
|
||||
vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } }))
|
||||
|
||||
const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1')
|
||||
const HANDLE = 'terminal-1'
|
||||
const PTY_ID = `remote:${ENV}@@${HANDLE}`
|
||||
const FOLDER = 'folder:folder-1'
|
||||
|
||||
// Sleep keeps the layout binding as the wake binding and clears only the live map.
|
||||
function sleptState(worktreeId: string, livePtyIds: string[] = []) {
|
||||
return makeState({
|
||||
activeWorktreeId: 'another-workspace',
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: TAB_ID,
|
||||
ptyId: PTY_ID,
|
||||
worktreeId,
|
||||
title: 'claude',
|
||||
defaultTitle: 'claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW
|
||||
}
|
||||
]
|
||||
},
|
||||
activeTabIdByWorktree: { [worktreeId]: TAB_ID },
|
||||
ptyIdsByTabId: { [TAB_ID]: livePtyIds },
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function pendingFrame(worktreeId: string): RuntimeMobileSessionTabsResult {
|
||||
return makeSnapshot(
|
||||
[
|
||||
{
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
title: 'claude',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
}
|
||||
],
|
||||
{ worktree: worktreeId }
|
||||
)
|
||||
}
|
||||
|
||||
function inactive(state: ReturnType<typeof makeState>, worktreeId: string): boolean {
|
||||
return isInactiveWorkspace(worktreeId, state.tabsByWorktree, state.ptyIdsByTabId, {}, new Set())
|
||||
}
|
||||
|
||||
function listed(result: unknown): RuntimeRpcResponse<unknown> {
|
||||
return { id: 'list', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function listCall(listing: () => RuntimeRpcResponse<unknown>) {
|
||||
return vi.fn<TerminalOrphanRecoveryCall>(async ({ method }) => {
|
||||
if (method !== 'terminal.list') {
|
||||
throw new Error(`unexpected ${method}`)
|
||||
}
|
||||
return listing()
|
||||
})
|
||||
}
|
||||
|
||||
const INVENTORIES = [
|
||||
{
|
||||
name: 'a complete inventory',
|
||||
listing: () => listed(listResult(WT, []))
|
||||
},
|
||||
{
|
||||
name: 'an older host inventory without hostScope',
|
||||
listing: () => listed({ ...listResult(WT, []), hostScope: undefined })
|
||||
},
|
||||
{
|
||||
name: 'a truncated inventory',
|
||||
listing: () => listed(listResult(WT, [], { truncated: true }))
|
||||
},
|
||||
{
|
||||
name: 'a failed inventory',
|
||||
listing: (): RuntimeRpcResponse<unknown> => {
|
||||
throw new Error('connection lost')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
describe('terminal orphan recovery of a slept workspace (#30108)', () => {
|
||||
beforeEach(() => {
|
||||
clearWebSessionTerminalOrphanRecoveryForTests()
|
||||
resetWebSessionTabsSyncTestState()
|
||||
})
|
||||
afterEach(() => {
|
||||
clearWorktreeSleepIntent(WT)
|
||||
clearWorktreeSleepIntent(FOLDER)
|
||||
})
|
||||
|
||||
it.each(
|
||||
[WT, FOLDER].flatMap((worktreeId) =>
|
||||
INVENTORIES.map((inventory) => ({ worktreeId, ...inventory }))
|
||||
)
|
||||
)('keeps $worktreeId asleep when $name omits its handle', async ({ worktreeId, listing }) => {
|
||||
const state = sleptState(worktreeId)
|
||||
markWorktreeSleepIntent(worktreeId)
|
||||
const frame = pendingFrame(worktreeId)
|
||||
const call = listCall(listing)
|
||||
|
||||
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, frame, ENV, {
|
||||
call
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledOnce()
|
||||
expect(recovered?.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
leafId: LEAF_ID,
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
])
|
||||
const applied = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(state, recovered!, ENV, NOW + 1)
|
||||
}
|
||||
expect(applied.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF_ID]: PTY_ID
|
||||
})
|
||||
expect(applied.ptyIdsByTabId[TAB_ID] ?? []).toEqual([])
|
||||
expect(inactive(applied, worktreeId)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an already-live binding live through the same pending gap', async () => {
|
||||
const state = sleptState(WT, [PTY_ID])
|
||||
const call = listCall(() => listed(listResult(WT, [])))
|
||||
|
||||
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
pendingFrame(WT),
|
||||
ENV,
|
||||
{ call }
|
||||
)
|
||||
const applied = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(state, recovered!, ENV, NOW + 1)
|
||||
}
|
||||
|
||||
expect(applied.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF_ID]: PTY_ID
|
||||
})
|
||||
expect(applied.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID])
|
||||
expect(inactive(applied, WT)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,8 @@ type RecoverySurfaceCoordinates = {
|
||||
locallyActive: boolean
|
||||
/** A persisted binding whose leaf no longer appears in the layout tree. */
|
||||
offTree?: boolean
|
||||
/** A pending row whose handle no host inventory vouched for; it must stay pending, not ready. */
|
||||
unattested?: boolean
|
||||
}
|
||||
|
||||
export type RecoverySurface = RecoverySurfaceCoordinates & { handle: string }
|
||||
@@ -169,7 +171,8 @@ export function prepareTerminalOrphanRecovery(
|
||||
export function buildRetainedTerminalSurface(surface: AnyRecoverySurface): TerminalSurface {
|
||||
const incoming = surface.incoming
|
||||
const localTitle = typeof surface.localTab.title === 'string' ? surface.localTab.title.trim() : ''
|
||||
if (!surface.handle) {
|
||||
// Why: a ready row counts as a live terminal, so a slept pane (#30108) would wake in the mirror.
|
||||
if (!surface.handle || surface.unattested) {
|
||||
return {
|
||||
...(incoming ?? {
|
||||
type: 'terminal',
|
||||
@@ -212,6 +215,11 @@ export function buildRetainedTerminalSurface(surface: AnyRecoverySurface): Termi
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks a pending surface the host's terminal inventory did not list as live. */
|
||||
export function withoutHostAttestation(surface: RecoverySurface): RecoverySurface {
|
||||
return surface.pending ? { ...surface, unattested: true } : surface
|
||||
}
|
||||
|
||||
export function mergeRetainedTerminalSurfaces(
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
surfaces: readonly AnyRecoverySurface[],
|
||||
|
||||
@@ -482,7 +482,7 @@ describe('web session pending terminal handle recovery', () => {
|
||||
{ call: call as never }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
tabs: [expect.objectContaining({ status: 'ready', terminal: TERMINAL_HANDLE })]
|
||||
tabs: [expect.objectContaining({ status: 'pending-handle', terminal: null })]
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user