This commit is contained in:
Neil
2026-09-16 08:02:09 +00:00
committed by GitHub
7 changed files with 230 additions and 73 deletions
@@ -3,15 +3,16 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects'
import type { TerminalColdActivationController } from '../terminal-cold-activation'
const mocks = vi.hoisted(() => ({
gate: vi.fn(),
resume: vi.fn(),
authority: 'none',
launchStatus: vi.fn((_worktreeId: string, _provider: string): string => 'idle'),
createTab: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(() => 'none', {
useAppStore: Object.assign(() => mocks.authority, {
getState: () => ({ activeWorktreeId: 'wt-1' })
})
}))
@@ -22,7 +23,7 @@ vi.mock('@/lib/structured-agent-session-launch', () => ({
getStructuredAgentLaunchStatus: mocks.launchStatus
}))
vi.mock('@/lib/resume-sleeping-agent-session', () => ({
resumeSleepingAgentSessionsForWorktree: vi.fn()
resumeSleepingAgentSessionsForWorktree: mocks.resume
}))
vi.mock('@/lib/workspace-terminal-host-authority', () => ({
createWorkspaceTerminalHostAuthoritySelector: () => () => 'none'
@@ -34,23 +35,48 @@ vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({
disposeAllParkedTerminalWatchers: vi.fn()
}))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
let root: Root | undefined
afterEach(async () => {
await act(async () => root?.unmount())
vi.clearAllMocks()
mocks.authority = 'none'
})
function Watcher(): null {
function Watcher({ restored = true, hydrated = false, worktreeId = 'wt-1' } = {}): null {
useTerminalWatcherEffects({
activeWorktreeId: 'wt-1',
activeWorktreeId: worktreeId,
workspaceSessionReady: true,
terminalStartupRestorationReady: true,
terminalStartupRestorationReady: restored,
hydrationSucceeded: hydrated,
workspaceSurfaceIds: [],
tabsByWorktree: {},
createTab: mocks.createTab,
reconcileWorktreeTabModel: () => ({ renderableTabCount: 0 })
} as unknown as TerminalColdActivationController)
reconcileWorktreeTabModel: () => ({
renderableTabCount: 0,
activeRenderableTabId: null
}),
activationDeferredMountTabIdsByWorktreeRef: { current: new Map() },
activeTabId: null,
activeTabIdByWorktree: {},
activeView: 'terminal',
activityTerminalPortals: [],
anyMountedWorktreeHasLayout: false,
backgroundMountRevision: 0,
effectiveParkedTerminalWorktreeIds: new Set(),
evictionExemptTerminalTabIds: new Set(),
getEffectiveLayoutForWorktree: () => undefined,
groupsByWorktree: {},
measurableBackgroundWorktreeIdsRef: { current: new Set() },
mountedWorktreeIdsRef: { current: new Set() },
pairedRuntimeParkingEnvironmentIds: new Set(),
pendingStartupByTabId: {},
renderedActiveWorktreeId: worktreeId,
terminalParkingEnabled: false,
terminalProviderSnapshotCapabilityRevision: 0,
terminalSshParkingEnabled: false,
terminalTitleSnapshotAuthorityEnabled: false
})
return null
}
@@ -81,3 +107,51 @@ describe('passive terminal seeding during native chat creation', () => {
expect(mocks.createTab).toHaveBeenCalledTimes(expectedTabs)
})
})
describe('startup agent recovery host inventory', () => {
it('keeps recovery available until the execution host answers', async () => {
mocks.authority = 'unverifiable'
mocks.gate.mockResolvedValue('adopted')
root = createRoot(document.createElement('div'))
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).not.toHaveBeenCalled()
expect(mocks.resume).not.toHaveBeenCalled()
mocks.authority = 'live'
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledTimes(1)
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledTimes(1)
})
it('waits for terminal restoration, then uses the activation gate', async () => {
mocks.authority = 'live'
mocks.gate.mockResolvedValue('adopted')
root = createRoot(document.createElement('div'))
await act(async () => root?.render(<Watcher hydrated restored={false} />))
expect(mocks.resume).not.toHaveBeenCalled()
expect(mocks.gate).not.toHaveBeenCalled()
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledWith('wt-1')
expect(mocks.resume).not.toHaveBeenCalled()
})
it.each(['blocked', 'rejected'])(
'retries a %s startup after leaving and returning',
async (outcome) => {
mocks.authority = 'live'
if (outcome === 'rejected') {
mocks.gate.mockRejectedValue(new Error('host unavailable'))
} else {
mocks.gate.mockResolvedValue('blocked')
}
root = createRoot(document.createElement('div'))
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate).toHaveBeenCalledTimes(1)
await act(async () => root?.render(<Watcher hydrated worktreeId="wt-2" />))
mocks.gate.mockResolvedValue('adopted')
await act(async () => root?.render(<Watcher hydrated />))
expect(mocks.gate.mock.calls.map(([id]) => id)).toEqual(['wt-1', 'wt-2', 'wt-1'])
expect(mocks.resume).not.toHaveBeenCalled()
}
)
})
@@ -11,7 +11,6 @@ import {
} from './terminal-pane/terminal-parked-tab-watchers'
import { useAppStore } from '@/store'
import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority'
import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle'
@@ -251,7 +250,12 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
const startupResumeWorktreeIdsRef = useRef(new Set<string>())
useEffect(() => {
if (!workspaceSessionReady || !hydrationSucceeded || !activeWorktreeId) {
if (
!workspaceSessionReady ||
!terminalStartupRestorationReady ||
!hydrationSucceeded ||
!activeWorktreeId
) {
return
}
if (startupResumeWorktreeIdsRef.current.has(activeWorktreeId)) {
@@ -263,7 +267,20 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
return
}
startupResumeWorktreeIdsRef.current.add(activeWorktreeId)
// Why: startup hydration restores the worktree without activateAndRevealWorktree, so orphaned live/quit records need a terminal-surface pass after cold restore.
resumeSleepingAgentSessionsForWorktree(activeWorktreeId)
}, [activeWorktreeId, activeWorktreeHostAuthority, hydrationSucceeded, workspaceSessionReady])
// Startup recovery needs the same host census and in-flight gate as explicit activation.
void gateWorktreeAgentActivation(activeWorktreeId).then(
(outcome) => {
if (outcome === 'blocked') {
startupResumeWorktreeIdsRef.current.delete(activeWorktreeId)
}
},
() => startupResumeWorktreeIdsRef.current.delete(activeWorktreeId)
)
}, [
activeWorktreeId,
activeWorktreeHostAuthority,
hydrationSucceeded,
terminalStartupRestorationReady,
workspaceSessionReady
])
}
@@ -105,9 +105,7 @@ describe('activation inventory census', () => {
expect(listSessions).toHaveBeenCalledExactlyOnceWith({ connectionId: 'box' })
})
// A paired peer's PTYs never enter this client's registry, so refusing to answer would strand the
// workspace with no surface at all; the unscoped inventory is the shipped answer for it.
it('falls back to the unscoped inventory for a workspace it cannot scope', async () => {
it('rejects paired ownership without consulting the client inventory', async () => {
const listSessions = stubListSessions(async () => [])
await expect(
listActivationPtySessions(
@@ -125,11 +123,11 @@ describe('activation inventory census', () => {
},
worktreeId
)
).resolves.toEqual([])
expect(listSessions).toHaveBeenCalledExactlyOnceWith()
).rejects.toThrow('Activation PTY inventory is unverifiable')
expect(listSessions).not.toHaveBeenCalled()
})
it('retries unscoped when the selected relay is detached, and only then', async () => {
it('propagates detached and unavailable host errors without a client fallback', async () => {
const detached = stubListSessions(async (scope) => {
if (scope) {
throw new Error(
@@ -140,8 +138,8 @@ describe('activation inventory census', () => {
})
await expect(
listActivationPtySessions({ repos: [{ id: 'repo', executionHostId: 'ssh:box' }] }, worktreeId)
).resolves.toEqual([{ id: 'local-1' }])
expect(detached.mock.calls).toEqual([[{ connectionId: 'box' }], []])
).rejects.toThrow('No PTY provider for connection')
expect(detached.mock.calls).toEqual([[{ connectionId: 'box' }]])
const refused = stubListSessions(async () => {
throw new Error('relay unavailable')
@@ -12,19 +12,7 @@ import {
type WorktreeOperationRouteState
} from './worktree-operation-route'
/** Main rejects a scoped list with this prefix when the relay is detached (pty/provider/registry.ts). */
const DETACHED_PROVIDER_REJECTION = 'No PTY provider for connection'
/**
* The one provider that owns this workspace's PTYs, or `undefined` when the client cannot name a
* provider it could reach.
*
* Why `undefined` rather than a throw: a paired-runtime workspace is never in this client's PTY
* registry at all, so refusing to answer turns a healthy peer workspace into a `blocked` gate, and
* activation then leaves it with no surface whatsoever. Falling back to the unscoped diagnostic
* inventory reproduces the shipped answer for exactly those workspaces while the scoped fast path
* still covers local, folder and attached-SSH ones.
*/
/** Only a reachable execution-owner route can authorize activation from its inventory. */
export function resolveActivationPtyListScope(
state: WorktreeOperationRouteState,
worktreeId: string
@@ -61,27 +49,14 @@ export function resolveActivationPtyListScope(
return { connectionId: host.kind === 'ssh' ? host.targetId : null }
}
/**
* Activation's PTY census, scoped to the owning host whenever the client can name one.
*
* A detached relay is loss of contact, not evidence about the host, and it must not strand the
* workspace: fall back to the same unscoped inventory that shipped so the gate still reaches a
* verdict. Every other rejection is a real answer from the selected host and propagates.
*/
/** An unavailable execution host cannot be replaced with the client's diagnostic inventory. */
export async function listActivationPtySessions(
state: WorktreeOperationRouteState,
worktreeId: string
): Promise<PtyListedSession[]> {
const scope = resolveActivationPtyListScope(state, worktreeId)
if (!scope) {
return window.api.pty.listSessions()
}
try {
return await window.api.pty.listSessions(scope)
} catch (error) {
if (!String((error as Error)?.message ?? error).includes(DETACHED_PROVIDER_REJECTION)) {
throw error
}
return window.api.pty.listSessions()
throw new Error('Activation PTY inventory is unverifiable: no execution-owner route')
}
return window.api.pty.listSessions(scope)
}
@@ -415,15 +415,49 @@ describe('worktree agent activation gate', () => {
})
})
it.each(['present', 'unknown'] as const)(
'does not resume OMP when a surfaced %s agent lacks conversation ownership',
async (agentOwnership) => {
const record = {
...sleepingRecord('old-tab', DEAD_LEAF_ID, 'omp-session'),
agent: 'omp' as const
}
const ptyId = `${WORKTREE_ID}@@current-omp`
const { deps, resume, createTab } = testDeps({
sessions: [{ ...listed(ptyId), title: 'OMP', agentOwnership }],
sleeping: [record],
surfaceOwners: new Map([
[
ptyId,
{
ptyId,
tabId: 'current-tab',
paneKey: `current-tab:${LIVE_LEAF_ID}`
}
]
])
})
seedExistingSurface(deps.getState(), {
tabId: 'current-tab',
leafId: LIVE_LEAF_ID,
boundPtyId: ptyId
})
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
expect(resume).not.toHaveBeenCalled()
expect(createTab).not.toHaveBeenCalled()
expect(deps.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
}
)
it('does not use an ambiguous tab binding as a live session claim', async () => {
const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session')
const livePtyId = `${WORKTREE_ID}@@live-agent`
const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] })
deps.getState().ptyIdsByTabId['tab-live'] = [livePtyId, `${WORKTREE_ID}@@other-agent`]
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed')
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
expect(resume).not.toHaveBeenCalled()
})
it('blocks when a structured TUI owner is absent from live inventory', async () => {
@@ -103,13 +103,14 @@ function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boole
)
}
function liveSleepingAgentClaimKeys(
function liveSleepingAgentClaims(
store: ActivationStore,
worktreeId: string,
livePtyIds: ReadonlySet<string>,
structuredInventory: StructuredActivationInventory | null
): Set<string> {
): { keys: Set<string>; claimedPtyIds: Set<string> } {
const keys = new Set<string>()
const claimedPtyIds = new Set<string>()
for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) {
if (record.worktreeId !== worktreeId) {
continue
@@ -134,10 +135,11 @@ function liveSleepingAgentClaimKeys(
const persistedPtyId =
layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId
if (persistedPtyId && livePtyIds.has(persistedPtyId)) {
claimedPtyIds.add(persistedPtyId)
keys.add(getProviderSessionClaimKey(record))
}
}
return keys
return { keys, claimedPtyIds }
}
export async function runWorktreeAgentActivationGate(
@@ -243,14 +245,27 @@ export async function runWorktreeAgentActivationGate(
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
return 'structured'
}
const launched = deps.resume(worktreeId, {
skipClaimKeys: liveSleepingAgentClaimKeys(
deps.getState(),
worktreeId,
liveWorkspacePtyIds,
structuredInventory
const store = deps.getState()
const claims = liveSleepingAgentClaims(
store,
worktreeId,
liveWorkspacePtyIds,
structuredInventory
)
const hasUnclaimedRecovery = Object.values(store.sleepingAgentSessionsByPaneKey).some(
(record) =>
record.worktreeId === worktreeId && !claims.keys.has(getProviderSessionClaimKey(record))
)
// A surfaced PTY without a conversation claim may still own the sleeping session.
if (
hasUnclaimedRecovery &&
liveWorkspaceSessions.some(
(session) => session.agentOwnership !== 'absent' && !claims.claimedPtyIds.has(session.id)
)
})
) {
return 'blocked'
}
const launched = deps.resume(worktreeId, { skipClaimKeys: claims.keys })
// 'empty' is the caller's directive — "this gate produced no surface, seed one" — not a
// claim the host had nothing; the callers re-check their own seeding guards first.
return launched > 0
@@ -6,6 +6,7 @@ import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalSummary
} from '../../../shared/runtime-types'
import * as sleepingResume from './resume-sleeping-agent-session'
import { activateAndRevealWorktree } from './worktree-activation'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state'
@@ -158,6 +159,7 @@ function stubInventory(args?: {
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
useAppStore.setState(initialState, true)
})
@@ -302,9 +304,7 @@ describe('worktree agent activation seam', () => {
})
})
// A peer owns its own PTYs, so this client can never scope an inventory at it. Scoping must not
// turn that into a refusal: 'blocked' would also skip the sleeping-agent resume below.
it('still reaches a verdict for a paired-runtime-owned workspace', async () => {
it('does not authorize client-side recovery for a paired-runtime-owned workspace', async () => {
const worktree = makeWorktree()
useAppStore.setState({
...baseState(),
@@ -313,14 +313,11 @@ describe('worktree agent activation seam', () => {
const { listSessions } = stubInventory()
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('empty')
expect(listSessions).toHaveBeenCalledExactlyOnceWith()
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('blocked')
expect(listSessions).not.toHaveBeenCalled()
})
// Loss of contact with the relay is not evidence about the host, and once the sync has stopped
// without an answer the bounded floor in workspace-terminal-host-authority.ts hands seeding back
// to this client — a detached provider must not turn that into a permanently empty workspace.
it('still seeds a pane when the selected SSH relay is detached', async () => {
it('blocks seeding while detached and allows activation after the owning relay answers', async () => {
const worktree = makeWorktree()
useAppStore.setState({
...baseState(),
@@ -336,11 +333,58 @@ describe('worktree agent activation seam', () => {
})
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('blocked')
expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }]])
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
listSessions.mockResolvedValue([])
activateAndRevealWorktree(worktree.id)
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('empty')
expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }], []])
expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }], [{ connectionId: 'box' }]])
await vi.waitFor(() =>
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1)
)
expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.ptyId).toBeNull()
})
it('preserves a sleeping OMP session until its execution host can answer', async () => {
const worktree = makeWorktree()
const record = {
paneKey: 'saved-tab:11111111-1111-4111-8111-111111111111',
tabId: 'saved-tab',
worktreeId: worktree.id,
agent: 'omp' as const,
providerSession: { key: 'session_id' as const, id: 'saved-omp-session' },
prompt: 'resume',
state: 'working' as const,
capturedAt: 1,
updatedAt: 1
}
useAppStore.setState({
...baseState(),
worktreesByRepo: { [worktree.repoId]: [{ ...worktree, hostId: 'ssh:box' }] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
})
const { listSessions } = stubInventory()
const resume = vi
.spyOn(sleepingResume, 'resumeSleepingAgentSessionsForWorktree')
.mockReturnValue(1)
listSessions.mockImplementation(async (scope?: unknown) => {
if (scope) {
throw new Error('No PTY provider for connection "box": the SSH relay is not attached')
}
return []
})
activateAndRevealWorktree(worktree.id)
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('blocked')
expect(resume).not.toHaveBeenCalled()
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
listSessions.mockResolvedValue([])
activateAndRevealWorktree(worktree.id)
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('resumed')
expect(resume).toHaveBeenCalledExactlyOnceWith(worktree.id, { skipClaimKeys: new Set() })
expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }], [{ connectionId: 'box' }]])
})
})