mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(runtime): a slept remote workspace stays hidden after host updates and restarts (#29437)
* fix(runtime): keep pending wake bindings out of live terminal activity * test(runtime): cover slept snapshot handle wait before resume * test(runtime): follow main's web-session-tabs-sync module split --------- Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import path from 'node:path'
|
||||
import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api'
|
||||
import {
|
||||
makeSnapshot,
|
||||
resetWebSessionTabsSyncTestState
|
||||
} from '@/runtime/web-session-tabs-sync-test-harness'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
|
||||
@@ -274,6 +279,47 @@ describe('resume across the mirror handle gap', () => {
|
||||
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
|
||||
})
|
||||
|
||||
it('holds a slept binding retained through a pending snapshot without replacing it', () => {
|
||||
resetWebSessionTabsSyncTestState()
|
||||
const worktree = makeRuntimeOwnedWorktree()
|
||||
seedMirroredWorkspace(worktree)
|
||||
const paneKey = seedActiveSleepingRecord(worktree.id)
|
||||
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
|
||||
const snapshot = makeSnapshot(
|
||||
[
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `host-tab-1::${LEAF_ID}`,
|
||||
title: 'Claude',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
}
|
||||
],
|
||||
{ worktree: worktree.id }
|
||||
)
|
||||
useAppStore.setState(
|
||||
applyWebSessionTabsSnapshot(useAppStore.getState(), snapshot, RUNTIME_ENV_ID, Date.now())
|
||||
)
|
||||
|
||||
expect(
|
||||
useAppStore.getState().terminalLayoutsByTabId[WEB_TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID]
|
||||
).toBe('remote:env-handle-gap@@term_1')
|
||||
expect(useAppStore.getState().ptyIdsByTabId[WEB_TAB_ID] ?? []).toEqual([])
|
||||
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
|
||||
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
|
||||
vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS - 1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0)
|
||||
vi.advanceTimersByTime(1)
|
||||
// The deadline ends the wait, but an unverifiable binding never authorizes a replacement.
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0)
|
||||
resetWebSessionTabsSyncTestState()
|
||||
})
|
||||
|
||||
it('does not resume a published host row without a PTY binding', () => {
|
||||
const worktree = makeRuntimeOwnedWorktree()
|
||||
seedMirroredWorkspace(worktree)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import { isInactiveWorkspace } from '../lib/worktree-activity-state'
|
||||
import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '../lib/worktree-sleep-intent'
|
||||
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
|
||||
import {
|
||||
ENV,
|
||||
HOST_SURFACE_ID,
|
||||
LEAF_ID,
|
||||
NOW,
|
||||
SECOND_LEAF_ID,
|
||||
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 PTY_ID = `remote:${ENV}@@terminal-1`
|
||||
const FOLDER = 'folder:folder-1'
|
||||
|
||||
function sleptState(worktreeId = WT, ptyId = PTY_ID) {
|
||||
return makeState({
|
||||
activeWorktreeId: 'another-workspace',
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: TAB_ID,
|
||||
ptyId,
|
||||
worktreeId,
|
||||
title: 'shell',
|
||||
defaultTitle: 'shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { [TAB_ID]: [] },
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function pendingSnapshot(worktreeId = WT) {
|
||||
return makeSnapshot(
|
||||
[
|
||||
{
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
title: 'shell',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
}
|
||||
],
|
||||
{ worktree: worktreeId }
|
||||
)
|
||||
}
|
||||
|
||||
function inactive(state: ReturnType<typeof makeState>, worktreeId = WT) {
|
||||
return isInactiveWorkspace(worktreeId, state.tabsByWorktree, state.ptyIdsByTabId, {}, new Set())
|
||||
}
|
||||
|
||||
describe('sleep activity during host terminal mirroring', () => {
|
||||
beforeEach(resetWebSessionTabsSyncTestState)
|
||||
afterEach(() => {
|
||||
clearWorktreeSleepIntent(WT)
|
||||
clearWorktreeSleepIntent(FOLDER)
|
||||
})
|
||||
|
||||
it.each([WT, FOLDER])('keeps %s asleep while retaining its wake binding', (worktreeId) => {
|
||||
const state = sleptState(worktreeId)
|
||||
markWorktreeSleepIntent(worktreeId)
|
||||
expect(inactive(state, worktreeId)).toBe(true)
|
||||
|
||||
const next = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(state, pendingSnapshot(worktreeId), ENV, NOW + 1)
|
||||
}
|
||||
|
||||
expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: PTY_ID })
|
||||
expect(next.tabsByWorktree[worktreeId]?.[0]?.ptyId).toBe(PTY_ID)
|
||||
expect(next.ptyIdsByTabId[TAB_ID] ?? []).toEqual([])
|
||||
expect(inactive(next, worktreeId)).toBe(true)
|
||||
expect(next.activeWorktreeId).toBe('another-workspace')
|
||||
})
|
||||
|
||||
it('does not turn a restored wake binding into live activity without a sleep marker', () => {
|
||||
const state = sleptState()
|
||||
const next = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1)
|
||||
}
|
||||
expect(inactive(next)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a previously live binding during an ordinary reconnect', () => {
|
||||
const state = sleptState()
|
||||
state.ptyIdsByTabId[TAB_ID] = [PTY_ID]
|
||||
const next = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1)
|
||||
}
|
||||
expect(next.ptyIdsByTabId[TAB_ID]).toEqual([PTY_ID])
|
||||
expect(inactive(next)).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes a ready replacement after explicit wake', () => {
|
||||
const state = sleptState()
|
||||
markWorktreeSleepIntent(WT)
|
||||
clearWorktreeSleepIntent(WT)
|
||||
const snapshot = makeSnapshot([
|
||||
{
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
title: 'shell',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-2'
|
||||
}
|
||||
])
|
||||
const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) }
|
||||
expect(next.ptyIdsByTabId[TAB_ID]).toEqual([`remote:${ENV}@@terminal-2`])
|
||||
expect(inactive(next)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only ready panes when a slept split sibling is still pending', () => {
|
||||
const state = sleptState()
|
||||
const snapshot = pendingSnapshot()
|
||||
snapshot.tabs.push({
|
||||
type: 'terminal',
|
||||
id: `host-tab-1::${SECOND_LEAF_ID}`,
|
||||
title: 'new shell',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: SECOND_LEAF_ID,
|
||||
isActive: false,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-2'
|
||||
})
|
||||
const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) }
|
||||
expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID]).toBe(PTY_ID)
|
||||
expect(next.ptyIdsByTabId[TAB_ID]).toEqual([`remote:${ENV}@@terminal-2`])
|
||||
expect(inactive(next)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the wake binding inactive across repeated host updates', () => {
|
||||
const state = sleptState()
|
||||
const snapshot = pendingSnapshot()
|
||||
const next = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + 1) }
|
||||
const updatedSnapshot = { ...snapshot, snapshotVersion: 2 }
|
||||
const updated = {
|
||||
...next,
|
||||
...applyWebSessionTabsSnapshot(next, updatedSnapshot, ENV, NOW + 2)
|
||||
}
|
||||
expect(inactive(updated)).toBe(true)
|
||||
expect(updated.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: PTY_ID })
|
||||
})
|
||||
|
||||
it('keeps local-mode pending wake bindings out of live activity', () => {
|
||||
const state = sleptState(WT, 'local-pty')
|
||||
const next = {
|
||||
...state,
|
||||
...applyWebSessionTabsSnapshot(state, pendingSnapshot(), ENV, NOW + 1, {
|
||||
terminalPtyMode: 'local'
|
||||
})
|
||||
}
|
||||
expect(next.terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'local-pty' })
|
||||
expect(inactive(next)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,7 @@ export function prepareWebSessionTabsSnapshotBase(
|
||||
environmentId,
|
||||
existingTerminalById,
|
||||
state.terminalLayoutsByTabId,
|
||||
state.ptyIdsByTabId,
|
||||
retainedTerminalTabs.length,
|
||||
now,
|
||||
callerFocusIntentTab?.type === 'terminal'
|
||||
|
||||
+9
-1
@@ -55,7 +55,15 @@ function rebuild(existing?: Partial<TerminalTab>): TerminalTab {
|
||||
const existingById = new Map<string, TerminalTab>(
|
||||
existing ? [[localTabId, { id: localTabId, ...existing } as TerminalTab]] : []
|
||||
)
|
||||
const [mirrored] = buildMirroredTerminalTabs(snapshot(), ENVIRONMENT, existingById, {}, 0, 1_000)
|
||||
const [mirrored] = buildMirroredTerminalTabs(
|
||||
snapshot(),
|
||||
ENVIRONMENT,
|
||||
existingById,
|
||||
{},
|
||||
{},
|
||||
0,
|
||||
1_000
|
||||
)
|
||||
return mirrored!.tab
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export function buildMirroredTerminalTabs(
|
||||
environmentId: string,
|
||||
existingById: ReadonlyMap<string, TerminalTab>,
|
||||
existingLayoutsByTabId: Readonly<Record<string, TerminalLayoutSnapshot>>,
|
||||
existingLivePtyIdsByTabId: Readonly<Record<string, readonly string[]>>,
|
||||
sortOffset: number,
|
||||
now: number,
|
||||
focusTarget?: { parentTabId: string; leafId: string },
|
||||
@@ -101,7 +102,14 @@ export function buildMirroredTerminalTabs(
|
||||
chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId)
|
||||
).snapshot
|
||||
const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {})
|
||||
const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId)
|
||||
// Wake bindings survive Sleep; only ready surfaces or already-live bindings count as activity.
|
||||
const livePtyIds = new Set([
|
||||
...Object.values(freshPtyIdsByLeafId),
|
||||
...(existingLivePtyIdsByTabId[localTabId] ?? [])
|
||||
])
|
||||
const ptyIds = layoutPtyEntries
|
||||
.map(([, ptyId]) => ptyId)
|
||||
.filter((ptyId) => livePtyIds.has(ptyId))
|
||||
let retainedSurfaceByPrunedLeafId: Map<string, TerminalSurface> | undefined
|
||||
if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) {
|
||||
const retainedLeafIdByPtyId = new Map(
|
||||
|
||||
Reference in New Issue
Block a user