mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
fix activation recovery ownership boundary
This commit is contained in:
@@ -30,7 +30,10 @@ describe('renderer startup runtime routing', () => {
|
||||
const recoveryEffect = source.slice(recoveryStart, recoveryEnd)
|
||||
|
||||
expect(recoveryStart).toBeGreaterThanOrEqual(0)
|
||||
expect(recoveryEffect).toContain("{ mode: 'startup', signal: abort.signal }")
|
||||
expect(recoveryEffect).toContain(
|
||||
"startWorkspaceActivationSurfaceProducer(identity, { mode: 'startup' })"
|
||||
)
|
||||
expect(recoveryEffect).toContain('signal: abort.signal')
|
||||
expect(recoveryEffect).toContain('abort.abort()')
|
||||
expect(recoveryEffect).not.toContain('gateWorktreeAgentActivation')
|
||||
expect(recoveryEffect).not.toContain('resumeSleepingAgentSessionsForWorktree')
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => {
|
||||
let uuid = 0
|
||||
return {
|
||||
recover: vi.fn(),
|
||||
produce: vi.fn(),
|
||||
nextUuid: () => `startup-recovery-${++uuid}`,
|
||||
resetUuid: () => {
|
||||
uuid = 0
|
||||
@@ -17,13 +18,24 @@ const mocks = vi.hoisted(() => {
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(() => 'none', {
|
||||
getState: () => ({ activeWorktreeId: 'folder:workspace-1' })
|
||||
})
|
||||
useAppStore: Object.assign(
|
||||
(
|
||||
selector: (state: {
|
||||
activeWorkspaceExecutionHostId: null
|
||||
runtimeEnvironments: readonly []
|
||||
}) => unknown
|
||||
) => selector({ activeWorkspaceExecutionHostId: null, runtimeEnvironments: [] }),
|
||||
{
|
||||
getState: () => ({ activeWorktreeId: 'folder:workspace-1' })
|
||||
}
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/worktree-activation-recovery', () => ({
|
||||
recoverWorkspaceActivation: mocks.recover
|
||||
}))
|
||||
vi.mock('@/lib/workspace-activation-surface-producer', () => ({
|
||||
startWorkspaceActivationSurfaceProducer: mocks.produce
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getExecutionHostIdForWorktree: () => 'local',
|
||||
getRuntimeEnvironmentIdForWorktree: () => null
|
||||
@@ -53,6 +65,7 @@ afterEach(async () => {
|
||||
await act(async () => root?.unmount())
|
||||
root = undefined
|
||||
mocks.recover.mockReset()
|
||||
mocks.produce.mockReset()
|
||||
mocks.resetUuid()
|
||||
})
|
||||
|
||||
@@ -85,9 +98,13 @@ describe('passive activation recovery', () => {
|
||||
},
|
||||
{ mode: 'startup', signal: expect.any(AbortSignal) }
|
||||
)
|
||||
expect(mocks.produce).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceKey: 'folder:workspace-1' }),
|
||||
{ mode: 'startup' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not turn later state-only selections into new recovery launches', async () => {
|
||||
it('observes every later general-setter selection', async () => {
|
||||
mocks.recover.mockResolvedValue({ kind: 'materialized' })
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-1" />))
|
||||
@@ -95,7 +112,12 @@ describe('passive activation recovery', () => {
|
||||
|
||||
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-2" />))
|
||||
|
||||
expect(mocks.recover).toHaveBeenCalledOnce()
|
||||
expect(mocks.recover).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.produce).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.recover.mock.calls.map(([request]) => request.workspaceKey)).toEqual([
|
||||
'worktree-1',
|
||||
'worktree-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('aborts an unsettled request without consuming the next startup assessment', async () => {
|
||||
|
||||
@@ -14,7 +14,11 @@ describe('Terminal startup recovery wiring', () => {
|
||||
|
||||
it('routes startup materialization through the recovery boundary', () => {
|
||||
expect(source.split('recoverWorkspaceActivation(').length - 1).toBe(1)
|
||||
expect(source).toContain("{ mode: 'startup', signal: abort.signal }")
|
||||
expect(source.split('startWorkspaceActivationSurfaceProducer(').length - 1).toBe(1)
|
||||
expect(source).toContain(
|
||||
"startWorkspaceActivationSurfaceProducer(identity, { mode: 'startup' })"
|
||||
)
|
||||
expect(source).toContain('signal: abort.signal')
|
||||
})
|
||||
|
||||
it('captures target, host, runtime, and attempt identity', () => {
|
||||
@@ -24,10 +28,11 @@ describe('Terminal startup recovery wiring', () => {
|
||||
expect(source).toContain('attemptId: createBrowserUuid()')
|
||||
})
|
||||
|
||||
it('keeps recovery retryable until the asynchronous assessment settles', () => {
|
||||
expect(source).toContain(
|
||||
'if (!abort.signal.aborted) {\n startupRecoverySettledRef.current = true'
|
||||
)
|
||||
it('re-arms startup ownership for every route and authority transition', () => {
|
||||
expect(source).toContain('const startupRecoveryTargetRef = useRef<string | null>(null)')
|
||||
expect(source).toContain('activeRuntimeRoute.revision,')
|
||||
expect(source).toContain('const target = JSON.stringify([')
|
||||
expect(source).toContain('startupRecoveryTargetRef.current = null')
|
||||
expect(source).toContain('return () => {\n abort.abort()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { startWorkspaceActivationSurfaceProducer } from '@/lib/workspace-activation-surface-producer'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
// Why shared: surfaces without watchable live tabs need no per-pass allocation.
|
||||
const NO_PARKED_TAB_IDS: ReadonlySet<string> = new Set()
|
||||
@@ -81,7 +83,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
|
||||
workspaceSessionReady,
|
||||
workspaceSurfaceIds
|
||||
} = controller
|
||||
const startupRecoverySettledRef = useRef(false)
|
||||
const startupRecoveryTargetRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds(workspaceSurfaceIds))
|
||||
@@ -194,38 +196,70 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
|
||||
const activeWorkspaceExecutionHostId = useAppStore(
|
||||
(state) => state.activeWorkspaceExecutionHostId
|
||||
)
|
||||
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
|
||||
const activeRuntimeRoute = useMemo(() => {
|
||||
const routeKey = `${activeWorkspaceExecutionHostId ?? ''}|${activeWorktreeHostAuthority}`
|
||||
if (!activeWorktreeId) {
|
||||
return { routeKey, revision: null }
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
|
||||
useAppStore.getState(),
|
||||
activeWorktreeId
|
||||
)
|
||||
const environment = runtimeEnvironments.find(({ id }) => id === runtimeEnvironmentId)
|
||||
return {
|
||||
routeKey,
|
||||
revision: environment
|
||||
? (environment.pairingRevision ?? environment.createdAt)
|
||||
: runtimeEnvironmentId
|
||||
? (getRuntimeEnvironmentRevision(runtimeEnvironmentId) ?? null)
|
||||
: null
|
||||
}
|
||||
}, [
|
||||
activeWorkspaceExecutionHostId,
|
||||
activeWorktreeHostAuthority,
|
||||
activeWorktreeId,
|
||||
runtimeEnvironments
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady || !terminalStartupRestorationReady) {
|
||||
startupRecoverySettledRef.current = false
|
||||
startupRecoveryTargetRef.current = null
|
||||
return
|
||||
}
|
||||
if (!activeWorktreeId || startupRecoverySettledRef.current) {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const identity = {
|
||||
workspaceKey: activeWorktreeId,
|
||||
executionHostId: getExecutionHostIdForWorktree(state, activeWorktreeId),
|
||||
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId),
|
||||
attemptId: createBrowserUuid()
|
||||
}
|
||||
const target = JSON.stringify([
|
||||
identity.executionHostId,
|
||||
identity.runtimeEnvironmentId,
|
||||
activeRuntimeRoute.revision,
|
||||
activeWorktreeId,
|
||||
activeRuntimeRoute.routeKey
|
||||
])
|
||||
if (startupRecoveryTargetRef.current === target) {
|
||||
return
|
||||
}
|
||||
startupRecoveryTargetRef.current = target
|
||||
const abort = new AbortController()
|
||||
void recoverWorkspaceActivation(
|
||||
{
|
||||
workspaceKey: activeWorktreeId,
|
||||
executionHostId: getExecutionHostIdForWorktree(state, activeWorktreeId),
|
||||
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId),
|
||||
attemptId: createBrowserUuid()
|
||||
},
|
||||
{ mode: 'startup', signal: abort.signal }
|
||||
).then(
|
||||
() => {
|
||||
if (!abort.signal.aborted) {
|
||||
startupRecoverySettledRef.current = true
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
startWorkspaceActivationSurfaceProducer(identity, { mode: 'startup' })
|
||||
void recoverWorkspaceActivation(identity, {
|
||||
mode: 'startup',
|
||||
signal: abort.signal
|
||||
}).catch(() => undefined)
|
||||
return () => {
|
||||
abort.abort()
|
||||
}
|
||||
}, [
|
||||
activeWorkspaceExecutionHostId,
|
||||
activeRuntimeRoute,
|
||||
activeWorktreeHostAuthority,
|
||||
activeWorktreeId,
|
||||
terminalStartupRestorationReady,
|
||||
|
||||
@@ -32,7 +32,10 @@ const producer: WorkspaceSurfaceProducer = {
|
||||
materialized: vi.fn(),
|
||||
declined: vi.fn(),
|
||||
failed: vi.fn(),
|
||||
unverifiable: vi.fn()
|
||||
unverifiable: vi.fn(),
|
||||
blocked: vi.fn(),
|
||||
unexpected: vi.fn(),
|
||||
intentionalEmpty: vi.fn()
|
||||
}
|
||||
|
||||
describe('direct work item surface production', () => {
|
||||
|
||||
@@ -17,6 +17,11 @@ import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-syn
|
||||
import { findUnhydratedHostMirrorForPane } from './host-mirrored-pane-liveness'
|
||||
import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority'
|
||||
import { parkUntilHostSessionMirrorHydrates } from '@/runtime/host-session-mirror-hydration'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
} from './worktree-runtime-owner'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
export type { ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch'
|
||||
|
||||
@@ -166,16 +171,48 @@ function parkWorktreeResumeSweepUntilHostMirrorHydrates(
|
||||
// wakes, and a latch that has since failed must stay resumable here.
|
||||
resumeSleepingAgentSessionsForWorktree(worktreeId, {
|
||||
...(options?.onSessionLaunched ? { onSessionLaunched: options.onSessionLaunched } : {}),
|
||||
...(options?.expectedExecutionHostId
|
||||
? { expectedExecutionHostId: options.expectedExecutionHostId }
|
||||
: {}),
|
||||
...('expectedRuntimeEnvironmentId' in (options ?? {})
|
||||
? { expectedRuntimeEnvironmentId: options?.expectedRuntimeEnvironmentId ?? null }
|
||||
: {}),
|
||||
...(options?.expectedRuntimeEnvironmentRevision === undefined
|
||||
? {}
|
||||
: { expectedRuntimeEnvironmentRevision: options.expectedRuntimeEnvironmentRevision }),
|
||||
...(isActive ? {} : { suppressNavigation: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resumeRouteIsCurrent(
|
||||
state: ReturnType<typeof useAppStore.getState>,
|
||||
worktreeId: string,
|
||||
options: ResumeSleepingAgentSessionsOptions | undefined
|
||||
): boolean {
|
||||
return (
|
||||
(options?.expectedExecutionHostId === undefined ||
|
||||
getExecutionHostIdForWorktree(state, worktreeId) === options.expectedExecutionHostId) &&
|
||||
(!options ||
|
||||
!('expectedRuntimeEnvironmentId' in options) ||
|
||||
getRuntimeEnvironmentIdForWorktree(state, worktreeId) ===
|
||||
options.expectedRuntimeEnvironmentId) &&
|
||||
(options?.expectedRuntimeEnvironmentRevision === undefined ||
|
||||
(options.expectedRuntimeEnvironmentId !== null &&
|
||||
options.expectedRuntimeEnvironmentId !== undefined &&
|
||||
getRuntimeEnvironmentRevision(options.expectedRuntimeEnvironmentId) ===
|
||||
options.expectedRuntimeEnvironmentRevision))
|
||||
)
|
||||
}
|
||||
|
||||
export function resumeSleepingAgentSessionsForWorktree(
|
||||
worktreeId: string,
|
||||
options?: ResumeSleepingAgentSessionsOptions
|
||||
): number {
|
||||
const state = useAppStore.getState()
|
||||
if (!resumeRouteIsCurrent(state, worktreeId, options)) {
|
||||
return 0
|
||||
}
|
||||
// Why: every branch below reads local rows as the verdict on what the execution host is running,
|
||||
// and before it answers "I hold no pane for this record" is `unverifiable`, not `exited`. Resuming
|
||||
// on it forks a second agent onto a transcript the host is still writing (STA-3500). Declining is
|
||||
@@ -200,6 +237,9 @@ export function resumeSleepingAgentSessionsForWorktree(
|
||||
let launched = 0
|
||||
for (const record of worktreeRecords) {
|
||||
const currentState = useAppStore.getState()
|
||||
if (!resumeRouteIsCurrent(currentState, worktreeId, options)) {
|
||||
return launched
|
||||
}
|
||||
if (currentState.sleepingAgentSessionsByPaneKey[record.paneKey] !== record) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type ResumeSleepingAgentSessionsOptions = {
|
||||
@@ -26,6 +27,9 @@ export type ResumeSleepingAgentSessionsOptions = {
|
||||
/** Called with the tab id of each freshly launched resume tab, so
|
||||
* navigation-suppressed callers can background-mount exactly those tabs. */
|
||||
onSessionLaunched?: (tabId: string) => void
|
||||
expectedExecutionHostId?: ExecutionHostId
|
||||
expectedRuntimeEnvironmentId?: string | null
|
||||
expectedRuntimeEnvironmentRevision?: number
|
||||
}
|
||||
|
||||
function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
type ActivationAttemptIdentity = {
|
||||
workspaceKey: string
|
||||
executionHostId: ExecutionHostId
|
||||
attemptId: string
|
||||
}
|
||||
|
||||
const latestAttemptIdByTarget = new Map<string, string>()
|
||||
|
||||
export function activationRecoveryTargetKey(identity: {
|
||||
workspaceKey: string
|
||||
executionHostId: ExecutionHostId
|
||||
}): string {
|
||||
return `${identity.executionHostId}|${identity.workspaceKey}`
|
||||
}
|
||||
|
||||
export function markLatestActivationRecoveryAttempt(identity: ActivationAttemptIdentity): void {
|
||||
latestAttemptIdByTarget.set(activationRecoveryTargetKey(identity), identity.attemptId)
|
||||
}
|
||||
|
||||
export function readLatestActivationRecoveryAttempt(
|
||||
identity: ActivationAttemptIdentity
|
||||
): string | undefined {
|
||||
return latestAttemptIdByTarget.get(activationRecoveryTargetKey(identity))
|
||||
}
|
||||
|
||||
export function clearLatestActivationRecoveryAttempts(args: {
|
||||
workspaceKey?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}): void {
|
||||
for (const key of latestAttemptIdByTarget.keys()) {
|
||||
const separator = key.indexOf('|')
|
||||
const executionHostId = key.slice(0, separator)
|
||||
const workspaceKey = key.slice(separator + 1)
|
||||
if (
|
||||
(args.workspaceKey === undefined || workspaceKey === args.workspaceKey) &&
|
||||
(args.executionHostId === undefined || executionHostId === args.executionHostId)
|
||||
) {
|
||||
latestAttemptIdByTarget.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
|
||||
import { clearWorkspaceActivationRecoveryPresentation } from './workspace-activation-recovery-presentation'
|
||||
import { readWorkspaceSurfaceProducerEntries } from './workspace-surface-production'
|
||||
import {
|
||||
resolveWorkspaceExecutionEvidence,
|
||||
type WorkspaceExecutionEvidence
|
||||
@@ -12,7 +10,6 @@ import type {
|
||||
} from './worktree-activation-recovery'
|
||||
import type { WorkspaceActivationRecoveryOwnerContext } from './workspace-activation-recovery-retry'
|
||||
import {
|
||||
canInspectAgentActivationInventory,
|
||||
captureActivationRecoverySelectionRevision,
|
||||
hasLiveActivationTerminalTombstone,
|
||||
installActivationRecoverySelectionTracker,
|
||||
@@ -31,99 +28,6 @@ import {
|
||||
publishActivationRecovery,
|
||||
waitForActivationProducerAttempts
|
||||
} from './workspace-activation-recovery-settlement'
|
||||
import {
|
||||
runActivationRecoveryGate,
|
||||
waitForActivationProducedSurface
|
||||
} from './workspace-activation-recovery-gate'
|
||||
|
||||
let seedingTargetKey: string | null = null
|
||||
|
||||
function seedPrivateRecoverySurface(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
context: WorkspaceActivationRecoveryOwnerContext,
|
||||
capturedSelectionRevision: number
|
||||
): WorkspaceActivationRecoveryResult {
|
||||
const key = `${identity.executionHostId}|${identity.workspaceKey}`
|
||||
if (
|
||||
!isActivationRecoveryFresh(identity, capturedSelectionRevision, context) ||
|
||||
seedingTargetKey === key
|
||||
) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
seedingTargetKey = key
|
||||
try {
|
||||
const { renderableTabCount, surface } = readActivationRenderableInventory(identity)
|
||||
// Why: reconciliation can synchronously notify subscribers and start a newer activation.
|
||||
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
if (surface) {
|
||||
return activationRecoveryMaterializedResult(identity, surface)
|
||||
}
|
||||
if (renderableTabCount > 0) {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unexpected',
|
||||
'Workspace content exists, but no renderable surface could be selected.'
|
||||
)
|
||||
return activationRecoveryFailedResult(identity, 'unexpected')
|
||||
}
|
||||
if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
|
||||
clearActivationRecoveryPresentation(identity)
|
||||
return { kind: 'intentional-empty' }
|
||||
}
|
||||
const pendingProducer = readWorkspaceSurfaceProducerEntries(identity).find(
|
||||
(entry) => entry.result === null || entry.result?.kind === 'unverifiable'
|
||||
)
|
||||
if (pendingProducer) {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unverifiable',
|
||||
'A surface producer still owns this workspace.'
|
||||
)
|
||||
return {
|
||||
kind: 'deferred',
|
||||
reason: 'A surface producer still owns this workspace.',
|
||||
ownerAttemptId: pendingProducer.attempt.id
|
||||
}
|
||||
}
|
||||
const evidence: WorkspaceExecutionEvidence = resolveWorkspaceExecutionEvidence(
|
||||
useAppStore.getState(),
|
||||
identity.workspaceKey,
|
||||
identity.executionHostId
|
||||
)
|
||||
if (evidence !== 'exited') {
|
||||
const detail =
|
||||
evidence === 'live'
|
||||
? 'The execution host owns this workspace surface. Wait for it to publish or reconnect.'
|
||||
: 'Orca cannot verify the execution host. Reconnect before retrying recovery.'
|
||||
publishActivationRecovery(identity, context, 'unverifiable', detail)
|
||||
return { kind: 'deferred', reason: detail, ownerAttemptId: null }
|
||||
}
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount, false)) {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unexpected',
|
||||
'Workspace recovery could not select a surface.'
|
||||
)
|
||||
return activationRecoveryFailedResult(identity, 'unexpected')
|
||||
}
|
||||
const tab = useAppStore.getState().createTab(identity.workspaceKey, undefined, undefined, {
|
||||
pendingActivationSpawn: true
|
||||
})
|
||||
clearActivationRecoveryPresentation(identity)
|
||||
return { kind: 'materialized', surface: { id: tab.id, type: 'terminal' } }
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
publishActivationRecovery(identity, context, 'unexpected', detail)
|
||||
return activationRecoveryFailedResult(identity, 'unexpected')
|
||||
} finally {
|
||||
seedingTargetKey = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverWorkspaceActivationOwned(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
@@ -157,51 +61,61 @@ export async function recoverWorkspaceActivationOwned(
|
||||
const producerAssessment = assessActivationProducerAttempts(identity, context)
|
||||
const producerResult =
|
||||
producerAssessment.kind === 'wait'
|
||||
? await waitForActivationProducerAttempts(identity, context, deadlineAt)
|
||||
? await waitForActivationProducerAttempts(
|
||||
identity,
|
||||
context,
|
||||
deadlineAt,
|
||||
capturedSelectionRevision
|
||||
)
|
||||
: producerAssessment.kind === 'complete'
|
||||
? producerAssessment.result
|
||||
: null
|
||||
const hasSleepingAgentSession = Object.values(
|
||||
useAppStore.getState().sleepingAgentSessionsByPaneKey
|
||||
).some((record) => record.worktreeId === identity.workspaceKey)
|
||||
if (producerResult && (producerResult.kind !== 'materialized' || !hasSleepingAgentSession)) {
|
||||
if (producerResult) {
|
||||
return producerResult
|
||||
}
|
||||
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
const shouldGate =
|
||||
hasSleepingAgentSession || (!producerResult && canInspectAgentActivationInventory())
|
||||
if (shouldGate) {
|
||||
const gateResult = await runActivationRecoveryGate(identity, context, deadlineAt)
|
||||
if (gateResult !== 'empty' && gateResult !== 'produced') {
|
||||
return gateResult
|
||||
}
|
||||
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
const gateSurface = readActivationRenderableSurface(identity)
|
||||
if (gateSurface) {
|
||||
return activationRecoveryMaterializedResult(identity, gateSurface)
|
||||
}
|
||||
if (producerResult?.kind === 'materialized') {
|
||||
return producerResult
|
||||
}
|
||||
if (gateResult === 'produced') {
|
||||
return waitForActivationProducedSurface(identity, context, deadlineAt)
|
||||
}
|
||||
const { renderableTabCount, surface } = readActivationRenderableInventory(identity)
|
||||
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
const seedResult = seedPrivateRecoverySurface(identity, context, capturedSelectionRevision)
|
||||
if (
|
||||
seedResult.kind !== 'stale' ||
|
||||
!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)
|
||||
) {
|
||||
return seedResult
|
||||
if (surface) {
|
||||
return activationRecoveryMaterializedResult(identity, surface)
|
||||
}
|
||||
// Why: a reentrant subscriber can start the newer attempt while the superseded attempt still
|
||||
// owns the synchronous seed guard; retry once after that critical section unwinds.
|
||||
await Promise.resolve()
|
||||
return seedPrivateRecoverySurface(identity, context, capturedSelectionRevision)
|
||||
if (renderableTabCount > 0) {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unexpected',
|
||||
'Workspace content exists, but no renderable surface could be selected.'
|
||||
)
|
||||
return activationRecoveryFailedResult(identity, 'unexpected')
|
||||
}
|
||||
if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
|
||||
clearActivationRecoveryPresentation(identity)
|
||||
return { kind: 'intentional-empty' }
|
||||
}
|
||||
const evidence: WorkspaceExecutionEvidence = resolveWorkspaceExecutionEvidence(
|
||||
useAppStore.getState(),
|
||||
identity.workspaceKey,
|
||||
identity.executionHostId
|
||||
)
|
||||
const detail =
|
||||
evidence === 'live'
|
||||
? 'The execution host owns this workspace surface. Wait for it to publish or reconnect.'
|
||||
: evidence === 'unverifiable'
|
||||
? 'Orca cannot verify the execution host. Reconnect before retrying recovery.'
|
||||
: 'No concrete surface producer owns this empty workspace activation.'
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
evidence === 'exited' ? 'unexpected' : 'unverifiable',
|
||||
detail
|
||||
)
|
||||
return evidence === 'exited'
|
||||
? activationRecoveryFailedResult(identity, 'unexpected')
|
||||
: { kind: 'deferred', reason: detail, ownerAttemptId: null }
|
||||
} catch (error) {
|
||||
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
|
||||
return { kind: 'stale' }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const surfaceIdsByProducerAttempt = new Map<string, ReadonlySet<string>>()
|
||||
|
||||
export function readActivationRecoveryFailureSurfaceIds(
|
||||
attemptId: string
|
||||
): ReadonlySet<string> | undefined {
|
||||
return surfaceIdsByProducerAttempt.get(attemptId)
|
||||
}
|
||||
|
||||
export function recordActivationRecoveryFailureSurfaceIds(
|
||||
attemptId: string,
|
||||
surfaceIds: ReadonlySet<string>
|
||||
): void {
|
||||
surfaceIdsByProducerAttempt.set(attemptId, surfaceIds)
|
||||
}
|
||||
|
||||
export function clearActivationRecoveryFailureSnapshots(attemptIds: readonly string[]): void {
|
||||
for (const attemptId of attemptIds) {
|
||||
surfaceIdsByProducerAttempt.delete(attemptId)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
|
||||
import type {
|
||||
WorkspaceActivationIdentity,
|
||||
WorkspaceActivationRecoveryResult
|
||||
} from './worktree-activation-recovery'
|
||||
import type { WorkspaceActivationRecoveryOwnerContext } from './workspace-activation-recovery-retry'
|
||||
import {
|
||||
activationRecoveryRouteKey,
|
||||
readActivationRecoveryGateRoute,
|
||||
readActivationRenderableSurface,
|
||||
recordActivationRecoveryGateRoute,
|
||||
waitForActivationRecoveryChange,
|
||||
waitForActivationRecoveryPromise
|
||||
} from './workspace-activation-recovery-state'
|
||||
import {
|
||||
activationRecoveryFailedResult,
|
||||
activationRecoveryMaterializedResult,
|
||||
publishActivationRecovery
|
||||
} from './workspace-activation-recovery-settlement'
|
||||
|
||||
export async function runActivationRecoveryGate(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
context: WorkspaceActivationRecoveryOwnerContext,
|
||||
deadlineAt: number
|
||||
): Promise<WorkspaceActivationRecoveryResult | 'empty' | 'produced'> {
|
||||
const gate = gateWorktreeAgentActivation(identity.workspaceKey)
|
||||
const gateRoute = readActivationRecoveryGateRoute(gate)
|
||||
if (gateRoute && gateRoute !== activationRecoveryRouteKey(identity)) {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unverifiable',
|
||||
'Another execution host owns the in-progress recovery assessment. Retry after it settles.'
|
||||
)
|
||||
return {
|
||||
kind: 'deferred',
|
||||
reason: 'A recovery gate from another execution host is still in progress.',
|
||||
ownerAttemptId: null
|
||||
}
|
||||
}
|
||||
recordActivationRecoveryGateRoute(gate, activationRecoveryRouteKey(identity))
|
||||
const outcome = await waitForActivationRecoveryPromise(gate, deadlineAt, context.signal)
|
||||
if (outcome === 'cancelled') {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
if (outcome === 'timeout') {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unexpected',
|
||||
'Workspace inventory did not finish before the recovery deadline.'
|
||||
)
|
||||
return activationRecoveryFailedResult(identity, 'unexpected')
|
||||
}
|
||||
if (outcome.kind === 'rejected') {
|
||||
const detail = outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
|
||||
publishActivationRecovery(identity, context, 'unexpected', detail)
|
||||
return activationRecoveryFailedResult(identity, 'unexpected')
|
||||
}
|
||||
if (outcome.value === 'blocked') {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'blocked',
|
||||
'Orca deliberately paused recovery because the execution host did not provide complete ownership evidence.'
|
||||
)
|
||||
return activationRecoveryFailedResult(identity, 'blocked')
|
||||
}
|
||||
return outcome.value === 'empty' ? 'empty' : 'produced'
|
||||
}
|
||||
|
||||
export async function waitForActivationProducedSurface(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
context: WorkspaceActivationRecoveryOwnerContext,
|
||||
deadlineAt: number
|
||||
): Promise<WorkspaceActivationRecoveryResult> {
|
||||
while (true) {
|
||||
const surface = readActivationRenderableSurface(identity)
|
||||
if (surface) {
|
||||
return activationRecoveryMaterializedResult(identity, surface)
|
||||
}
|
||||
const wait = await waitForActivationRecoveryChange(deadlineAt, context.signal)
|
||||
if (wait === 'cancelled') {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
if (wait === 'timeout') {
|
||||
publishActivationRecovery(
|
||||
identity,
|
||||
context,
|
||||
'unverifiable',
|
||||
'The execution host reported work, but no renderable surface became visible.'
|
||||
)
|
||||
return {
|
||||
kind: 'deferred',
|
||||
reason: 'Host work did not publish a renderable surface before the recovery deadline.',
|
||||
ownerAttemptId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from './workspace-activation-recovery-lifecycle'
|
||||
import {
|
||||
markLatestActivationRecoveryAttempt,
|
||||
readLatestActivationRecoveryAttempt
|
||||
} from './workspace-activation-recovery-attempts'
|
||||
import {
|
||||
readActivationRecoveryFailureSurfaceIds,
|
||||
recordActivationRecoveryFailureSurfaceIds
|
||||
} from './workspace-activation-recovery-failure-snapshots'
|
||||
import {
|
||||
publishWorkspaceActivationRecoveryPresentation,
|
||||
readWorkspaceActivationRecoveryPresentation
|
||||
} from './workspace-activation-recovery-presentation'
|
||||
import {
|
||||
readWorkspaceSurfaceProducerEntries,
|
||||
registerWorkspaceSurfaceProducer
|
||||
} from './workspace-surface-production'
|
||||
|
||||
const SSH_HOST = toSshExecutionHostId('box')
|
||||
|
||||
function registerLifecycle(
|
||||
workspaceKey: string,
|
||||
executionHostId: 'local' | typeof SSH_HOST,
|
||||
attemptId: string
|
||||
): void {
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey,
|
||||
executionHostId,
|
||||
attemptId
|
||||
})
|
||||
producer.failed('failed')
|
||||
recordActivationRecoveryFailureSurfaceIds(attemptId, new Set([`${attemptId}-surface`]))
|
||||
markLatestActivationRecoveryAttempt({ workspaceKey, executionHostId, attemptId })
|
||||
publishWorkspaceActivationRecoveryPresentation({
|
||||
workspaceKey,
|
||||
executionHostId,
|
||||
attemptId,
|
||||
kind: 'producer-failed',
|
||||
retry: () => undefined
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearWorkspaceActivationRecoveryLifecycle({})
|
||||
})
|
||||
|
||||
describe('workspace activation recovery lifecycle', () => {
|
||||
it('clears all recovery ownership when a workspace is deleted', () => {
|
||||
registerLifecycle('worktree-1', 'local', 'local-1')
|
||||
registerLifecycle('worktree-1', SSH_HOST, 'ssh-1')
|
||||
registerLifecycle('worktree-2', 'local', 'local-2')
|
||||
|
||||
clearWorkspaceActivationRecoveryLifecycle({ workspaceKey: 'worktree-1' })
|
||||
|
||||
expect(
|
||||
readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: 'local' })
|
||||
).toEqual([])
|
||||
expect(
|
||||
readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: SSH_HOST })
|
||||
).toEqual([])
|
||||
expect(readWorkspaceActivationRecoveryPresentation('worktree-1', 'local')).toBeNull()
|
||||
expect(
|
||||
readLatestActivationRecoveryAttempt({
|
||||
workspaceKey: 'worktree-1',
|
||||
executionHostId: 'local',
|
||||
attemptId: 'unused'
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(readActivationRecoveryFailureSurfaceIds('local-1')).toBeUndefined()
|
||||
expect(readActivationRecoveryFailureSurfaceIds('ssh-1')).toBeUndefined()
|
||||
expect(
|
||||
readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-2', executionHostId: 'local' })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears only the retired execution host across workspaces', () => {
|
||||
registerLifecycle('worktree-1', SSH_HOST, 'ssh-1')
|
||||
registerLifecycle('worktree-2', SSH_HOST, 'ssh-2')
|
||||
registerLifecycle('worktree-1', 'local', 'local-1')
|
||||
|
||||
clearWorkspaceActivationRecoveryLifecycle({ executionHostId: SSH_HOST })
|
||||
|
||||
expect(
|
||||
readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: SSH_HOST })
|
||||
).toEqual([])
|
||||
expect(
|
||||
readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-2', executionHostId: SSH_HOST })
|
||||
).toEqual([])
|
||||
expect(readWorkspaceActivationRecoveryPresentation('worktree-2', SSH_HOST)).toBeNull()
|
||||
expect(readActivationRecoveryFailureSurfaceIds('ssh-1')).toBeUndefined()
|
||||
expect(readActivationRecoveryFailureSurfaceIds('ssh-2')).toBeUndefined()
|
||||
expect(
|
||||
readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: 'local' })
|
||||
).toHaveLength(1)
|
||||
expect(readWorkspaceActivationRecoveryPresentation('worktree-1', 'local')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { clearWorkspaceActivationRecoveryPresentations } from './workspace-activation-recovery-presentation'
|
||||
import { clearLatestActivationRecoveryAttempts } from './workspace-activation-recovery-attempts'
|
||||
import { clearActivationRecoveryFailureSnapshots } from './workspace-activation-recovery-failure-snapshots'
|
||||
import { clearWorkspaceSurfaceProducerAttempts } from './workspace-surface-production'
|
||||
|
||||
export function clearWorkspaceActivationRecoveryLifecycle(args: {
|
||||
workspaceKey?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}): void {
|
||||
const removedAttemptIds = clearWorkspaceSurfaceProducerAttempts(args)
|
||||
clearActivationRecoveryFailureSnapshots(removedAttemptIds)
|
||||
clearWorkspaceActivationRecoveryPresentations(args)
|
||||
clearLatestActivationRecoveryAttempts(args)
|
||||
}
|
||||
@@ -46,6 +46,25 @@ export function clearWorkspaceActivationRecoveryPresentation(args: {
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
export function clearWorkspaceActivationRecoveryPresentations(args: {
|
||||
workspaceKey?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}): void {
|
||||
let changed = false
|
||||
for (const [key, presentation] of presentationsByTarget) {
|
||||
if (
|
||||
(args.workspaceKey === undefined || presentation.workspaceKey === args.workspaceKey) &&
|
||||
(args.executionHostId === undefined || presentation.executionHostId === args.executionHostId)
|
||||
) {
|
||||
presentationsByTarget.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkspaceActivationRecoveryPresentation(
|
||||
workspaceKey: string,
|
||||
executionHostId: ExecutionHostId
|
||||
@@ -59,6 +78,5 @@ export function subscribeWorkspaceActivationRecoveryPresentation(listener: () =>
|
||||
}
|
||||
|
||||
export function resetWorkspaceActivationRecoveryPresentationsForTests(): void {
|
||||
presentationsByTarget.clear()
|
||||
notifyListeners()
|
||||
clearWorkspaceActivationRecoveryPresentations({})
|
||||
}
|
||||
|
||||
@@ -22,10 +22,8 @@ export function createWorkspaceActivationRecoveryOwnerContext(
|
||||
return {
|
||||
...context,
|
||||
retry: () => {
|
||||
void recoverWorkspaceActivation(
|
||||
{ ...identity, attemptId: createBrowserUuid() },
|
||||
{ mode: context.mode }
|
||||
)
|
||||
const retryIdentity = { ...identity, attemptId: createBrowserUuid() }
|
||||
void recoverWorkspaceActivation(retryIdentity, { mode: context.mode })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WorkspaceVisibleTabType } from '../../../shared/tab-types'
|
||||
import {
|
||||
consumeWorkspaceSurfaceProducerAttempt,
|
||||
discardWorkspaceSurfaceProducerAttempt,
|
||||
readWorkspaceSurfaceProducerEntries
|
||||
} from './workspace-surface-production'
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from './workspace-activation-recovery-presentation'
|
||||
import {
|
||||
isActivationRecoveryCurrent,
|
||||
isActivationRecoveryFresh,
|
||||
readActivationRenderableSurface,
|
||||
readActivationRenderableSurfaceById,
|
||||
readActivationRenderableSurfaceIds,
|
||||
@@ -21,8 +23,11 @@ import type {
|
||||
WorkspaceActivationRecoveryResult
|
||||
} from './worktree-activation-recovery'
|
||||
import type { WorkspaceActivationRecoveryOwnerContext } from './workspace-activation-recovery-retry'
|
||||
|
||||
const failureSurfaceIdsByProducerAttempt = new Map<string, ReadonlySet<string>>()
|
||||
import {
|
||||
clearActivationRecoveryFailureSnapshots,
|
||||
readActivationRecoveryFailureSurfaceIds,
|
||||
recordActivationRecoveryFailureSurfaceIds
|
||||
} from './workspace-activation-recovery-failure-snapshots'
|
||||
|
||||
export function publishActivationRecovery(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
@@ -80,25 +85,41 @@ export type ProducerAssessment =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'wait' }
|
||||
|
||||
function materializedFromVisibleInventory(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
entries: ReturnType<typeof readWorkspaceSurfaceProducerEntries>
|
||||
): WorkspaceActivationRecoveryResult | null {
|
||||
const surface = readActivationRenderableSurface(identity)
|
||||
if (!surface) {
|
||||
return null
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.result?.kind === 'unverifiable') {
|
||||
discardWorkspaceSurfaceProducerAttempt(entry.attempt.id)
|
||||
}
|
||||
}
|
||||
return activationRecoveryMaterializedResult(identity, surface)
|
||||
}
|
||||
|
||||
function materializedAfterProducerFailure(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
producerAttemptId: string
|
||||
): WorkspaceActivationRecoveryResult | null {
|
||||
const currentSurfaceIds = readActivationRenderableSurfaceIds(identity)
|
||||
const failureSurfaceIds = failureSurfaceIdsByProducerAttempt.get(producerAttemptId)
|
||||
const failureSurfaceIds = readActivationRecoveryFailureSurfaceIds(producerAttemptId)
|
||||
const laterSurfaceId = failureSurfaceIds
|
||||
? [...currentSurfaceIds].find((surfaceId) => !failureSurfaceIds.has(surfaceId))
|
||||
: undefined
|
||||
if (laterSurfaceId) {
|
||||
const laterSurface = readActivationRenderableSurfaceById(identity, laterSurfaceId)
|
||||
if (laterSurface) {
|
||||
failureSurfaceIdsByProducerAttempt.delete(producerAttemptId)
|
||||
clearActivationRecoveryFailureSnapshots([producerAttemptId])
|
||||
consumeWorkspaceSurfaceProducerAttempt(producerAttemptId)
|
||||
return activationRecoveryMaterializedResult(identity, laterSurface)
|
||||
}
|
||||
}
|
||||
if (!failureSurfaceIds) {
|
||||
failureSurfaceIdsByProducerAttempt.set(producerAttemptId, currentSurfaceIds)
|
||||
recordActivationRecoveryFailureSurfaceIds(producerAttemptId, currentSurfaceIds)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -120,8 +141,34 @@ export function assessActivationProducerAttempts(
|
||||
result: activationRecoveryFailedResult(identity, 'producer-failed')
|
||||
}
|
||||
}
|
||||
const unexpected = entries.find((entry) => entry.result?.kind === 'unexpected')
|
||||
if (unexpected?.result?.kind === 'unexpected') {
|
||||
publishActivationRecovery(identity, context, 'unexpected', unexpected.result.reason)
|
||||
return {
|
||||
kind: 'complete',
|
||||
result: activationRecoveryFailedResult(identity, 'unexpected')
|
||||
}
|
||||
}
|
||||
const blocked = entries.find((entry) => entry.result?.kind === 'blocked')
|
||||
if (blocked?.result?.kind === 'blocked') {
|
||||
publishActivationRecovery(identity, context, 'blocked', blocked.result.reason)
|
||||
return {
|
||||
kind: 'complete',
|
||||
result: activationRecoveryFailedResult(identity, 'blocked')
|
||||
}
|
||||
}
|
||||
const intentionalEmpty = entries.find((entry) => entry.result?.kind === 'intentional-empty')
|
||||
if (intentionalEmpty) {
|
||||
consumeWorkspaceSurfaceProducerAttempt(intentionalEmpty.attempt.id)
|
||||
clearActivationRecoveryPresentation(identity)
|
||||
return { kind: 'complete', result: { kind: 'intentional-empty' } }
|
||||
}
|
||||
const unverifiable = entries.find((entry) => entry.result?.kind === 'unverifiable')
|
||||
if (unverifiable?.result?.kind === 'unverifiable') {
|
||||
const visibleSurface = materializedFromVisibleInventory(identity, entries)
|
||||
if (visibleSurface) {
|
||||
return { kind: 'complete', result: visibleSurface }
|
||||
}
|
||||
publishActivationRecovery(identity, context, 'unverifiable', unverifiable.result.reason)
|
||||
return {
|
||||
kind: 'complete',
|
||||
@@ -184,18 +231,19 @@ export function assessActivationProducerAttempts(
|
||||
}
|
||||
: { kind: 'wait' }
|
||||
}
|
||||
const surface = readActivationRenderableSurface(identity)
|
||||
return surface
|
||||
? { kind: 'complete', result: activationRecoveryMaterializedResult(identity, surface) }
|
||||
: { kind: 'idle' }
|
||||
return { kind: 'idle' }
|
||||
}
|
||||
|
||||
export async function waitForActivationProducerAttempts(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
context: WorkspaceActivationRecoveryOwnerContext,
|
||||
deadlineAt: number
|
||||
deadlineAt: number,
|
||||
capturedSelectionRevision: number
|
||||
): Promise<WorkspaceActivationRecoveryResult | null> {
|
||||
while (true) {
|
||||
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
const assessment = assessActivationProducerAttempts(identity, context)
|
||||
if (assessment.kind === 'complete') {
|
||||
return assessment.result
|
||||
|
||||
@@ -14,51 +14,38 @@ import type {
|
||||
WorkspaceActivationContext,
|
||||
WorkspaceActivationIdentity
|
||||
} from './worktree-activation-recovery'
|
||||
import { readLatestActivationRecoveryAttempt } from './workspace-activation-recovery-attempts'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
export {
|
||||
activationRecoveryTargetKey,
|
||||
clearLatestActivationRecoveryAttempts,
|
||||
markLatestActivationRecoveryAttempt,
|
||||
readLatestActivationRecoveryAttempt
|
||||
} from './workspace-activation-recovery-attempts'
|
||||
|
||||
export type RecoveryWaitResult = 'changed' | 'cancelled' | 'timeout'
|
||||
|
||||
export const WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS = 30_000
|
||||
export const WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS = 200
|
||||
|
||||
const latestAttemptIdByTarget = new Map<string, string>()
|
||||
const gateIdentityByPromise = new WeakMap<Promise<unknown>, string>()
|
||||
let selectionRevision = 0
|
||||
let previousSelectionKey: string | null = null
|
||||
let disposeSelectionTracker: (() => void) | null = null
|
||||
|
||||
export function activationRecoveryTargetKey(identity: WorkspaceActivationIdentity): string {
|
||||
return `${identity.executionHostId}|${identity.workspaceKey}`
|
||||
}
|
||||
|
||||
export function activationRecoveryRouteKey(identity: WorkspaceActivationIdentity): string {
|
||||
return `${identity.executionHostId}|${identity.runtimeEnvironmentId ?? ''}`
|
||||
}
|
||||
|
||||
export function markLatestActivationRecoveryAttempt(identity: WorkspaceActivationIdentity): void {
|
||||
latestAttemptIdByTarget.set(activationRecoveryTargetKey(identity), identity.attemptId)
|
||||
}
|
||||
|
||||
export function readLatestActivationRecoveryAttempt(
|
||||
identity: WorkspaceActivationIdentity
|
||||
): string | undefined {
|
||||
return latestAttemptIdByTarget.get(activationRecoveryTargetKey(identity))
|
||||
}
|
||||
|
||||
export function readActivationRecoveryGateRoute(gate: Promise<unknown>): string | undefined {
|
||||
return gateIdentityByPromise.get(gate)
|
||||
}
|
||||
|
||||
export function recordActivationRecoveryGateRoute(gate: Promise<unknown>, route: string): void {
|
||||
gateIdentityByPromise.set(gate, route)
|
||||
}
|
||||
|
||||
function currentSelectionKey(): string {
|
||||
const state = useAppStore.getState()
|
||||
const workspaceKey = state.activeWorktreeId
|
||||
if (!workspaceKey) {
|
||||
return 'none'
|
||||
}
|
||||
return `${getExecutionHostIdForWorktree(state, workspaceKey)}|${getRuntimeEnvironmentIdForWorktree(state, workspaceKey) ?? ''}|${workspaceKey}`
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, workspaceKey)
|
||||
return JSON.stringify([
|
||||
getExecutionHostIdForWorktree(state, workspaceKey),
|
||||
runtimeEnvironmentId,
|
||||
runtimeEnvironmentId ? (getRuntimeEnvironmentRevision(runtimeEnvironmentId) ?? null) : null,
|
||||
workspaceKey
|
||||
])
|
||||
}
|
||||
|
||||
export function installActivationRecoverySelectionTracker(): void {
|
||||
@@ -96,12 +83,27 @@ export function isActivationRecoveryCurrent(
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return isActivationExecutionRouteCurrent(identity)
|
||||
}
|
||||
|
||||
export function isActivationExecutionRouteCurrent(
|
||||
identity: Pick<
|
||||
WorkspaceActivationIdentity,
|
||||
'workspaceKey' | 'executionHostId' | 'runtimeEnvironmentId'
|
||||
> & { runtimeEnvironmentRevision?: number | null }
|
||||
): boolean {
|
||||
const state = useAppStore.getState()
|
||||
return (
|
||||
state.activeWorktreeId === identity.workspaceKey &&
|
||||
getExecutionHostIdForWorktree(state, identity.workspaceKey) === identity.executionHostId &&
|
||||
getRuntimeEnvironmentIdForWorktree(state, identity.workspaceKey) ===
|
||||
identity.runtimeEnvironmentId
|
||||
identity.runtimeEnvironmentId &&
|
||||
(identity.runtimeEnvironmentRevision === undefined ||
|
||||
(identity.runtimeEnvironmentRevision === null
|
||||
? identity.runtimeEnvironmentId === null
|
||||
: identity.runtimeEnvironmentId !== null &&
|
||||
getRuntimeEnvironmentRevision(identity.runtimeEnvironmentId) ===
|
||||
identity.runtimeEnvironmentRevision))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,53 +225,11 @@ export function waitForActivationRecoveryChange(
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForActivationRecoveryPromise<T>(
|
||||
promise: Promise<T>,
|
||||
deadlineAt: number,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<
|
||||
| { kind: 'settled'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| Exclude<RecoveryWaitResult, 'changed'>
|
||||
> {
|
||||
const remaining = Math.max(0, deadlineAt - Date.now())
|
||||
if (remaining === 0) {
|
||||
return Promise.resolve('timeout')
|
||||
export function canInspectAgentActivationInventory(runtimeEnvironmentId: string | null): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (
|
||||
result:
|
||||
| { kind: 'settled'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| Exclude<RecoveryWaitResult, 'changed'>
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(result)
|
||||
}
|
||||
const onAbort = (): void => finish('cancelled')
|
||||
const timeout = setTimeout(() => finish('timeout'), remaining)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal?.aborted) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
void promise.then(
|
||||
(value) => finish({ kind: 'settled', value }),
|
||||
(error: unknown) => finish({ kind: 'rejected', error })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function canInspectAgentActivationInventory(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.api?.runtime?.call === 'function' &&
|
||||
typeof window.api?.pty?.listSessions === 'function'
|
||||
)
|
||||
return runtimeEnvironmentId
|
||||
? typeof window.api?.runtimeEnvironments?.subscribe === 'function'
|
||||
: typeof window.api?.runtime?.subscribe === 'function'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
|
||||
import type { WorkspaceActivationIdentity } from './worktree-activation-recovery'
|
||||
import {
|
||||
captureActivationRenderableSurfaceIds,
|
||||
settleActivationSeedProducer
|
||||
} from './worktree-activation-recovery-routing'
|
||||
import { isActivationExecutionRouteCurrent } from './workspace-activation-recovery-state'
|
||||
import type { WorkspaceExecutionEvidence } from './workspace-execution-evidence'
|
||||
import {
|
||||
consumeWorkspaceSurfaceProducerAttempt,
|
||||
registerWorkspaceSurfaceProducer
|
||||
} from './workspace-surface-production'
|
||||
|
||||
type RequestedSurfaceOwner = 'local' | 'runtime-transfer' | 'backend-confirmed'
|
||||
|
||||
type RequestedSurfaceProduction = {
|
||||
identity: WorkspaceActivationIdentity
|
||||
executionEvidence: WorkspaceExecutionEvidence
|
||||
owner: RequestedSurfaceOwner
|
||||
createSurface: () => string | null
|
||||
}
|
||||
|
||||
export function produceRequestedWorkspaceSurface({
|
||||
identity,
|
||||
executionEvidence,
|
||||
owner,
|
||||
createSurface
|
||||
}: RequestedSurfaceProduction): string | null {
|
||||
const producer = registerWorkspaceSurfaceProducer(identity)
|
||||
if (owner === 'runtime-transfer') {
|
||||
try {
|
||||
createSurface()
|
||||
producer.declined('Surface production transferred to the paired execution host.')
|
||||
consumeWorkspaceSurfaceProducerAttempt(producer.attempt.id)
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (owner === 'backend-confirmed') {
|
||||
try {
|
||||
const primaryTabId = createSurface()
|
||||
if (primaryTabId) {
|
||||
producer.materialized({ kind: 'tab', id: primaryTabId })
|
||||
} else {
|
||||
producer.unverifiable(
|
||||
'The execution host accepted the startup, but its surface is not visible yet.'
|
||||
)
|
||||
}
|
||||
return primaryTabId
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (executionEvidence !== 'exited') {
|
||||
producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
|
||||
return null
|
||||
}
|
||||
const route = {
|
||||
...identity,
|
||||
runtimeEnvironmentRevision: identity.runtimeEnvironmentId
|
||||
? (getRuntimeEnvironmentRevision(identity.runtimeEnvironmentId) ?? null)
|
||||
: null
|
||||
}
|
||||
try {
|
||||
const existingSurfaceIds = captureActivationRenderableSurfaceIds(identity.workspaceKey)
|
||||
resumeSleepingAgentSessionsForWorktree(identity.workspaceKey, {
|
||||
expectedExecutionHostId: route.executionHostId,
|
||||
expectedRuntimeEnvironmentId: route.runtimeEnvironmentId,
|
||||
...(route.runtimeEnvironmentRevision === null
|
||||
? {}
|
||||
: { expectedRuntimeEnvironmentRevision: route.runtimeEnvironmentRevision })
|
||||
})
|
||||
if (!isActivationExecutionRouteCurrent(route)) {
|
||||
producer.unverifiable('The workspace execution route changed before surface creation.')
|
||||
return null
|
||||
}
|
||||
const primaryTabId = createSurface()
|
||||
settleActivationSeedProducer(producer, identity.workspaceKey, primaryTabId, existingSurfaceIds)
|
||||
return primaryTabId
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceActivationIdentity } from './worktree-activation-recovery'
|
||||
import {
|
||||
readWorkspaceSurfaceProducerEntries,
|
||||
resetWorkspaceSurfaceProducersForTests
|
||||
} from './workspace-surface-production'
|
||||
import { startWorkspaceActivationSurfaceProducer } from './workspace-activation-surface-producer'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
evidence: vi.fn(),
|
||||
gate: vi.fn(),
|
||||
hasSleeping: vi.fn(),
|
||||
hasTombstone: vi.fn(),
|
||||
isRouteCurrent: vi.fn(),
|
||||
readSurface: vi.fn(),
|
||||
runtimeRevision: vi.fn(),
|
||||
seed: vi.fn(),
|
||||
state: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mocks.state }
|
||||
}))
|
||||
vi.mock('./workspace-execution-evidence', () => ({
|
||||
resolveWorkspaceExecutionEvidence: mocks.evidence
|
||||
}))
|
||||
vi.mock('./workspace-activation-recovery-state', () => ({
|
||||
canInspectAgentActivationInventory: () => true,
|
||||
hasLiveActivationTerminalTombstone: mocks.hasTombstone,
|
||||
isActivationExecutionRouteCurrent: mocks.isRouteCurrent,
|
||||
readActivationRenderableSurface: mocks.readSurface,
|
||||
WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS: 30_000
|
||||
}))
|
||||
vi.mock('./worktree-agent-activation-gate', () => ({
|
||||
gateWorktreeAgentActivation: mocks.gate
|
||||
}))
|
||||
vi.mock('./worktree-agent-activation-claims', () => ({
|
||||
workspaceHasSleepingAgentSessions: mocks.hasSleeping
|
||||
}))
|
||||
vi.mock('./worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: mocks.seed
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-environment-revision', () => ({
|
||||
getRuntimeEnvironmentRevision: mocks.runtimeRevision
|
||||
}))
|
||||
|
||||
const IDENTITY: WorkspaceActivationIdentity = {
|
||||
workspaceKey: 'worktree-1',
|
||||
executionHostId: 'local',
|
||||
runtimeEnvironmentId: null,
|
||||
attemptId: 'activation-1'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetWorkspaceSurfaceProducersForTests()
|
||||
mocks.evidence.mockReset().mockReturnValue('exited')
|
||||
mocks.gate.mockReset().mockResolvedValue('empty')
|
||||
mocks.hasSleeping.mockReset().mockReturnValue(false)
|
||||
mocks.hasTombstone.mockReset().mockReturnValue(false)
|
||||
mocks.isRouteCurrent.mockReset().mockReturnValue(true)
|
||||
mocks.readSurface.mockReset().mockReturnValue(null)
|
||||
mocks.runtimeRevision.mockReset().mockReturnValue(undefined)
|
||||
mocks.seed.mockReset().mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('workspace activation surface producer', () => {
|
||||
it('contains an initial inventory error as producer settlement', () => {
|
||||
mocks.readSurface.mockImplementation(() => {
|
||||
throw new Error('inventory unavailable')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
).not.toThrow()
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'unexpected', reason: 'inventory unavailable' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('owns shell creation after the route-scoped gate proves it safe', async () => {
|
||||
mocks.readSurface
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({ id: 'seeded-tab', type: 'terminal' })
|
||||
mocks.seed.mockReturnValue('seeded-tab')
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'materialized', surface: { kind: 'tab', id: 'seeded-tab' } } }
|
||||
])
|
||||
)
|
||||
expect(mocks.gate).toHaveBeenCalledWith(
|
||||
{ ...IDENTITY, runtimeEnvironmentRevision: null },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
expect(mocks.seed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('captures the saved runtime pairing revision in the gate route', async () => {
|
||||
mocks.runtimeRevision.mockReturnValue(17)
|
||||
const remoteIdentity: WorkspaceActivationIdentity = {
|
||||
...IDENTITY,
|
||||
executionHostId: 'runtime:environment-1',
|
||||
runtimeEnvironmentId: 'environment-1'
|
||||
}
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(remoteIdentity, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.gate).toHaveBeenCalledWith(
|
||||
{ ...remoteIdentity, runtimeEnvironmentRevision: 17 },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('still gates sleeping-session resumption when a husk surface remains visible', async () => {
|
||||
mocks.hasSleeping.mockReturnValue(true)
|
||||
mocks.readSurface.mockReturnValue({ id: 'husk-tab', type: 'terminal' })
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'materialized', surface: { kind: 'tab', id: 'husk-tab' } } }
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes a blocked settlement without starting a writer', async () => {
|
||||
mocks.gate.mockResolvedValue('blocked')
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'blocked' } }
|
||||
])
|
||||
)
|
||||
expect(mocks.seed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['adopted', 'structured', 'resumed'] as const)(
|
||||
'does not seed before a %s gate outcome becomes visible',
|
||||
async (outcome) => {
|
||||
mocks.gate.mockResolvedValue(outcome)
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'unverifiable' } }
|
||||
])
|
||||
)
|
||||
expect(mocks.seed).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('lets a later activation replace settled recovery ownership', async () => {
|
||||
mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty')
|
||||
mocks.readSurface
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({ id: 'retry-tab', type: 'terminal' })
|
||||
mocks.seed.mockReturnValue('retry-tab')
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)[0]?.result?.kind).toBe('blocked')
|
||||
)
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(
|
||||
{ ...IDENTITY, attemptId: 'activation-2' },
|
||||
{ mode: 'explicit' }
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'materialized', surface: { id: 'retry-tab' } } }
|
||||
])
|
||||
)
|
||||
expect(mocks.gate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('discards stale route ownership without starting a writer', async () => {
|
||||
mocks.gate.mockResolvedValue('stale')
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() => expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toEqual([]))
|
||||
expect(mocks.seed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks the route after a gate settles before starting a writer', async () => {
|
||||
let settleGate!: (outcome: 'empty') => void
|
||||
mocks.gate.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleGate = resolve
|
||||
})
|
||||
)
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
mocks.isRouteCurrent.mockReturnValue(false)
|
||||
settleGate('empty')
|
||||
|
||||
await vi.waitFor(() => expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toEqual([]))
|
||||
expect(mocks.seed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks the route after final inventory reconciliation', async () => {
|
||||
mocks.readSurface.mockReturnValueOnce(null).mockImplementation(() => {
|
||||
mocks.isRouteCurrent.mockReturnValue(false)
|
||||
return null
|
||||
})
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() => expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toEqual([]))
|
||||
expect(mocks.seed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps genuinely unverifiable execution writer-free', async () => {
|
||||
mocks.evidence.mockReturnValue('unverifiable')
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'unverifiable' } }
|
||||
])
|
||||
)
|
||||
expect(mocks.seed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a startup tombstone as intentional empty', async () => {
|
||||
mocks.hasTombstone.mockReturnValue(true)
|
||||
|
||||
startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'startup' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
|
||||
{ result: { kind: 'intentional-empty' } }
|
||||
])
|
||||
)
|
||||
expect(mocks.seed).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveWorkspaceExecutionEvidence } from './workspace-execution-evidence'
|
||||
import {
|
||||
canInspectAgentActivationInventory,
|
||||
hasLiveActivationTerminalTombstone,
|
||||
isActivationExecutionRouteCurrent,
|
||||
readActivationRenderableSurface,
|
||||
WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS
|
||||
} from './workspace-activation-recovery-state'
|
||||
import {
|
||||
discardWorkspaceSurfaceProducerAttempt,
|
||||
readWorkspaceSurfaceProducerEntries,
|
||||
registerWorkspaceSurfaceProducer,
|
||||
type WorkspaceSurfaceProducer
|
||||
} from './workspace-surface-production'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
|
||||
import { workspaceHasSleepingAgentSessions } from './worktree-agent-activation-claims'
|
||||
import type { WorktreeAgentActivationRoute } from './worktree-agent-activation-route'
|
||||
import type { WorkspaceActivationIdentity } from './worktree-activation-recovery'
|
||||
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
type ActivationSurfaceProductionContext = { mode: 'explicit' | 'startup' }
|
||||
|
||||
function settleProducedSurface(
|
||||
producer: WorkspaceSurfaceProducer,
|
||||
identity: WorkspaceActivationIdentity & WorktreeAgentActivationRoute,
|
||||
context: ActivationSurfaceProductionContext,
|
||||
maySeedEmptySurface: boolean
|
||||
): string | null {
|
||||
if (!isActivationExecutionRouteCurrent(identity)) {
|
||||
discardWorkspaceSurfaceProducerAttempt(producer.attempt.id)
|
||||
return null
|
||||
}
|
||||
const visible = readActivationRenderableSurface(identity)
|
||||
if (!isActivationExecutionRouteCurrent(identity)) {
|
||||
discardWorkspaceSurfaceProducerAttempt(producer.attempt.id)
|
||||
return null
|
||||
}
|
||||
if (visible) {
|
||||
producer.materialized({ kind: 'tab', id: visible.id })
|
||||
return visible.id
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const evidence = resolveWorkspaceExecutionEvidence(
|
||||
state,
|
||||
identity.workspaceKey,
|
||||
identity.executionHostId
|
||||
)
|
||||
if (evidence !== 'exited') {
|
||||
producer.unverifiable('Orca cannot verify the execution host for this workspace surface.')
|
||||
return null
|
||||
}
|
||||
if (!maySeedEmptySurface) {
|
||||
producer.unverifiable(
|
||||
'The activation gate found execution ownership, but its surface is not visible yet.'
|
||||
)
|
||||
return null
|
||||
}
|
||||
const primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
state,
|
||||
identity.workspaceKey,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ reseedEmptiedWorkspace: context.mode === 'explicit' }
|
||||
)
|
||||
const materialized = primaryTabId ? readActivationRenderableSurface(identity) : null
|
||||
if (materialized) {
|
||||
producer.materialized({ kind: 'tab', id: materialized.id })
|
||||
return materialized.id
|
||||
}
|
||||
if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
|
||||
producer.intentionalEmpty()
|
||||
return null
|
||||
}
|
||||
producer.unverifiable('The activation producer did not publish a renderable surface.')
|
||||
return null
|
||||
}
|
||||
|
||||
function clearSettledActivationOwnership(identity: WorkspaceActivationIdentity): void {
|
||||
for (const entry of readWorkspaceSurfaceProducerEntries(identity)) {
|
||||
if (entry.attempt.purpose === 'activation-recovery' && entry.result !== null) {
|
||||
discardWorkspaceSurfaceProducerAttempt(entry.attempt.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startWorkspaceActivationSurfaceProducer(
|
||||
identity: WorkspaceActivationIdentity,
|
||||
context: ActivationSurfaceProductionContext
|
||||
): string | null {
|
||||
const route: WorkspaceActivationIdentity & WorktreeAgentActivationRoute = {
|
||||
...identity,
|
||||
runtimeEnvironmentRevision: identity.runtimeEnvironmentId
|
||||
? (getRuntimeEnvironmentRevision(identity.runtimeEnvironmentId) ?? null)
|
||||
: null
|
||||
}
|
||||
let visible: ReturnType<typeof readActivationRenderableSurface>
|
||||
try {
|
||||
visible = readActivationRenderableSurface(identity)
|
||||
} catch (error) {
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: identity.workspaceKey,
|
||||
executionHostId: identity.executionHostId,
|
||||
purpose: 'activation-recovery'
|
||||
})
|
||||
producer.unexpected(error)
|
||||
return null
|
||||
}
|
||||
if (!isActivationExecutionRouteCurrent(route)) {
|
||||
return null
|
||||
}
|
||||
const hasSleepingSessions = workspaceHasSleepingAgentSessions(
|
||||
useAppStore.getState(),
|
||||
identity.workspaceKey
|
||||
)
|
||||
if (visible && !hasSleepingSessions) {
|
||||
return visible.id
|
||||
}
|
||||
clearSettledActivationOwnership(identity)
|
||||
if (readWorkspaceSurfaceProducerEntries(identity).length > 0) {
|
||||
return null
|
||||
}
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: identity.workspaceKey,
|
||||
executionHostId: identity.executionHostId,
|
||||
purpose: 'activation-recovery'
|
||||
})
|
||||
if (!canInspectAgentActivationInventory(identity.runtimeEnvironmentId) && !hasSleepingSessions) {
|
||||
try {
|
||||
return settleProducedSurface(producer, route, context, true)
|
||||
} catch (error) {
|
||||
producer.unexpected(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
void gateWorktreeAgentActivation(route, {
|
||||
timeoutMs: WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS
|
||||
}).then(
|
||||
(outcome) => {
|
||||
if (outcome === 'stale') {
|
||||
discardWorkspaceSurfaceProducerAttempt(producer.attempt.id)
|
||||
return
|
||||
}
|
||||
if (outcome === 'blocked') {
|
||||
producer.blocked(
|
||||
'Orca paused activation because the execution host did not provide complete ownership evidence.'
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
settleProducedSurface(producer, route, context, outcome === 'empty')
|
||||
} catch (error) {
|
||||
producer.unexpected(error)
|
||||
}
|
||||
},
|
||||
(error: unknown) => producer.unexpected(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
resolveWorkspaceTerminalHostAuthority,
|
||||
type WorkspaceTerminalHostAuthorityState
|
||||
@@ -20,7 +19,7 @@ export function resolveWorkspaceExecutionEvidence(
|
||||
if (!host || host.kind === 'local') {
|
||||
return 'exited'
|
||||
}
|
||||
if (host.kind === 'runtime' || parseWorkspaceKey(workspaceKey)?.type === 'folder') {
|
||||
if (host.kind === 'runtime') {
|
||||
return 'unverifiable'
|
||||
}
|
||||
const syncStatus = state.remoteWorkspaceSyncStatusByTargetId?.[host.targetId]
|
||||
|
||||
@@ -10,11 +10,15 @@ export type WorkspaceSurfaceProductionResult =
|
||||
| { kind: 'declined'; reason: string }
|
||||
| { kind: 'failed'; reason: string }
|
||||
| { kind: 'unverifiable'; reason: string }
|
||||
| { kind: 'blocked'; reason: string }
|
||||
| { kind: 'unexpected'; reason: string }
|
||||
| { kind: 'intentional-empty' }
|
||||
|
||||
export type WorkspaceSurfaceProducerAttempt = {
|
||||
id: string
|
||||
workspaceKey: string
|
||||
executionHostId: ExecutionHostId
|
||||
purpose?: 'activation-recovery'
|
||||
result: Promise<WorkspaceSurfaceProductionResult>
|
||||
}
|
||||
|
||||
@@ -24,6 +28,9 @@ export type WorkspaceSurfaceProducer = {
|
||||
declined: (reason: unknown) => void
|
||||
failed: (reason: unknown) => void
|
||||
unverifiable: (reason: unknown) => void
|
||||
blocked: (reason: unknown) => void
|
||||
unexpected: (reason: unknown) => void
|
||||
intentionalEmpty: () => void
|
||||
}
|
||||
|
||||
export type WorkspaceSurfaceProducerEntry = {
|
||||
@@ -53,6 +60,7 @@ export function registerWorkspaceSurfaceProducer(args: {
|
||||
workspaceKey: string
|
||||
executionHostId: ExecutionHostId
|
||||
attemptId?: string
|
||||
purpose?: 'activation-recovery'
|
||||
}): WorkspaceSurfaceProducer {
|
||||
const id = args.attemptId?.trim() || createBrowserUuid()
|
||||
if (entriesByAttemptId.has(id)) {
|
||||
@@ -66,17 +74,21 @@ export function registerWorkspaceSurfaceProducer(args: {
|
||||
id,
|
||||
workspaceKey: args.workspaceKey,
|
||||
executionHostId: args.executionHostId,
|
||||
...(args.purpose ? { purpose: args.purpose } : {}),
|
||||
result
|
||||
}
|
||||
const entry: WorkspaceSurfaceProducerEntry = {
|
||||
attempt,
|
||||
result: null,
|
||||
settle: (settlement) => {
|
||||
if (entry.result) {
|
||||
if (entry.result && entry.result.kind !== 'unverifiable') {
|
||||
return
|
||||
}
|
||||
const firstSettlement = entry.result === null
|
||||
entry.result = settlement
|
||||
settlePromise(settlement)
|
||||
if (firstSettlement) {
|
||||
settlePromise(settlement)
|
||||
}
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
@@ -87,7 +99,10 @@ export function registerWorkspaceSurfaceProducer(args: {
|
||||
materialized: (surface) => entry.settle({ kind: 'materialized', surface }),
|
||||
declined: (reason) => entry.settle({ kind: 'declined', reason: reasonText(reason) }),
|
||||
failed: (reason) => entry.settle({ kind: 'failed', reason: reasonText(reason) }),
|
||||
unverifiable: (reason) => entry.settle({ kind: 'unverifiable', reason: reasonText(reason) })
|
||||
unverifiable: (reason) => entry.settle({ kind: 'unverifiable', reason: reasonText(reason) }),
|
||||
blocked: (reason) => entry.settle({ kind: 'blocked', reason: reasonText(reason) }),
|
||||
unexpected: (reason) => entry.settle({ kind: 'unexpected', reason: reasonText(reason) }),
|
||||
intentionalEmpty: () => entry.settle({ kind: 'intentional-empty' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,19 +119,44 @@ export function readWorkspaceSurfaceProducerEntries(args: {
|
||||
|
||||
export function consumeWorkspaceSurfaceProducerAttempt(attemptId: string): void {
|
||||
const entry = entriesByAttemptId.get(attemptId)
|
||||
if (!entry || entry.result?.kind === 'unverifiable' || entry.result === null) {
|
||||
if (!entry || entry.result === null || entry.result.kind === 'unverifiable') {
|
||||
return
|
||||
}
|
||||
entriesByAttemptId.delete(attemptId)
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
export function discardWorkspaceSurfaceProducerAttempt(attemptId: string): void {
|
||||
if (entriesByAttemptId.delete(attemptId)) {
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspaceSurfaceProducerAttempts(args: {
|
||||
workspaceKey?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}): string[] {
|
||||
const removed: string[] = []
|
||||
for (const [attemptId, entry] of entriesByAttemptId) {
|
||||
if (
|
||||
(args.workspaceKey === undefined || entry.attempt.workspaceKey === args.workspaceKey) &&
|
||||
(args.executionHostId === undefined || entry.attempt.executionHostId === args.executionHostId)
|
||||
) {
|
||||
entriesByAttemptId.delete(attemptId)
|
||||
removed.push(attemptId)
|
||||
}
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
notifyListeners()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
export function subscribeWorkspaceSurfaceProducers(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function resetWorkspaceSurfaceProducersForTests(): void {
|
||||
entriesByAttemptId.clear()
|
||||
notifyListeners()
|
||||
clearWorkspaceSurfaceProducerAttempts({})
|
||||
}
|
||||
|
||||
@@ -117,6 +117,12 @@ describe('selection-free empty-workspace recovery', () => {
|
||||
resolveGate = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtime: { subscribe: vi.fn() },
|
||||
runtimeEnvironments: { subscribe: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
|
||||
useAppStore.getState().createTab(worktree.id)
|
||||
@@ -193,15 +199,6 @@ describe('folder activation recovery', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the direct general setter state-only', async () => {
|
||||
seedEmptyFolderWorkspace('local')
|
||||
|
||||
useAppStore.getState().setActiveWorktree(FOLDER_KEY, 'local')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([])
|
||||
})
|
||||
|
||||
it('does not start a writer for an unverifiable SSH folder', async () => {
|
||||
seedEmptyFolderWorkspace(SSH_HOST_ID)
|
||||
|
||||
@@ -210,4 +207,17 @@ describe('folder activation recovery', () => {
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([])
|
||||
})
|
||||
|
||||
it('seeds a connected SSH folder after its current inventory is synchronized', async () => {
|
||||
seedEmptyFolderWorkspace(SSH_HOST_ID)
|
||||
useAppStore.setState({
|
||||
remoteWorkspaceHydratedTargetIds: new Set(['conn-1']),
|
||||
remoteWorkspaceSyncStatusByTargetId: { 'conn-1': { phase: 'synced' } }
|
||||
})
|
||||
|
||||
const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID })
|
||||
|
||||
expect(result).toEqual({ primaryTabId: expect.any(String) })
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { PtyListedSession, PtySessionListScope } from '../../../shared/pty-listed-session'
|
||||
import type { RuntimeTerminalListResult } from '../../../shared/runtime-types'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
|
||||
import {
|
||||
@@ -11,6 +14,10 @@ import {
|
||||
resolveWorktreeOperationRouteResult,
|
||||
type WorktreeOperationRouteState
|
||||
} from './worktree-operation-route'
|
||||
import {
|
||||
runtimeTargetForActivationRoute,
|
||||
type WorktreeAgentActivationRoute
|
||||
} from './worktree-agent-activation-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'
|
||||
@@ -85,3 +92,49 @@ export async function listActivationPtySessions(
|
||||
return window.api.pty.listSessions()
|
||||
}
|
||||
}
|
||||
|
||||
export async function listActivationPtySessionsForRoute(
|
||||
route: WorktreeAgentActivationRoute,
|
||||
options: { timeoutMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<PtyListedSession[]> {
|
||||
const target = runtimeTargetForActivationRoute(route)
|
||||
if (!target) {
|
||||
throw new Error('activation PTY inventory route unavailable')
|
||||
}
|
||||
const result = await callRuntimeRpc<RuntimeTerminalListResult>(
|
||||
target,
|
||||
'terminal.list',
|
||||
{
|
||||
worktree: toRuntimeWorktreeSelector(route.workspaceKey),
|
||||
requireFreshPtyLiveness: true,
|
||||
includeVisualLayouts: false
|
||||
},
|
||||
{
|
||||
...options,
|
||||
...(route.runtimeEnvironmentRevision === null
|
||||
? {}
|
||||
: { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
|
||||
}
|
||||
)
|
||||
if (
|
||||
result.truncated ||
|
||||
!result.hostScope?.hostIds.includes(route.executionHostId) ||
|
||||
!Array.isArray(result.terminals)
|
||||
) {
|
||||
throw new Error('activation PTY inventory scope unavailable')
|
||||
}
|
||||
return result.terminals.flatMap((terminal) =>
|
||||
terminal.ptyId &&
|
||||
(terminal.executionHostId === undefined || terminal.executionHostId === route.executionHostId)
|
||||
? [
|
||||
{
|
||||
id: terminal.ptyId,
|
||||
cwd: terminal.worktreePath,
|
||||
title: terminal.title ?? '',
|
||||
worktreeId: terminal.worktreeId,
|
||||
agentOwnership: terminal.agentIdentity ? ('present' as const) : ('unknown' as const)
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,11 +63,14 @@ function selectionCouplingViolations(source: string): string[] {
|
||||
) {
|
||||
violations.push('selection controls recovery ownership')
|
||||
}
|
||||
if (/\.find\s*\([\s\S]{0,240}\blaunchAgent\b/.test(source)) {
|
||||
violations.push('selection-stamped legacy tab controls recovery')
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
describe('activation recovery architecture census', () => {
|
||||
it('classifies every general setter caller and keeps only the public activation path recovery-triggering', () => {
|
||||
it('classifies every general setter caller', () => {
|
||||
expect(callerCounts('setActiveWorktree')).toEqual({
|
||||
...stateOnlySetActiveWorktreeCallers,
|
||||
'src/renderer/src/lib/worktree-activation.ts': 1
|
||||
@@ -93,7 +96,8 @@ describe('activation recovery architecture census', () => {
|
||||
])
|
||||
expect(source).not.toContain('worktree-creation')
|
||||
expect(source).not.toContain('ensureWorktreeHasInitialTerminal')
|
||||
expect(selectionCouplingViolations(source)).toEqual([])
|
||||
expect(source).not.toContain('gateWorktreeAgentActivation')
|
||||
expect(source).not.toContain('.createTab(')
|
||||
})
|
||||
|
||||
it('detects aliases, option spreading, picker conditionals, and picker-minted fake claims', () => {
|
||||
@@ -101,7 +105,11 @@ describe('activation recovery architecture census', () => {
|
||||
`const callerWillProvideSurface = options.agent != null\nrecoverWorkspaceActivation(identity, { mode: 'explicit', callerWillProvideSurface })`,
|
||||
`recoverWorkspaceActivation({ ...options }, context)`,
|
||||
`if (selection.agent) recoverWorkspaceActivation(identity, context)`,
|
||||
`if (picker.agent) registerWorkspaceSurfaceProducer(identity)`
|
||||
`if (picker.agent) registerWorkspaceSurfaceProducer(identity)`,
|
||||
`const launchAgent = selection.agent
|
||||
const tab = tabs.find((candidate) => candidate.launchAgent === launchAgent)
|
||||
if (tab) useSurface(tab)
|
||||
else recoverWorkspaceActivation(identity, context)`
|
||||
]
|
||||
for (const fixture of fixtures) {
|
||||
expect(selectionCouplingViolations(fixture)).not.toEqual([])
|
||||
@@ -109,12 +117,30 @@ describe('activation recovery architecture census', () => {
|
||||
})
|
||||
|
||||
it('routes all production recovery requests through the owner, watcher, or create failure adapter', () => {
|
||||
expect(callerCounts('recoverWorkspaceActivation')).toEqual({
|
||||
const callers = {
|
||||
'src/renderer/src/components/use-terminal-watcher-effects.ts': 1,
|
||||
'src/renderer/src/lib/workspace-activation-recovery-retry.ts': 1,
|
||||
'src/renderer/src/lib/worktree-activation-recovery-routing.ts': 1,
|
||||
'src/renderer/src/lib/worktree-activation-recovery.ts': 1,
|
||||
'src/renderer/src/lib/worktree-creation-flow-execute.ts': 1
|
||||
}
|
||||
expect(callerCounts('recoverWorkspaceActivation')).toEqual(callers)
|
||||
for (const path of Object.keys(callers)) {
|
||||
expect(selectionCouplingViolations(readFileSync(join(process.cwd(), path), 'utf8'))).toEqual(
|
||||
[]
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans recovery ownership on workspace deletion and execution-host retirement', () => {
|
||||
expect(callerCounts('clearWorkspaceActivationRecoveryLifecycle')).toEqual({
|
||||
'src/renderer/src/lib/workspace-activation-recovery-lifecycle.ts': 1,
|
||||
'src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts': 1,
|
||||
'src/renderer/src/store/project-groups/project-group-mutations.ts': 1,
|
||||
'src/renderer/src/store/slices/ssh.ts': 1,
|
||||
'src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts': 1,
|
||||
'src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts': 1,
|
||||
'src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts': 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
} from './worktree-runtime-owner'
|
||||
import { startWorkspaceActivationSurfaceProducer } from './workspace-activation-surface-producer'
|
||||
|
||||
export function ensureFolderWorkspaceInitialTerminal(
|
||||
folderWorkspace: FolderWorkspace,
|
||||
@@ -90,6 +91,7 @@ export function recoverActivatedWorkspace(identity: WorkspaceActivationIdentity)
|
||||
const existingTabIds = new Set(
|
||||
(useAppStore.getState().tabsByWorktree[identity.workspaceKey] ?? []).map((tab) => tab.id)
|
||||
)
|
||||
startWorkspaceActivationSurfaceProducer(identity, { mode: 'explicit' })
|
||||
void recoverWorkspaceActivation(identity, { mode: 'explicit' })
|
||||
return (
|
||||
useAppStore
|
||||
@@ -103,6 +105,17 @@ export function finalizeActivatedWorkspaceSurface(
|
||||
primaryTabId: string | null,
|
||||
initialCwd?: string
|
||||
): string | null {
|
||||
if (primaryTabId) {
|
||||
for (const entry of readWorkspaceSurfaceProducerEntries(identity)) {
|
||||
if (
|
||||
entry.result?.kind === 'materialized' &&
|
||||
entry.result.surface.kind === 'tab' &&
|
||||
entry.result.surface.id === primaryTabId
|
||||
) {
|
||||
consumeWorkspaceSurfaceProducerAttempt(entry.attempt.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const settledPrimaryTabId = primaryTabId ?? recoverActivatedWorkspace(identity)
|
||||
if (settledPrimaryTabId && initialCwd) {
|
||||
useAppStore.getState().queueTabInitialCwd(settledPrimaryTabId, initialCwd)
|
||||
@@ -118,8 +131,3 @@ export function hasOutstandingActivationSurfaceProducer(
|
||||
entry.result?.kind !== 'materialized' || entry.result.surface.kind === 'workspace-content'
|
||||
)
|
||||
}
|
||||
|
||||
export function consumeTransferredActivationProducer(producer: WorkspaceSurfaceProducer): void {
|
||||
producer.declined('Surface production transferred to the paired execution host.')
|
||||
consumeWorkspaceSurfaceProducerAttempt(producer.attempt.id)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
registerWorkspaceSurfaceProducer,
|
||||
resetWorkspaceSurfaceProducersForTests
|
||||
} from './workspace-surface-production'
|
||||
import { isActivationExecutionRouteCurrent } from './workspace-activation-recovery-state'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
type FakeUnifiedTab = {
|
||||
id: string
|
||||
@@ -115,7 +117,6 @@ const mocks = vi.hoisted(() => {
|
||||
storeListeners.add(listener)
|
||||
return () => storeListeners.delete(listener)
|
||||
},
|
||||
gate: vi.fn(),
|
||||
authority: vi.fn(() => 'none'),
|
||||
structuredStatus: vi.fn(() => 'idle'),
|
||||
subscribeStructured: (listener: () => void) => {
|
||||
@@ -135,9 +136,6 @@ vi.mock('@/store', () => ({
|
||||
vi.mock('@/components/terminal/initial-terminal', () => ({
|
||||
shouldAutoCreateInitialTerminal: (count: number) => count === 0
|
||||
}))
|
||||
vi.mock('./worktree-agent-activation-gate', () => ({
|
||||
gateWorktreeAgentActivation: mocks.gate
|
||||
}))
|
||||
vi.mock('./workspace-terminal-host-authority', () => ({
|
||||
resolveWorkspaceTerminalHostAuthority: mocks.authority
|
||||
}))
|
||||
@@ -166,12 +164,6 @@ function identity(
|
||||
}
|
||||
}
|
||||
|
||||
function forceGate(): void {
|
||||
mocks.state().sleepingAgentSessionsByPaneKey = {
|
||||
pane: { worktreeId: mocks.state().activeWorktreeId }
|
||||
}
|
||||
}
|
||||
|
||||
function showSurface(contentType: FakeUnifiedTab['contentType'], id = 'surface-1'): void {
|
||||
mocks.state().unifiedTabsByWorktree[mocks.state().activeWorktreeId] = [{ id, contentType }]
|
||||
mocks.notifyStore()
|
||||
@@ -179,12 +171,11 @@ function showSurface(contentType: FakeUnifiedTab['contentType'], id = 'surface-1
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.reset()
|
||||
mocks.gate.mockReset()
|
||||
mocks.gate.mockResolvedValue('empty')
|
||||
mocks.authority.mockReset()
|
||||
mocks.authority.mockReturnValue('none')
|
||||
mocks.structuredStatus.mockReset()
|
||||
mocks.structuredStatus.mockReturnValue('idle')
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
resetWorkspaceSurfaceProducersForTests()
|
||||
resetWorkspaceActivationRecoveryPresentationsForTests()
|
||||
})
|
||||
@@ -194,9 +185,34 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('activation recovery failures', () => {
|
||||
it('invalidates a captured route when the saved runtime is re-paired', () => {
|
||||
mocks.state().activeWorkspaceExecutionHostId = 'runtime:environment-1'
|
||||
mocks.state().executionHostId = 'runtime:environment-1'
|
||||
mocks.state().runtimeEnvironmentId = 'environment-1'
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'environment-1', createdAt: 1, pairingRevision: 17 }])
|
||||
const route = {
|
||||
...identity('paired-runtime-attempt', {
|
||||
executionHostId: 'runtime:environment-1',
|
||||
runtimeEnvironmentId: 'environment-1'
|
||||
}),
|
||||
runtimeEnvironmentRevision: 17
|
||||
}
|
||||
|
||||
expect(isActivationExecutionRouteCurrent({ ...route, runtimeEnvironmentRevision: null })).toBe(
|
||||
false
|
||||
)
|
||||
expect(isActivationExecutionRouteCurrent(route)).toBe(true)
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'environment-1', createdAt: 1, pairingRevision: 18 }])
|
||||
expect(isActivationExecutionRouteCurrent(route)).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes a blocked error and starts no writer', async () => {
|
||||
forceGate()
|
||||
mocks.gate.mockResolvedValue('blocked')
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'blocked-producer'
|
||||
})
|
||||
producer.blocked('host ownership is incomplete')
|
||||
|
||||
const result = await recoverWorkspaceActivation(identity('blocked-attempt'), {
|
||||
mode: 'explicit'
|
||||
@@ -209,39 +225,34 @@ describe('activation recovery failures', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['resume', 'adoption'])(
|
||||
'contains an escaped %s rejection as an unexpected error',
|
||||
async () => {
|
||||
forceGate()
|
||||
mocks.gate.mockRejectedValue(new Error('inventory mutation failed'))
|
||||
|
||||
const result = await recoverWorkspaceActivation(identity('rejected-attempt'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
|
||||
kind: 'unexpected',
|
||||
detail: 'inventory mutation failed'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('turns a private seeder throw into an actionable error', async () => {
|
||||
mocks.state().createTab.mockImplementationOnce(() => {
|
||||
throw new Error('tab commit failed')
|
||||
it('contains an observer reconciliation rejection as an unexpected error', async () => {
|
||||
mocks.state().reconcileWorktreeTabModel = vi.fn(() => {
|
||||
throw new Error('inventory reconciliation failed')
|
||||
})
|
||||
|
||||
const result = await recoverWorkspaceActivation(identity('seeder-attempt'), {
|
||||
const result = await recoverWorkspaceActivation(identity('rejected-attempt'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
|
||||
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
|
||||
kind: 'unexpected',
|
||||
detail: 'tab commit failed'
|
||||
detail: 'inventory reconciliation failed'
|
||||
})
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not seed when no concrete producer owns an empty activation', async () => {
|
||||
const result = await recoverWorkspaceActivation(identity('observer-only-attempt'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
|
||||
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
|
||||
kind: 'unexpected',
|
||||
detail: 'No concrete surface producer owns this empty workspace activation.'
|
||||
})
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a failed concrete producer visible without substituting a shell', async () => {
|
||||
@@ -353,70 +364,89 @@ describe('activation recovery failures', () => {
|
||||
|
||||
it('bounds an inventory assessment and publishes an actionable timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
forceGate()
|
||||
mocks.gate.mockReturnValue(new Promise(() => undefined))
|
||||
registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'pending-producer'
|
||||
})
|
||||
|
||||
const recovery = recoverWorkspaceActivation(identity('deadline-attempt'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await expect(recovery).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
|
||||
await expect(recovery).resolves.toMatchObject({
|
||||
kind: 'deferred',
|
||||
ownerAttemptId: 'pending-producer'
|
||||
})
|
||||
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
|
||||
'unexpected'
|
||||
'unverifiable'
|
||||
)
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['adopted', 'structured', 'resumed'] as const)(
|
||||
'requires real publication after a %s gate outcome',
|
||||
async (outcome) => {
|
||||
vi.useFakeTimers()
|
||||
forceGate()
|
||||
mocks.gate.mockResolvedValue(outcome)
|
||||
it('accepts a later producer update after an unverifiable settlement', async () => {
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'updated-producer'
|
||||
})
|
||||
producer.unverifiable('publication acknowledgement was lost')
|
||||
|
||||
const recovery = recoverWorkspaceActivation(identity(`gate-${outcome}`), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('unverifiable-observation'), { mode: 'explicit' })
|
||||
).resolves.toMatchObject({ kind: 'deferred', ownerAttemptId: 'updated-producer' })
|
||||
|
||||
await expect(recovery).resolves.toMatchObject({ kind: 'deferred' })
|
||||
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
|
||||
'unverifiable'
|
||||
)
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
producer.materialized({ kind: 'tab', id: 'published-later' })
|
||||
const recovery = recoverWorkspaceActivation(identity('updated-observation'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
showSurface('agent-session', 'published-later')
|
||||
|
||||
await expect(recovery).resolves.toEqual({
|
||||
kind: 'materialized',
|
||||
surface: { id: 'published-later', type: 'agent-session' }
|
||||
})
|
||||
expect(readWorkspaceSurfaceProducerEntries(identity('unused'))).toEqual([])
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles visible inventory over an unverifiable producer verdict', async () => {
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'reconciled-producer'
|
||||
})
|
||||
producer.unverifiable('publication acknowledgement was lost')
|
||||
showSurface('browser', 'visible-after-uncertainty')
|
||||
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('reconciled-observation'), { mode: 'explicit' })
|
||||
).resolves.toEqual({
|
||||
kind: 'materialized',
|
||||
surface: { id: 'visible-after-uncertainty', type: 'browser' }
|
||||
})
|
||||
expect(readWorkspaceSurfaceProducerEntries(identity('unused'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('activation recovery settlement', () => {
|
||||
it('uses the live startup tombstone at final settlement', async () => {
|
||||
forceGate()
|
||||
let settleGate!: (outcome: string) => void
|
||||
mocks.gate.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleGate = resolve
|
||||
})
|
||||
)
|
||||
const recovery = recoverWorkspaceActivation(identity('startup-tombstone'), {
|
||||
mode: 'startup'
|
||||
})
|
||||
await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalled())
|
||||
it('uses the live startup tombstone during observation', async () => {
|
||||
mocks.state().tabsByWorktree[WORKSPACE_KEY] = []
|
||||
mocks.notifyStore()
|
||||
settleGate('empty')
|
||||
|
||||
await expect(recovery).resolves.toEqual({ kind: 'intentional-empty' })
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('startup-tombstone'), { mode: 'startup' })
|
||||
).resolves.toEqual({ kind: 'intentional-empty' })
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets an explicit reopen override a live tombstone', async () => {
|
||||
it('does not let the observer override a live tombstone with a writer', async () => {
|
||||
mocks.state().tabsByWorktree[WORKSPACE_KEY] = []
|
||||
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('explicit-tombstone'), { mode: 'explicit' })
|
||||
).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } })
|
||||
expect(mocks.state().createTab).toHaveBeenCalledOnce()
|
||||
).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -443,59 +473,64 @@ describe('activation recovery settlement', () => {
|
||||
)
|
||||
|
||||
it('cancels one startup request without consuming a later assessment', async () => {
|
||||
forceGate()
|
||||
mocks.gate.mockReturnValueOnce(new Promise(() => undefined)).mockResolvedValueOnce('empty')
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'startup-producer'
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const first = recoverWorkspaceActivation(identity('cancelled-startup'), {
|
||||
mode: 'startup',
|
||||
signal: abort.signal
|
||||
})
|
||||
await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
|
||||
await Promise.resolve()
|
||||
abort.abort()
|
||||
await expect(first).resolves.toEqual({ kind: 'stale' })
|
||||
producer.intentionalEmpty()
|
||||
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('replacement-startup'), { mode: 'startup' })
|
||||
).resolves.toMatchObject({ kind: 'materialized' })
|
||||
expect(mocks.state().createTab).toHaveBeenCalledOnce()
|
||||
).resolves.toEqual({ kind: 'intentional-empty' })
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates an old request across an away-and-back selection cycle', async () => {
|
||||
forceGate()
|
||||
let settleGate!: (outcome: string) => void
|
||||
const sharedGate = new Promise((resolve) => {
|
||||
settleGate = resolve
|
||||
const producer = registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'selection-producer'
|
||||
})
|
||||
mocks.gate.mockReturnValue(sharedGate)
|
||||
const first = recoverWorkspaceActivation(identity('before-away-and-back'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
|
||||
await Promise.resolve()
|
||||
|
||||
mocks.state().activeWorktreeId = 'worktree-2'
|
||||
mocks.notifyStore()
|
||||
mocks.state().activeWorktreeId = WORKSPACE_KEY
|
||||
mocks.notifyStore()
|
||||
settleGate('empty')
|
||||
|
||||
await expect(first).resolves.toEqual({ kind: 'stale' })
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
producer.materialized({ kind: 'workspace-content', id: 'new-selection-content' })
|
||||
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('after-away-and-back'), { mode: 'explicit' })
|
||||
).resolves.toMatchObject({ kind: 'materialized' })
|
||||
expect(mocks.state().createTab).toHaveBeenCalledOnce()
|
||||
).resolves.toEqual({
|
||||
kind: 'materialized',
|
||||
surface: { id: 'new-selection-content', type: 'workspace-content' }
|
||||
})
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a joined gate whose host tuple conflicts', async () => {
|
||||
forceGate()
|
||||
let settleGate!: (outcome: string) => void
|
||||
const sharedGate = new Promise((resolve) => {
|
||||
settleGate = resolve
|
||||
it('invalidates an observer whose captured host tuple changes', async () => {
|
||||
registerWorkspaceSurfaceProducer({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
executionHostId: 'local',
|
||||
attemptId: 'host-a-producer'
|
||||
})
|
||||
mocks.gate.mockReturnValue(sharedGate)
|
||||
const first = recoverWorkspaceActivation(identity('host-a'), { mode: 'explicit' })
|
||||
await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
|
||||
await Promise.resolve()
|
||||
|
||||
const sshHost = toSshExecutionHostId('box')
|
||||
mocks.state().executionHostId = sshHost
|
||||
@@ -507,13 +542,12 @@ describe('activation recovery settlement', () => {
|
||||
|
||||
await expect(second).resolves.toMatchObject({
|
||||
kind: 'deferred',
|
||||
reason: expect.stringContaining('another execution host')
|
||||
reason: expect.stringContaining('cannot verify the execution host')
|
||||
})
|
||||
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, sshHost)?.kind).toBe(
|
||||
'unverifiable'
|
||||
)
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
settleGate('empty')
|
||||
await expect(first).resolves.toEqual({ kind: 'stale' })
|
||||
})
|
||||
|
||||
@@ -555,7 +589,7 @@ describe('activation recovery settlement', () => {
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows SSH recovery only after a current synced inventory proves emptiness', async () => {
|
||||
it('keeps synced SSH observation writer-free without producer settlement', async () => {
|
||||
const sshHost = toSshExecutionHostId('box')
|
||||
mocks.state().executionHostId = sshHost
|
||||
mocks.state().activeWorkspaceExecutionHostId = sshHost
|
||||
@@ -567,8 +601,8 @@ describe('activation recovery settlement', () => {
|
||||
recoverWorkspaceActivation(identity('ssh-synced', { executionHostId: sshHost }), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } })
|
||||
expect(mocks.state().createTab).toHaveBeenCalledOnce()
|
||||
).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a producer-owned tab pending until real publication reaches inventory', async () => {
|
||||
@@ -618,9 +652,10 @@ describe('activation recovery settlement', () => {
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a reentrant newer activation own the final seed critical section', async () => {
|
||||
it('lets a reentrant newer activation own final reconciliation', async () => {
|
||||
let newerRecovery: Promise<unknown> | null = null
|
||||
mocks.runOnNextReconcile(() => {
|
||||
showSurface('terminal', 'newer-surface')
|
||||
newerRecovery = recoverWorkspaceActivation(identity('reentrant-newer'), {
|
||||
mode: 'explicit'
|
||||
})
|
||||
@@ -628,8 +663,11 @@ describe('activation recovery settlement', () => {
|
||||
|
||||
await expect(
|
||||
recoverWorkspaceActivation(identity('reentrant-older'), { mode: 'explicit' })
|
||||
).resolves.toMatchObject({ kind: 'materialized' })
|
||||
await expect(newerRecovery).resolves.toMatchObject({ kind: 'materialized' })
|
||||
expect(mocks.state().createTab).toHaveBeenCalledOnce()
|
||||
).resolves.toEqual({ kind: 'stale' })
|
||||
await expect(newerRecovery).resolves.toEqual({
|
||||
kind: 'materialized',
|
||||
surface: { id: 'newer-surface', type: 'terminal' }
|
||||
})
|
||||
expect(mocks.state().createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
isWebRuntimeSessionActive
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { registerWorktreeActivation } from '@/lib/worktree-activation-nav-registration'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
@@ -25,19 +24,15 @@ import type {
|
||||
WorktreeActivationOptions,
|
||||
WorktreeActivationSurfaceSelection
|
||||
} from './worktree-activation-surface-selection'
|
||||
import { registerWorkspaceSurfaceProducer } from './workspace-surface-production'
|
||||
import { resolveWorkspaceExecutionEvidence } from './workspace-execution-evidence'
|
||||
import {
|
||||
captureActivationRenderableSurfaceIds,
|
||||
consumeTransferredActivationProducer,
|
||||
createWorkspaceActivationIdentity,
|
||||
ensureFolderWorkspaceInitialTerminal,
|
||||
finalizeActivatedWorkspaceSurface,
|
||||
hasOutstandingActivationSurfaceProducer,
|
||||
hasWorkspaceActivationWork,
|
||||
recoverActivatedWorkspace,
|
||||
settleActivationSeedProducer
|
||||
hasWorkspaceActivationWork
|
||||
} from './worktree-activation-recovery-routing'
|
||||
import { produceRequestedWorkspaceSurface } from './workspace-activation-requested-surface'
|
||||
import {
|
||||
clearWorktreeActivationSidebarFilters,
|
||||
revealActivatedWorktree
|
||||
@@ -124,19 +119,12 @@ export function activateAndRevealFolderWorkspace(
|
||||
executionEvidence === 'live' && isWebRuntimeSessionActive(runtimeEnvironmentId)
|
||||
let primaryTabId: string | null = null
|
||||
if (opts?.startup && !delegatesToRuntime) {
|
||||
const producer = registerWorkspaceSurfaceProducer(identity)
|
||||
if (executionEvidence !== 'exited') {
|
||||
producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
|
||||
} else {
|
||||
try {
|
||||
const existingSurfaceIds = captureActivationRenderableSurfaceIds(workspaceKey)
|
||||
resumeSleepingAgentSessionsForWorktree(workspaceKey)
|
||||
primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts.startup)
|
||||
settleActivationSeedProducer(producer, workspaceKey, primaryTabId, existingSurfaceIds)
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
}
|
||||
}
|
||||
primaryTabId = produceRequestedWorkspaceSurface({
|
||||
identity,
|
||||
executionEvidence,
|
||||
owner: 'local',
|
||||
createSurface: () => ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts.startup)
|
||||
})
|
||||
}
|
||||
if (opts?.revealInSidebar !== false) {
|
||||
state.revealWorktreeInSidebar(
|
||||
@@ -152,7 +140,7 @@ export function activateAndRevealFolderWorkspace(
|
||||
agent: opts?.agent
|
||||
})
|
||||
}
|
||||
return { primaryTabId: primaryTabId ?? recoverActivatedWorkspace(identity) }
|
||||
return { primaryTabId: finalizeActivatedWorkspaceSurface(identity, primaryTabId) }
|
||||
}
|
||||
|
||||
export function activateAndRevealWorktree(
|
||||
@@ -223,9 +211,15 @@ export function activateAndRevealWorktree(
|
||||
// Why: sleeping destroys the local PTY but preserves the provider session id, so waking should
|
||||
// restore those CLI sessions. Ordering is load-bearing: resuming synchronously creates the
|
||||
// session's tab first, so the seeding below doesn't add a bare shell next to it.
|
||||
const producer = registerWorkspaceSurfaceProducer(identity)
|
||||
if (delegatesToRuntime) {
|
||||
try {
|
||||
primaryTabId = produceRequestedWorkspaceSurface({
|
||||
identity,
|
||||
executionEvidence,
|
||||
owner: delegatesToRuntime
|
||||
? 'runtime-transfer'
|
||||
: opts?.backendStartupTerminalSpawned
|
||||
? 'backend-confirmed'
|
||||
: 'local',
|
||||
createSurface: () =>
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
worktreeId,
|
||||
@@ -239,58 +233,7 @@ export function activateAndRevealWorktree(
|
||||
reseedEmptiedWorkspace: true
|
||||
}
|
||||
)
|
||||
consumeTransferredActivationProducer(producer)
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
}
|
||||
} else if (opts?.backendStartupTerminalSpawned) {
|
||||
try {
|
||||
primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
worktreeId,
|
||||
opts.startup,
|
||||
opts.setup,
|
||||
opts.issueCommand,
|
||||
opts.defaultTabs,
|
||||
{
|
||||
backendStartupTerminalSpawned: true,
|
||||
...(opts.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
|
||||
reseedEmptiedWorkspace: true
|
||||
}
|
||||
)
|
||||
if (primaryTabId) {
|
||||
producer.materialized({ kind: 'tab', id: primaryTabId })
|
||||
} else {
|
||||
producer.unverifiable(
|
||||
'The execution host accepted the startup, but its surface is not visible yet.'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
}
|
||||
} else if (executionEvidence !== 'exited') {
|
||||
producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
|
||||
} else {
|
||||
try {
|
||||
const existingSurfaceIds = captureActivationRenderableSurfaceIds(worktreeId)
|
||||
resumeSleepingAgentSessionsForWorktree(worktreeId)
|
||||
primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
useAppStore.getState(),
|
||||
worktreeId,
|
||||
opts?.startup,
|
||||
opts?.setup,
|
||||
opts?.issueCommand,
|
||||
opts?.defaultTabs,
|
||||
{
|
||||
...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
|
||||
reseedEmptiedWorkspace: true
|
||||
}
|
||||
)
|
||||
settleActivationSeedProducer(producer, worktreeId, primaryTabId, existingSurfaceIds)
|
||||
} catch (error) {
|
||||
producer.failed(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// 5. Clear sidebar filters hiding the target — reveal needs the card rendered, else it silently no-ops.
|
||||
if (opts?.clearSidebarFilters !== false) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { useAppStore } from '@/store'
|
||||
import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
|
||||
import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record'
|
||||
import type { StructuredActivationInventory } from './worktree-agent-structured-inventory'
|
||||
|
||||
type ActivationClaimsStore = Pick<
|
||||
ReturnType<typeof useAppStore.getState>,
|
||||
| 'ptyIdsByTabId'
|
||||
| 'sleepingAgentSessionsByPaneKey'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'unifiedTabsByWorktree'
|
||||
>
|
||||
|
||||
export function workspaceHasSleepingAgentSessions(
|
||||
state: Pick<ActivationClaimsStore, 'sleepingAgentSessionsByPaneKey'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
return Object.values(state.sleepingAgentSessionsByPaneKey).some(
|
||||
(record) => record.worktreeId === worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
export function workspaceHasStructuredAgentSession(
|
||||
store: Pick<ActivationClaimsStore, 'unifiedTabsByWorktree'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
return (store.unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(tab) => tab.contentType === 'agent-session'
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean {
|
||||
if (parsePtySessionId(sessionId).worktreeId === worktreeId) {
|
||||
return true
|
||||
}
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
return (
|
||||
scope?.type === 'folder' &&
|
||||
sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) &&
|
||||
sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length
|
||||
)
|
||||
}
|
||||
|
||||
export function liveSleepingAgentClaimKeys(
|
||||
store: ActivationClaimsStore,
|
||||
worktreeId: string,
|
||||
livePtyIds: ReadonlySet<string>,
|
||||
structuredInventory: StructuredActivationInventory | null
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) {
|
||||
if (record.worktreeId !== worktreeId) {
|
||||
continue
|
||||
}
|
||||
const stable = parsePaneKey(record.paneKey)
|
||||
const tabId = record.tabId ?? stable?.tabId
|
||||
const layoutPtyId = stable
|
||||
? store.terminalLayoutsByTabId[stable.tabId]?.ptyIdsByLeafId?.[stable.leafId]
|
||||
: undefined
|
||||
const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined
|
||||
const structuredOwner =
|
||||
stable && isStructuredAgentSyntheticSleepingRecord(record)
|
||||
? structuredInventory?.ownerBySessionId.get(record.providerSession.id)
|
||||
: undefined
|
||||
if (structuredOwner?.owner === 'native') {
|
||||
keys.add(getProviderSessionClaimKey(record))
|
||||
continue
|
||||
}
|
||||
const structuredOwnerPtyId =
|
||||
structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined
|
||||
const persistedPtyId =
|
||||
layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId
|
||||
if (persistedPtyId && livePtyIds.has(persistedPtyId)) {
|
||||
keys.add(getProviderSessionClaimKey(record))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -6,14 +6,29 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-typ
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
||||
import type { TerminalSlice } from '@/store/slices/terminals'
|
||||
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
gateWorktreeAgentActivation,
|
||||
runWorktreeAgentActivationGate,
|
||||
waitForWorktreeAgentActivationGateForTests
|
||||
} from './worktree-agent-activation-gate'
|
||||
import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners'
|
||||
import type { WorktreeAgentActivationRoute } from './worktree-agent-activation-route'
|
||||
import * as activationPtyInventory from './worktree-activation-pty-inventory'
|
||||
import * as structuredInventory from './worktree-agent-structured-inventory'
|
||||
import * as recoveryState from './workspace-activation-recovery-state'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale'
|
||||
const LIVE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const DEAD_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const LOCAL_ROUTE: WorktreeAgentActivationRoute = {
|
||||
workspaceKey: WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
runtimeEnvironmentId: null,
|
||||
runtimeEnvironmentRevision: null
|
||||
}
|
||||
|
||||
function listed(id: string): PtyListedSession {
|
||||
return { id, cwd: '/worktree', title: 'Codex', agentOwnership: 'present' }
|
||||
@@ -325,7 +340,7 @@ describe('worktree agent activation gate', () => {
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession })
|
||||
).resolves.toBe('structured')
|
||||
|
||||
expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID, undefined)
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -340,6 +355,39 @@ describe('worktree agent activation gate', () => {
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
|
||||
})
|
||||
|
||||
it('fences resumed sessions to the captured route', async () => {
|
||||
const { deps, resume } = testDeps({})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps, LOCAL_ROUTE)).resolves.toBe(
|
||||
'resumed'
|
||||
)
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
skipClaimKeys: new Set(),
|
||||
expectedExecutionHostId: 'local',
|
||||
expectedRuntimeEnvironmentId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('fences a paired-runtime resume to the captured pairing revision', async () => {
|
||||
const { deps, resume } = testDeps({})
|
||||
const route: WorktreeAgentActivationRoute = {
|
||||
...LOCAL_ROUTE,
|
||||
executionHostId: 'runtime:environment-1',
|
||||
runtimeEnvironmentId: 'environment-1',
|
||||
runtimeEnvironmentRevision: 17
|
||||
}
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps, route)).resolves.toBe('resumed')
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
skipClaimKeys: new Set(),
|
||||
expectedExecutionHostId: 'runtime:environment-1',
|
||||
expectedRuntimeEnvironmentId: 'environment-1',
|
||||
expectedRuntimeEnvironmentRevision: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes a dead agent when the workspace only has a non-agent PTY', async () => {
|
||||
const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session')
|
||||
const plainPtyId = `${WORKTREE_ID}@@plain-shell`
|
||||
@@ -767,4 +815,92 @@ describe('worktree agent activation gate', () => {
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
|
||||
it('stops before adoption when the captured route changes during owner inventory', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab, resume } = testDeps({ sessions: [listed(livePtyId)] })
|
||||
let routeCurrent = true
|
||||
let resolveOwners!: (owners: LiveTerminalSurfaceOwnerIndex) => void
|
||||
deps.listSurfaceOwners.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveOwners = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const activation = runWorktreeAgentActivationGate(
|
||||
WORKTREE_ID,
|
||||
{ ...deps, isRouteCurrent: () => routeCurrent },
|
||||
LOCAL_ROUTE
|
||||
)
|
||||
await vi.waitFor(() => expect(deps.listSurfaceOwners).toHaveBeenCalledOnce())
|
||||
routeCurrent = false
|
||||
resolveOwners(new Map())
|
||||
|
||||
await expect(activation).resolves.toBe('stale')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resume after the gate deadline aborts inventory', async () => {
|
||||
const { deps, createTab, resume } = testDeps({})
|
||||
const controller = new AbortController()
|
||||
deps.listSessions.mockImplementation(async () => {
|
||||
controller.abort()
|
||||
return []
|
||||
})
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, deps, LOCAL_ROUTE, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 100
|
||||
})
|
||||
).resolves.toBe('blocked')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts deadline-bound inventory and releases the route dedupe entry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const originalReadiness = {
|
||||
workspaceSessionReady: useAppStore.getState().workspaceSessionReady,
|
||||
terminalStartupRestorationReady: useAppStore.getState().terminalStartupRestorationReady
|
||||
}
|
||||
useAppStore.setState({ workspaceSessionReady: true, terminalStartupRestorationReady: true })
|
||||
vi.stubGlobal('window', {})
|
||||
const isCurrent = vi
|
||||
.spyOn(recoveryState, 'isActivationExecutionRouteCurrent')
|
||||
.mockReturnValue(true)
|
||||
const structured = vi
|
||||
.spyOn(structuredInventory, 'readWorktreeStructuredActivationInventory')
|
||||
.mockResolvedValue(false)
|
||||
const list = vi
|
||||
.spyOn(activationPtyInventory, 'listActivationPtySessionsForRoute')
|
||||
.mockImplementation(
|
||||
(_route, operation = {}) =>
|
||||
new Promise((_, reject) => {
|
||||
operation.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(new Error('inventory aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const activation = gateWorktreeAgentActivation(LOCAL_ROUTE, { timeoutMs: 100 })
|
||||
expect(waitForWorktreeAgentActivationGateForTests(WORKTREE_ID)).toBe(activation)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await expect(activation).resolves.toBe('blocked')
|
||||
await expect(waitForWorktreeAgentActivationGateForTests(WORKTREE_ID)).resolves.toBeNull()
|
||||
expect(list).toHaveBeenCalledWith(
|
||||
LOCAL_ROUTE,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 100 })
|
||||
)
|
||||
structured.mockRestore()
|
||||
list.mockRestore()
|
||||
isCurrent.mockRestore()
|
||||
useAppStore.setState(originalReadiness)
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyListedSession } from '../../../shared/pty-listed-session'
|
||||
import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import { listActivationPtySessions } from './worktree-activation-pty-inventory'
|
||||
import { listActivationPtySessionsForRoute } from './worktree-activation-pty-inventory'
|
||||
import {
|
||||
resumeSleepingAgentSessionsForWorktree,
|
||||
type ResumeSleepingAgentSessionsOptions
|
||||
} from './resume-sleeping-agent-session'
|
||||
import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
|
||||
import {
|
||||
adoptLiveWorkspacePtySurfaces,
|
||||
bindLivePtyToExactSurface,
|
||||
@@ -17,11 +14,21 @@ import {
|
||||
} from './worktree-agent-live-surface-adoption'
|
||||
import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners'
|
||||
import { readWorktreeLiveTerminalSurfaceOwners } from './worktree-live-terminal-surface-owners'
|
||||
import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record'
|
||||
import {
|
||||
readWorktreeStructuredActivationInventory,
|
||||
type StructuredActivationInventory
|
||||
} from './worktree-agent-structured-inventory'
|
||||
import {
|
||||
worktreeAgentActivationRouteKey,
|
||||
type WorktreeAgentActivationRoute
|
||||
} from './worktree-agent-activation-route'
|
||||
import { isActivationExecutionRouteCurrent } from './workspace-activation-recovery-state'
|
||||
import {
|
||||
liveSleepingAgentClaimKeys,
|
||||
sessionBelongsToWorkspace,
|
||||
workspaceHasSleepingAgentSessions,
|
||||
workspaceHasStructuredAgentSession
|
||||
} from './worktree-agent-activation-claims'
|
||||
|
||||
type ActivationStore = LiveSurfaceAdoptionStore &
|
||||
Pick<
|
||||
@@ -31,25 +38,35 @@ type ActivationStore = LiveSurfaceAdoptionStore &
|
||||
|
||||
type ActivationGateDeps = {
|
||||
getState: () => ActivationStore
|
||||
awaitReady?: () => Promise<boolean>
|
||||
listSessions: () => Promise<PtyListedSession[]>
|
||||
awaitReady?: (operation?: ActivationGateOperation) => Promise<boolean>
|
||||
listSessions: (operation?: ActivationGateOperation) => Promise<PtyListedSession[]>
|
||||
/** Host-recorded PTY→surface ownership; null when the host could not answer. */
|
||||
listSurfaceOwners: (worktreeId: string) => Promise<LiveTerminalSurfaceOwnerIndex | null>
|
||||
hasStructuredSession?: (worktreeId: string) => Promise<boolean | StructuredActivationInventory>
|
||||
listSurfaceOwners: (
|
||||
worktreeId: string,
|
||||
operation?: ActivationGateOperation
|
||||
) => Promise<LiveTerminalSurfaceOwnerIndex | null>
|
||||
hasStructuredSession?: (
|
||||
worktreeId: string,
|
||||
operation?: ActivationGateOperation
|
||||
) => Promise<boolean | StructuredActivationInventory>
|
||||
resume: (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number
|
||||
isRouteCurrent?: () => boolean
|
||||
}
|
||||
|
||||
type ActivationGateOperation = { signal: AbortSignal; timeoutMs: number }
|
||||
|
||||
export type WorktreeAgentActivationOutcome =
|
||||
| 'adopted'
|
||||
| 'structured'
|
||||
| 'resumed'
|
||||
| 'empty'
|
||||
| 'blocked'
|
||||
| 'stale'
|
||||
|
||||
const inFlightByWorktreeId = new Map<string, Promise<WorktreeAgentActivationOutcome>>()
|
||||
const inFlightByRoute = new Map<string, Promise<WorktreeAgentActivationOutcome>>()
|
||||
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
|
||||
|
||||
function waitForWorkspaceSessionReady(): Promise<boolean> {
|
||||
function waitForWorkspaceSessionReady(operation?: ActivationGateOperation): Promise<boolean> {
|
||||
const isReady = () => {
|
||||
const state = useAppStore.getState()
|
||||
return state.workspaceSessionReady && state.terminalStartupRestorationReady
|
||||
@@ -62,106 +79,67 @@ function waitForWorkspaceSessionReady(): Promise<boolean> {
|
||||
const settle = (ready: boolean) => {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe?.()
|
||||
operation?.signal.removeEventListener('abort', onAbort)
|
||||
resolve(ready)
|
||||
}
|
||||
const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS)
|
||||
const onAbort = (): void => settle(false)
|
||||
const timeout = setTimeout(
|
||||
() => settle(isReady()),
|
||||
operation?.timeoutMs ?? WORKSPACE_SESSION_READY_TIMEOUT_MS
|
||||
)
|
||||
unsubscribe = useAppStore.subscribe((state) => {
|
||||
if (state.workspaceSessionReady && state.terminalStartupRestorationReady) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
operation?.signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (operation?.signal.aborted) {
|
||||
settle(false)
|
||||
return
|
||||
}
|
||||
if (isReady()) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function workspaceHasSleepingAgentSessions(
|
||||
state: Pick<ReturnType<typeof useAppStore.getState>, 'sleepingAgentSessionsByPaneKey'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
return Object.values(state.sleepingAgentSessionsByPaneKey).some(
|
||||
(record) => record.worktreeId === worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
function hasStructuredSession(store: ActivationStore, worktreeId: string): boolean {
|
||||
return (store.unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(tab) => tab.contentType === 'agent-session'
|
||||
)
|
||||
}
|
||||
|
||||
function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean {
|
||||
if (parsePtySessionId(sessionId).worktreeId === worktreeId) {
|
||||
return true
|
||||
}
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
return (
|
||||
scope?.type === 'folder' &&
|
||||
sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) &&
|
||||
sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length
|
||||
)
|
||||
}
|
||||
|
||||
function liveSleepingAgentClaimKeys(
|
||||
store: ActivationStore,
|
||||
worktreeId: string,
|
||||
livePtyIds: ReadonlySet<string>,
|
||||
structuredInventory: StructuredActivationInventory | null
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) {
|
||||
if (record.worktreeId !== worktreeId) {
|
||||
continue
|
||||
}
|
||||
const stable = parsePaneKey(record.paneKey)
|
||||
const tabId = record.tabId ?? stable?.tabId
|
||||
const layoutPtyId = stable
|
||||
? store.terminalLayoutsByTabId[stable.tabId]?.ptyIdsByLeafId?.[stable.leafId]
|
||||
: undefined
|
||||
const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined
|
||||
const structuredOwner =
|
||||
stable && isStructuredAgentSyntheticSleepingRecord(record)
|
||||
? structuredInventory?.ownerBySessionId.get(record.providerSession.id)
|
||||
: undefined
|
||||
if (structuredOwner?.owner === 'native') {
|
||||
keys.add(getProviderSessionClaimKey(record))
|
||||
continue
|
||||
}
|
||||
// Packaged hydration can omit renderer bindings while main retains this session's exact TUI.
|
||||
const structuredOwnerPtyId =
|
||||
structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined
|
||||
const persistedPtyId =
|
||||
layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId
|
||||
if (persistedPtyId && livePtyIds.has(persistedPtyId)) {
|
||||
keys.add(getProviderSessionClaimKey(record))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export async function runWorktreeAgentActivationGate(
|
||||
worktreeId: string,
|
||||
deps: ActivationGateDeps
|
||||
deps: ActivationGateDeps,
|
||||
route?: WorktreeAgentActivationRoute,
|
||||
operation?: ActivationGateOperation
|
||||
): Promise<WorktreeAgentActivationOutcome> {
|
||||
const routeIsCurrent = (): boolean => deps.isRouteCurrent?.() !== false
|
||||
const mayAct = (): boolean => routeIsCurrent() && operation?.signal.aborted !== true
|
||||
const interruptedOutcome = (): WorktreeAgentActivationOutcome =>
|
||||
routeIsCurrent() ? 'blocked' : 'stale'
|
||||
if (!mayAct()) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
try {
|
||||
if (deps.awaitReady && !(await deps.awaitReady())) {
|
||||
return 'blocked'
|
||||
if (deps.awaitReady && !(await deps.awaitReady(operation))) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
} catch {
|
||||
return 'blocked'
|
||||
return interruptedOutcome()
|
||||
}
|
||||
if (!mayAct()) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
let structured = false
|
||||
let structuredInventory: StructuredActivationInventory | null = null
|
||||
try {
|
||||
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
|
||||
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId, operation)
|
||||
structuredInventory =
|
||||
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
|
||||
structured = Boolean(
|
||||
hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession
|
||||
workspaceHasStructuredAgentSession(deps.getState(), worktreeId) || reportedStructuredSession
|
||||
)
|
||||
} catch {
|
||||
return 'blocked'
|
||||
return interruptedOutcome()
|
||||
}
|
||||
if (!mayAct()) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
|
||||
const structuredTabs = structuredInventory?.snapshot.tabs.filter(
|
||||
@@ -189,10 +167,13 @@ export async function runWorktreeAgentActivationGate(
|
||||
|
||||
let sessions: PtyListedSession[]
|
||||
try {
|
||||
sessions = await deps.listSessions()
|
||||
sessions = await deps.listSessions(operation)
|
||||
} catch {
|
||||
// Inventory uncertainty cannot authorize a second writer.
|
||||
return 'blocked'
|
||||
return interruptedOutcome()
|
||||
}
|
||||
if (!mayAct()) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
|
||||
// Why either signal rather than a preference: a relay row's worktreeId can be seeded from the
|
||||
@@ -208,6 +189,9 @@ export async function runWorktreeAgentActivationGate(
|
||||
if (owner.owner !== 'tui') {
|
||||
continue
|
||||
}
|
||||
if (!mayAct()) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
if (
|
||||
!owner.terminal ||
|
||||
!liveWorkspacePtyIds.has(owner.terminal.ptyId) ||
|
||||
@@ -224,8 +208,12 @@ export async function runWorktreeAgentActivationGate(
|
||||
deps.getState,
|
||||
worktreeId,
|
||||
[...liveWorkspacePtyIds],
|
||||
deps.listSurfaceOwners
|
||||
(targetWorktreeId) => deps.listSurfaceOwners(targetWorktreeId, operation),
|
||||
mayAct
|
||||
)
|
||||
if (adoption.stale) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
liveSurfaceAdopted = adoption.surfaced
|
||||
// A live agent the user can no longer see has to be diagnosable from the console.
|
||||
if (adoption.declinedPtyIds.length > 0) {
|
||||
@@ -245,13 +233,25 @@ export async function runWorktreeAgentActivationGate(
|
||||
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
|
||||
return 'structured'
|
||||
}
|
||||
if (!mayAct()) {
|
||||
return interruptedOutcome()
|
||||
}
|
||||
const launched = deps.resume(worktreeId, {
|
||||
skipClaimKeys: liveSleepingAgentClaimKeys(
|
||||
deps.getState(),
|
||||
worktreeId,
|
||||
liveWorkspacePtyIds,
|
||||
structuredInventory
|
||||
)
|
||||
),
|
||||
...(route
|
||||
? {
|
||||
expectedExecutionHostId: route.executionHostId,
|
||||
expectedRuntimeEnvironmentId: route.runtimeEnvironmentId,
|
||||
...(route.runtimeEnvironmentRevision === null
|
||||
? {}
|
||||
: { expectedRuntimeEnvironmentRevision: route.runtimeEnvironmentRevision })
|
||||
}
|
||||
: {})
|
||||
})
|
||||
// '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.
|
||||
@@ -265,33 +265,54 @@ export async function runWorktreeAgentActivationGate(
|
||||
}
|
||||
|
||||
export function gateWorktreeAgentActivation(
|
||||
worktreeId: string
|
||||
route: WorktreeAgentActivationRoute,
|
||||
options: { timeoutMs?: number } = {}
|
||||
): Promise<WorktreeAgentActivationOutcome> {
|
||||
const existing = inFlightByWorktreeId.get(worktreeId)
|
||||
const key = worktreeAgentActivationRouteKey(route)
|
||||
const existing = inFlightByRoute.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const gate = runWorktreeAgentActivationGate(worktreeId, {
|
||||
getState: () => useAppStore.getState(),
|
||||
awaitReady: waitForWorkspaceSessionReady,
|
||||
listSessions: () =>
|
||||
typeof window === 'undefined'
|
||||
? Promise.resolve([])
|
||||
: listActivationPtySessions(useAppStore.getState(), worktreeId),
|
||||
listSurfaceOwners: readWorktreeLiveTerminalSurfaceOwners,
|
||||
hasStructuredSession: readWorktreeStructuredActivationInventory,
|
||||
resume: resumeSleepingAgentSessionsForWorktree
|
||||
}).finally(() => {
|
||||
if (inFlightByWorktreeId.get(worktreeId) === gate) {
|
||||
inFlightByWorktreeId.delete(worktreeId)
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = options.timeoutMs ?? WORKSPACE_SESSION_READY_TIMEOUT_MS
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
const operation = { signal: controller.signal, timeoutMs }
|
||||
const gate = runWorktreeAgentActivationGate(
|
||||
route.workspaceKey,
|
||||
{
|
||||
getState: () => useAppStore.getState(),
|
||||
awaitReady: waitForWorkspaceSessionReady,
|
||||
listSessions: (request) =>
|
||||
typeof window === 'undefined'
|
||||
? Promise.resolve([])
|
||||
: listActivationPtySessionsForRoute(route, request),
|
||||
listSurfaceOwners: (_worktreeId, request) =>
|
||||
readWorktreeLiveTerminalSurfaceOwners(route, request),
|
||||
hasStructuredSession: (_worktreeId, request) =>
|
||||
readWorktreeStructuredActivationInventory(route, request),
|
||||
resume: resumeSleepingAgentSessionsForWorktree,
|
||||
isRouteCurrent: () => isActivationExecutionRouteCurrent(route)
|
||||
},
|
||||
route,
|
||||
operation
|
||||
).finally(() => {
|
||||
clearTimeout(timeout)
|
||||
if (inFlightByRoute.get(key) === gate) {
|
||||
inFlightByRoute.delete(key)
|
||||
}
|
||||
})
|
||||
inFlightByWorktreeId.set(worktreeId, gate)
|
||||
inFlightByRoute.set(key, gate)
|
||||
return gate
|
||||
}
|
||||
|
||||
export function waitForWorktreeAgentActivationGateForTests(
|
||||
worktreeId: string
|
||||
): Promise<WorktreeAgentActivationOutcome | null> {
|
||||
return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null)
|
||||
for (const [key, gate] of inFlightByRoute) {
|
||||
const routeKey: unknown = JSON.parse(key)
|
||||
if (Array.isArray(routeKey) && routeKey[3] === worktreeId) {
|
||||
return gate
|
||||
}
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
worktreeAgentActivationRouteKey,
|
||||
type WorktreeAgentActivationRoute
|
||||
} from './worktree-agent-activation-route'
|
||||
|
||||
const LOCAL_ROUTE: WorktreeAgentActivationRoute = {
|
||||
workspaceKey: 'repo::/worktree',
|
||||
executionHostId: 'local',
|
||||
runtimeEnvironmentId: null,
|
||||
runtimeEnvironmentRevision: null
|
||||
}
|
||||
|
||||
describe('worktree agent activation route', () => {
|
||||
it('includes workspace, execution host, runtime, and pairing revision in the dedupe key', () => {
|
||||
const keys = [
|
||||
LOCAL_ROUTE,
|
||||
{ ...LOCAL_ROUTE, executionHostId: 'ssh:box' as const },
|
||||
{
|
||||
...LOCAL_ROUTE,
|
||||
executionHostId: 'runtime:environment-1' as const,
|
||||
runtimeEnvironmentId: 'environment-1',
|
||||
runtimeEnvironmentRevision: 1
|
||||
},
|
||||
{
|
||||
...LOCAL_ROUTE,
|
||||
executionHostId: 'runtime:environment-1' as const,
|
||||
runtimeEnvironmentId: 'environment-1',
|
||||
runtimeEnvironmentRevision: 2
|
||||
}
|
||||
].map(worktreeAgentActivationRouteKey)
|
||||
|
||||
expect(new Set(keys).size).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
export type WorktreeAgentActivationRoute = {
|
||||
workspaceKey: string
|
||||
executionHostId: ExecutionHostId
|
||||
runtimeEnvironmentId: string | null
|
||||
runtimeEnvironmentRevision: number | null
|
||||
}
|
||||
|
||||
export function worktreeAgentActivationRouteKey(route: WorktreeAgentActivationRoute): string {
|
||||
return JSON.stringify([
|
||||
route.executionHostId,
|
||||
route.runtimeEnvironmentId,
|
||||
route.runtimeEnvironmentRevision,
|
||||
route.workspaceKey
|
||||
])
|
||||
}
|
||||
|
||||
export function runtimeTargetForActivationRoute(
|
||||
route: WorktreeAgentActivationRoute
|
||||
): RuntimeClientTarget | null {
|
||||
const host = parseExecutionHostId(route.executionHostId)
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
if (host.kind === 'runtime') {
|
||||
return route.runtimeEnvironmentId === host.environmentId
|
||||
? { kind: 'environment', environmentId: host.environmentId }
|
||||
: null
|
||||
}
|
||||
return route.runtimeEnvironmentId === null ? { kind: 'local' } : null
|
||||
}
|
||||
@@ -161,7 +161,22 @@ function stubInventory(args?: {
|
||||
]
|
||||
: []
|
||||
)
|
||||
vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, pty: { listSessions } } })
|
||||
const runtimeSubscribe = vi.fn(
|
||||
async (
|
||||
request: { method: string; params?: unknown },
|
||||
callback: (response: unknown) => void
|
||||
) => {
|
||||
callback(await runtimeCall(request))
|
||||
return { unsubscribe: vi.fn() }
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtime: { call: runtimeCall, subscribe: runtimeSubscribe },
|
||||
runtimeEnvironments: { subscribe: vi.fn() },
|
||||
pty: { listSessions }
|
||||
}
|
||||
})
|
||||
return { runtimeCall, listSessions }
|
||||
}
|
||||
|
||||
@@ -331,7 +346,15 @@ describe('worktree agent activation seam', () => {
|
||||
method: 'session.tabs.list',
|
||||
params: { worktree: `id:${worktree.id}` }
|
||||
})
|
||||
expect(listSessions).toHaveBeenCalledExactlyOnceWith({ connectionId: null })
|
||||
expect(listSessions).not.toHaveBeenCalled()
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
method: 'terminal.list',
|
||||
params: {
|
||||
worktree: `id:${worktree.id}`,
|
||||
requireFreshPtyLiveness: true,
|
||||
includeVisualLayouts: false
|
||||
}
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
method: 'agentSession.handoffStatus',
|
||||
params: { sessionId: 'chat-1' }
|
||||
|
||||
@@ -80,8 +80,12 @@ function adoptHostOwnedSurface(
|
||||
getState: () => LiveSurfaceAdoptionStore,
|
||||
worktreeId: string,
|
||||
owner: LiveTerminalSurfaceOwner,
|
||||
materializedTabIds: Set<string>
|
||||
materializedTabIds: Set<string>,
|
||||
canMutate: () => boolean
|
||||
): boolean {
|
||||
if (!canMutate()) {
|
||||
return false
|
||||
}
|
||||
const store = getState()
|
||||
const known = tabExists(store, owner.tabId)
|
||||
if (bindLivePtyToExactSurface(store, worktreeId, owner)) {
|
||||
@@ -101,6 +105,9 @@ function adoptHostOwnedSurface(
|
||||
if (!layout?.root) {
|
||||
return false
|
||||
}
|
||||
if (!canMutate()) {
|
||||
return false
|
||||
}
|
||||
current.setTabLayout(owner.tabId, {
|
||||
...layout,
|
||||
root: {
|
||||
@@ -128,8 +135,9 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
getState: () => LiveSurfaceAdoptionStore,
|
||||
worktreeId: string,
|
||||
livePtyIds: readonly string[],
|
||||
listSurfaceOwners: (worktreeId: string) => Promise<LiveTerminalSurfaceOwnerIndex | null>
|
||||
): Promise<{ surfaced: boolean; declinedPtyIds: string[] }> {
|
||||
listSurfaceOwners: (worktreeId: string) => Promise<LiveTerminalSurfaceOwnerIndex | null>,
|
||||
canMutate: () => boolean = () => true
|
||||
): Promise<{ surfaced: boolean; declinedPtyIds: string[]; stale: boolean }> {
|
||||
// Why: ptyIdsByTabId holds only panes this renderer mounted, so a tab bound
|
||||
// solely in tab.ptyId or the persisted layout used to read as unbound.
|
||||
const unbound = livePtyIds.filter(
|
||||
@@ -138,7 +146,7 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
let surfaced = unbound.length < livePtyIds.length
|
||||
const declinedPtyIds: string[] = []
|
||||
if (unbound.length === 0) {
|
||||
return { surfaced, declinedPtyIds }
|
||||
return { surfaced, declinedPtyIds, stale: false }
|
||||
}
|
||||
let surfaceOwners: LiveTerminalSurfaceOwnerIndex | null
|
||||
try {
|
||||
@@ -146,8 +154,14 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
} catch {
|
||||
surfaceOwners = null
|
||||
}
|
||||
if (!canMutate()) {
|
||||
return { surfaced: false, declinedPtyIds: [], stale: true }
|
||||
}
|
||||
const materializedTabIds = new Set<string>()
|
||||
for (const ptyId of unbound) {
|
||||
if (!canMutate()) {
|
||||
return { surfaced: false, declinedPtyIds, stale: true }
|
||||
}
|
||||
// Why: a pane can mount while the census is in flight, so the pre-RPC
|
||||
// verdict is stale by the time it would authorize a mint.
|
||||
if (resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId).kind !== 'none') {
|
||||
@@ -156,7 +170,7 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
}
|
||||
const owner = surfaceOwners?.get(ptyId)
|
||||
if (owner) {
|
||||
if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds)) {
|
||||
if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds, canMutate)) {
|
||||
surfaced = true
|
||||
} else {
|
||||
declinedPtyIds.push(ptyId)
|
||||
@@ -176,5 +190,5 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
})
|
||||
surfaced = true
|
||||
}
|
||||
return { surfaced, declinedPtyIds }
|
||||
return { surfaced, declinedPtyIds, stale: false }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
runtimeTargetForActivationRoute,
|
||||
type WorktreeAgentActivationRoute
|
||||
} from './worktree-agent-activation-route'
|
||||
|
||||
export type StructuredActivationInventory = {
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
@@ -14,19 +19,35 @@ export type StructuredActivationInventory = {
|
||||
}
|
||||
|
||||
export async function readWorktreeStructuredActivationInventory(
|
||||
worktreeId: string
|
||||
routeOrWorktreeId: WorktreeAgentActivationRoute | string,
|
||||
options: { timeoutMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<false | StructuredActivationInventory> {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
const response = await window.api.runtime.call({
|
||||
method: 'session.tabs.list',
|
||||
params: { worktree: toRuntimeWorktreeSelector(worktreeId) }
|
||||
})
|
||||
if (!response.ok) {
|
||||
const route = typeof routeOrWorktreeId === 'string' ? null : routeOrWorktreeId
|
||||
const worktreeId =
|
||||
typeof routeOrWorktreeId === 'string' ? routeOrWorktreeId : routeOrWorktreeId.workspaceKey
|
||||
const target = route ? runtimeTargetForActivationRoute(route) : { kind: 'local' as const }
|
||||
if (!target) {
|
||||
throw new Error('structured session inventory route unavailable')
|
||||
}
|
||||
let snapshot: RuntimeMobileSessionTabsResult
|
||||
try {
|
||||
snapshot = await callRuntimeRpc<RuntimeMobileSessionTabsResult>(
|
||||
target,
|
||||
'session.tabs.list',
|
||||
{ worktree: toRuntimeWorktreeSelector(worktreeId) },
|
||||
{
|
||||
...options,
|
||||
...(route?.runtimeEnvironmentRevision === null || route === null
|
||||
? {}
|
||||
: { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
throw new Error('structured session inventory unavailable')
|
||||
}
|
||||
const snapshot = response.result as RuntimeMobileSessionTabsResult
|
||||
if (
|
||||
!snapshot ||
|
||||
typeof snapshot.worktree !== 'string' ||
|
||||
@@ -49,35 +70,43 @@ export async function readWorktreeStructuredActivationInventory(
|
||||
snapshot.tabs.flatMap((tab) =>
|
||||
tab.type === 'agent-session'
|
||||
? [
|
||||
window.api.runtime
|
||||
.call({ method: 'agentSession.handoffStatus', params: { sessionId: tab.sessionId } })
|
||||
.then((statusResponse) => {
|
||||
if (!statusResponse.ok) {
|
||||
return
|
||||
}
|
||||
const status = statusResponse.result as {
|
||||
owner?: unknown
|
||||
terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
|
||||
}
|
||||
if (status.owner === 'native') {
|
||||
ownerBySessionId.set(tab.sessionId, { owner: 'native' })
|
||||
} else if (
|
||||
status.owner === 'tui' &&
|
||||
typeof status.terminal?.paneKey === 'string' &&
|
||||
typeof status.terminal?.ptyId === 'string' &&
|
||||
status.terminal.ptyId.length > 0 &&
|
||||
typeof status.terminal.tabId === 'string'
|
||||
) {
|
||||
ownerBySessionId.set(tab.sessionId, {
|
||||
owner: 'tui',
|
||||
terminal: {
|
||||
paneKey: status.terminal.paneKey,
|
||||
ptyId: status.terminal.ptyId,
|
||||
tabId: status.terminal.tabId
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
callRuntimeRpc<{
|
||||
owner?: unknown
|
||||
terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
|
||||
}>(
|
||||
target,
|
||||
'agentSession.handoffStatus',
|
||||
{ sessionId: tab.sessionId },
|
||||
{
|
||||
...options,
|
||||
...(route?.runtimeEnvironmentRevision === null || route === null
|
||||
? {}
|
||||
: { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
|
||||
}
|
||||
).then((status) => {
|
||||
const typedStatus: {
|
||||
owner?: unknown
|
||||
terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
|
||||
} = status
|
||||
if (typedStatus.owner === 'native') {
|
||||
ownerBySessionId.set(tab.sessionId, { owner: 'native' })
|
||||
} else if (
|
||||
typedStatus.owner === 'tui' &&
|
||||
typeof typedStatus.terminal?.paneKey === 'string' &&
|
||||
typeof typedStatus.terminal?.ptyId === 'string' &&
|
||||
typedStatus.terminal.ptyId.length > 0 &&
|
||||
typeof typedStatus.terminal.tabId === 'string'
|
||||
) {
|
||||
ownerBySessionId.set(tab.sessionId, {
|
||||
owner: 'tui',
|
||||
terminal: {
|
||||
paneKey: typedStatus.terminal.paneKey,
|
||||
ptyId: typedStatus.terminal.ptyId,
|
||||
tabId: typedStatus.terminal.tabId
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
@@ -197,27 +197,25 @@ export async function executeWorktreeCreation(
|
||||
// infer a primary tab from default-tab ordering; only a fresh seed may
|
||||
// return one here.
|
||||
const stateAfterActivationFailure = useAppStore.getState()
|
||||
const existingTabs = stateAfterActivationFailure.tabsByWorktree[worktree.id] ?? []
|
||||
const launchAgent = startupOpt?.launchAgent ?? preparedRequest.agent
|
||||
const verifiedLaunchTabId =
|
||||
result.startupTerminal?.tabId ??
|
||||
(launchAgent ? existingTabs.find((tab) => tab.launchAgent === launchAgent)?.id : undefined)
|
||||
if (verifiedLaunchTabId) {
|
||||
// Startup terminal ids and stamped agent tabs are the only safe primary
|
||||
// ids when activation returned no result.
|
||||
primaryTabId = verifiedLaunchTabId
|
||||
} else {
|
||||
const recoveryState = useAppStore.getState()
|
||||
const identity = {
|
||||
workspaceKey: worktree.id,
|
||||
executionHostId: getExecutionHostIdForWorktree(recoveryState, worktree.id),
|
||||
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(recoveryState, worktree.id),
|
||||
attemptId: createBrowserUuid()
|
||||
}
|
||||
const producer = registerWorkspaceSurfaceProducer(identity)
|
||||
producer.failed(error)
|
||||
void recoverWorkspaceActivation(identity, { mode: 'explicit' })
|
||||
stateAfterActivationFailure.reconcileWorktreeTabModel(worktree.id)
|
||||
const startupTerminalTabId = result.startupTerminal?.tabId
|
||||
primaryTabId = startupTerminalTabId
|
||||
? ((useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).find(
|
||||
(tab) => tab.id === startupTerminalTabId
|
||||
)?.id ?? null)
|
||||
: null
|
||||
const identity = {
|
||||
workspaceKey: worktree.id,
|
||||
executionHostId: getExecutionHostIdForWorktree(stateAfterActivationFailure, worktree.id),
|
||||
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(
|
||||
stateAfterActivationFailure,
|
||||
worktree.id
|
||||
),
|
||||
attemptId: createBrowserUuid()
|
||||
}
|
||||
const producer = registerWorkspaceSurfaceProducer(identity)
|
||||
producer.failed(error)
|
||||
void recoverWorkspaceActivation(identity, { mode: 'explicit' })
|
||||
}
|
||||
} else {
|
||||
// Keep chat creation on its pending surface until the session is ready.
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { WorkspaceSurfaceProducer } from '@/lib/workspace-surface-productio
|
||||
// plus toast instead of a panel silently stuck at "creating".
|
||||
|
||||
type TestActiveView = 'terminal' | 'tasks'
|
||||
const initialUnifiedTabsByWorktree: Record<string, { id: string }[]> = {}
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
@@ -54,7 +55,8 @@ const store = {
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
createWorktree: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
|
||||
unifiedTabsByWorktree: {}
|
||||
unifiedTabsByWorktree: initialUnifiedTabsByWorktree,
|
||||
reconcileWorktreeTabModel: vi.fn()
|
||||
}
|
||||
|
||||
const surfaceProducer: WorkspaceSurfaceProducer = {
|
||||
@@ -67,7 +69,10 @@ const surfaceProducer: WorkspaceSurfaceProducer = {
|
||||
materialized: vi.fn(),
|
||||
declined: vi.fn(),
|
||||
failed: vi.fn(),
|
||||
unverifiable: vi.fn()
|
||||
unverifiable: vi.fn(),
|
||||
blocked: vi.fn(),
|
||||
unexpected: vi.fn(),
|
||||
intentionalEmpty: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
@@ -202,6 +207,7 @@ beforeEach(() => {
|
||||
store.activeView = 'terminal'
|
||||
store.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
store.tabsByWorktree = {}
|
||||
store.unifiedTabsByWorktree = {}
|
||||
store.pendingWorktreeCreations = {}
|
||||
store.activePendingCreationId = null
|
||||
store.createWorktree.mockResolvedValue({
|
||||
@@ -273,7 +279,7 @@ describe('a throw after createWorktree succeeds no longer strands the creation s
|
||||
})
|
||||
})
|
||||
|
||||
it('activating branch: routes draft and follow-up delivery to the stamped agent tab', async () => {
|
||||
it('activating branch: a selection-stamped legacy row cannot suppress recovery', async () => {
|
||||
const request = makeRequest({
|
||||
agent: 'codex',
|
||||
startupPlan: {
|
||||
@@ -296,8 +302,10 @@ describe('a throw after createWorktree succeeds no longer strands the creation s
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
expect(surfaceProducer.failed).toHaveBeenCalledWith(expect.any(Error))
|
||||
expect(recoverWorkspaceActivation).toHaveBeenCalledOnce()
|
||||
expect(ensureAgentStartupInTerminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ primaryTabId: 'agent-tab' })
|
||||
expect.objectContaining({ primaryTabId: null })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
} from '../../../shared/runtime-types'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
runtimeTargetForActivationRoute,
|
||||
type WorktreeAgentActivationRoute
|
||||
} from './worktree-agent-activation-route'
|
||||
|
||||
/** The exact surface the execution host records as owning a live PTY. */
|
||||
export type LiveTerminalSurfaceOwner = {
|
||||
@@ -94,28 +99,59 @@ export function indexLiveTerminalSurfaceOwners(
|
||||
* complete one for the workspace.
|
||||
*/
|
||||
export async function readWorktreeLiveTerminalSurfaceOwners(
|
||||
worktreeId: string
|
||||
routeOrWorktreeId: WorktreeAgentActivationRoute | string,
|
||||
options: { timeoutMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<LiveTerminalSurfaceOwnerIndex | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
const response = await window.api.runtime.call({
|
||||
method: 'terminal.list',
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId),
|
||||
limit: OWNER_LISTING_LIMIT,
|
||||
includeVisualLayouts: false
|
||||
}
|
||||
})
|
||||
if (!response.ok || !isScopedTerminalListResult(response.result)) {
|
||||
const route = typeof routeOrWorktreeId === 'string' ? null : routeOrWorktreeId
|
||||
const worktreeId =
|
||||
typeof routeOrWorktreeId === 'string' ? routeOrWorktreeId : routeOrWorktreeId.workspaceKey
|
||||
const target = route ? runtimeTargetForActivationRoute(route) : { kind: 'local' as const }
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const { hostScope, terminals, truncated } = response.result
|
||||
let result: RuntimeTerminalListResult
|
||||
try {
|
||||
result = await callRuntimeRpc<RuntimeTerminalListResult>(
|
||||
target,
|
||||
'terminal.list',
|
||||
{
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId),
|
||||
limit: OWNER_LISTING_LIMIT,
|
||||
includeVisualLayouts: false,
|
||||
requireFreshPtyLiveness: true
|
||||
},
|
||||
{
|
||||
...options,
|
||||
...(route?.runtimeEnvironmentRevision === null || route === null
|
||||
? {}
|
||||
: { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isScopedTerminalListResult(result)) {
|
||||
return null
|
||||
}
|
||||
const { hostScope, terminals, truncated } = result
|
||||
// A worktree-scoped listing names every host but the target's as omitted by
|
||||
// design, so completeness here is "the workspace's own host answered" —
|
||||
// `hostIds` holds exactly that host when it did. A truncated list never proves
|
||||
// any PTY unowned.
|
||||
return truncated === true || hostScope.hostIds.length === 0
|
||||
return truncated === true ||
|
||||
(route ? !hostScope.hostIds.includes(route.executionHostId) : hostScope.hostIds.length === 0)
|
||||
? null
|
||||
: indexLiveTerminalSurfaceOwners(terminals, worktreeId)
|
||||
: indexLiveTerminalSurfaceOwners(
|
||||
route
|
||||
? terminals.filter(
|
||||
(terminal) =>
|
||||
terminal.executionHostId === undefined ||
|
||||
terminal.executionHostId === route.executionHostId
|
||||
)
|
||||
: terminals,
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
markHostSessionMirrorHydrated,
|
||||
resetHostSessionMirrorHydrationForTests
|
||||
} from '@/runtime/host-session-mirror-hydration'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
// Pins the activation contract behind the run6-review-pr-11959 incident shape:
|
||||
// a persisted (husk) tab whose pane cannot resume in place gets ONE appended
|
||||
@@ -24,6 +25,10 @@ const HUSK_TAB_ID = 'husk-tab-1'
|
||||
const RUNTIME_ENV_ID = 'env-4f0a8c21'
|
||||
const RUNTIME_HOST_ID = `runtime:${encodeURIComponent(RUNTIME_ENV_ID)}` as ExecutionHostId
|
||||
|
||||
beforeEach(() => {
|
||||
replaceRuntimeEnvironmentRevisions([{ id: RUNTIME_ENV_ID, createdAt: 1, pairingRevision: 1 }])
|
||||
})
|
||||
|
||||
function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState> {
|
||||
return {
|
||||
repos: [
|
||||
@@ -132,6 +137,7 @@ function seedSleepingRecord(worktreeId: string, sessionId: string): void {
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
resetHostSessionMirrorHydrationForTests()
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
})
|
||||
|
||||
describe('preserved-pane replacement contract on workspace activation', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
|
||||
// Red repro for the aug20 "windows 2" incident (restart-reattach/resume-relaunch):
|
||||
// a runtime-owned (paired remote) worktree's web-mirror tab holds a sleeping
|
||||
@@ -22,6 +23,10 @@ const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const WEB_TAB_ID = 'web-terminal-host-tab-1'
|
||||
const RUNTIME_ENV_ID = 'env-abfee683'
|
||||
|
||||
beforeEach(() => {
|
||||
replaceRuntimeEnvironmentRevisions([{ id: RUNTIME_ENV_ID, createdAt: 1, pairingRevision: 1 }])
|
||||
})
|
||||
|
||||
function makeRuntimeOwnedWorktree(): ReturnType<typeof makeCreatedAgentWorktree> {
|
||||
const workspacePath = path.join(path.sep, 'workspace', 'feature')
|
||||
return {
|
||||
@@ -128,6 +133,7 @@ function seedSleepingRecord(worktreeId: string, sessionId: string): string {
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
})
|
||||
|
||||
describe('runtime-owned worktree activation with an unhydrated host mirror', () => {
|
||||
|
||||
@@ -25,6 +25,33 @@ function emptySessionTabsSnapshot(worktreeId: string): RuntimeMobileSessionTabsR
|
||||
}
|
||||
}
|
||||
|
||||
function emptyTerminalList() {
|
||||
return {
|
||||
terminals: [],
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
type TestRuntimeResponse = { ok: boolean; result: unknown }
|
||||
|
||||
function subscribableRuntime(call: (request: { method: string }) => Promise<TestRuntimeResponse>) {
|
||||
return {
|
||||
call,
|
||||
subscribe: vi.fn(
|
||||
async (request: { method: string }, onData: (response: TestRuntimeResponse) => void) => {
|
||||
let cancelled = false
|
||||
void call(request).then((response) => {
|
||||
if (!cancelled) {
|
||||
onData(response)
|
||||
}
|
||||
})
|
||||
return { unsubscribe: () => (cancelled = true) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState> {
|
||||
return {
|
||||
repos: [
|
||||
@@ -111,8 +138,8 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtime: {
|
||||
call: vi.fn(async ({ method }: { method: string }) =>
|
||||
runtime: subscribableRuntime(
|
||||
vi.fn(async ({ method }: { method: string }) =>
|
||||
method === 'terminal.list'
|
||||
? {
|
||||
ok: true,
|
||||
@@ -140,7 +167,7 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
|
||||
}
|
||||
: { ok: true, result: emptySessionTabsSnapshot(worktree.id) }
|
||||
)
|
||||
},
|
||||
),
|
||||
pty: {
|
||||
listSessions: vi.fn(async () => [
|
||||
{
|
||||
@@ -178,9 +205,15 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
|
||||
useAppStore.setState(baseState(worktree))
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtime: {
|
||||
call: vi.fn(async () => ({ ok: true, result: emptySessionTabsSnapshot(worktree.id) }))
|
||||
},
|
||||
runtime: subscribableRuntime(
|
||||
vi.fn(async ({ method }: { method: string }) => ({
|
||||
ok: true,
|
||||
result:
|
||||
method === 'terminal.list'
|
||||
? emptyTerminalList()
|
||||
: emptySessionTabsSnapshot(worktree.id)
|
||||
}))
|
||||
),
|
||||
pty: { listSessions: vi.fn(async () => []) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { callAbortableLocalRuntime } from './abortable-runtime-environment-call'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function stubLocalSubscription() {
|
||||
const unsubscribe = vi.fn()
|
||||
let respond!: (response: { ok: true; result: unknown }) => void
|
||||
const subscribe = vi.fn(
|
||||
async (_request: unknown, callback: (response: { ok: true; result: unknown }) => void) => {
|
||||
respond = callback
|
||||
return { unsubscribe }
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('window', { api: { runtime: { subscribe } } })
|
||||
return {
|
||||
respond: (response: { ok: true; result: unknown }) => respond(response),
|
||||
subscribe,
|
||||
unsubscribe
|
||||
}
|
||||
}
|
||||
|
||||
describe('abortable local runtime call', () => {
|
||||
it('unsubscribes the host request after a response', async () => {
|
||||
const runtime = stubLocalSubscription()
|
||||
const request = callAbortableLocalRuntime(
|
||||
'terminal.list',
|
||||
{ worktree: 'id:worktree-1' },
|
||||
100,
|
||||
new AbortController().signal
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
runtime.respond({ ok: true, result: { terminals: [] } })
|
||||
|
||||
await expect(request).resolves.toEqual({ ok: true, result: { terminals: [] } })
|
||||
expect(runtime.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('aborts and unsubscribes a request whose inventory never responds', async () => {
|
||||
const runtime = stubLocalSubscription()
|
||||
const controller = new AbortController()
|
||||
const request = callAbortableLocalRuntime('terminal.list', {}, 100, controller.signal)
|
||||
await Promise.resolve()
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(runtime.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('enforces a response deadline and unsubscribes the host request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const runtime = stubLocalSubscription()
|
||||
const request = callAbortableLocalRuntime(
|
||||
'terminal.list',
|
||||
{},
|
||||
100,
|
||||
new AbortController().signal
|
||||
)
|
||||
const settled = request.catch((error: unknown) => error)
|
||||
await Promise.resolve()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await expect(settled).resolves.toMatchObject({
|
||||
message: 'Runtime request timed out before terminal.list completed'
|
||||
})
|
||||
expect(runtime.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,79 @@ export function createRuntimeRpcAbortError(): Error {
|
||||
return error
|
||||
}
|
||||
|
||||
export function waitForAbortableRuntimeDependency<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(createRuntimeRpcAbortError())
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(createRuntimeRpcAbortError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function callAbortableLocalRuntime(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw createRuntimeRpcAbortError()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let handle: { unsubscribe: () => void } | null = null
|
||||
let settled = false
|
||||
const deadline =
|
||||
timeoutMs === undefined
|
||||
? null
|
||||
: setTimeout(() => {
|
||||
finish(() => reject(new Error(`Runtime request timed out before ${method} completed`)))
|
||||
}, timeoutMs)
|
||||
const finish = (complete: () => void): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (deadline !== null) {
|
||||
clearTimeout(deadline)
|
||||
}
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
handle?.unsubscribe()
|
||||
complete()
|
||||
}
|
||||
const onAbort = (): void => finish(() => reject(createRuntimeRpcAbortError()))
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void window.api.runtime
|
||||
.subscribe({ method, params }, (response) => finish(() => resolve(response)))
|
||||
.then((subscription) => {
|
||||
handle = subscription
|
||||
if (settled) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
.catch((error) => finish(() => reject(error)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function callAbortableRuntimeEnvironment(
|
||||
environmentId: string,
|
||||
method: string,
|
||||
|
||||
@@ -3,19 +3,21 @@ import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import { assertRuntimeStatusCompatible } from './runtime-protocol-compat'
|
||||
import { createRuntimeRpcAbortError } from './abortable-runtime-environment-call'
|
||||
import {
|
||||
callAbortableLocalRuntime,
|
||||
createRuntimeRpcAbortError,
|
||||
waitForAbortableRuntimeDependency
|
||||
} from './abortable-runtime-environment-call'
|
||||
import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call'
|
||||
import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from './runtime-rpc-result'
|
||||
import { unwrapRuntimeRpcResult } from './runtime-rpc-result'
|
||||
import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision'
|
||||
import type { RuntimeClientTarget } from './runtime-client-target'
|
||||
|
||||
export {
|
||||
getActiveRuntimeTarget,
|
||||
settingsForRuntimeOwner,
|
||||
type RuntimeClientTarget
|
||||
} from './runtime-client-target'
|
||||
export { getActiveRuntimeTarget, settingsForRuntimeOwner } from './runtime-client-target'
|
||||
export type { RuntimeClientTarget } from './runtime-client-target'
|
||||
export {
|
||||
hasRuntimeRpcErrorCode,
|
||||
isRuntimeScopeForbiddenError,
|
||||
RuntimeRpcCallError,
|
||||
unwrapRuntimeRpcResult
|
||||
} from './runtime-rpc-result'
|
||||
@@ -36,13 +38,6 @@ type RuntimeCompatibilityCacheEntry = {
|
||||
|
||||
const runtimeCompatibilityChecks = new Map<string, RuntimeCompatibilityCacheEntry>()
|
||||
|
||||
// Why: mobile-scope device tokens are denied non-allowlisted runtime methods
|
||||
// with code 'forbidden'. Callers use this to surface one scope-mismatch banner
|
||||
// instead of silently swallowing the failure into empty/retry-looping UI.
|
||||
export function isRuntimeScopeForbiddenError(error: unknown): boolean {
|
||||
return error instanceof RuntimeRpcCallError && error.code === 'forbidden'
|
||||
}
|
||||
|
||||
export async function callRuntimeRpc<TResult>(
|
||||
target: RuntimeClientTarget,
|
||||
method: string,
|
||||
@@ -69,10 +64,13 @@ export async function callRuntimeRpc<TResult>(
|
||||
method !== 'status.get' &&
|
||||
options.skipCompatibilityCheck !== true
|
||||
) {
|
||||
await ensureRuntimeEnvironmentCompatible(target.environmentId, {
|
||||
...options,
|
||||
expectedEnvironmentPairingRevision
|
||||
})
|
||||
await waitForAbortableRuntimeDependency(
|
||||
ensureRuntimeEnvironmentCompatible(target.environmentId, {
|
||||
...options,
|
||||
expectedEnvironmentPairingRevision
|
||||
}),
|
||||
options.signal
|
||||
)
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
throw createRuntimeRpcAbortError()
|
||||
@@ -82,7 +80,9 @@ export async function callRuntimeRpc<TResult>(
|
||||
: params
|
||||
const response =
|
||||
target.kind === 'local'
|
||||
? await window.api.runtime.call({ method, params: nextParams })
|
||||
? options.signal
|
||||
? await callAbortableLocalRuntime(method, nextParams, options.timeoutMs, options.signal)
|
||||
: await window.api.runtime.call({ method, params: nextParams })
|
||||
: await callRuntimeEnvironmentWithRevision({
|
||||
environmentId: target.environmentId,
|
||||
method,
|
||||
@@ -332,6 +332,4 @@ export async function assertRuntimeEnvironmentCapability(
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRuntimeCompatibilityCacheForTests(): void {
|
||||
clearRuntimeCompatibilityCache()
|
||||
}
|
||||
export const clearRuntimeCompatibilityCacheForTests = clearRuntimeCompatibilityCache
|
||||
|
||||
@@ -15,6 +15,12 @@ export class RuntimeRpcCallError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: mobile-scope device tokens are denied non-allowlisted runtime methods
|
||||
// with code 'forbidden'. Callers use this to surface one scope-mismatch banner.
|
||||
export function isRuntimeScopeForbiddenError(error: unknown): boolean {
|
||||
return error instanceof RuntimeRpcCallError && error.code === 'forbidden'
|
||||
}
|
||||
|
||||
export function unwrapRuntimeRpcResult<TResult>(response: RuntimeRpcResponse<TResult>): TResult {
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcCallError(response)
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getFolderWorkspaceUpdateIdentity,
|
||||
reconcileFailedFolderWorkspaceUpdate
|
||||
} from './folder-workspace-catalog'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
|
||||
|
||||
export type FolderWorkspaceUpdateField = keyof FolderWorkspaceUpdates
|
||||
|
||||
@@ -248,6 +249,10 @@ export function createFolderWorkspaceMutationActions(
|
||||
return false
|
||||
}
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
clearWorkspaceActivationRecoveryLifecycle({
|
||||
workspaceKey,
|
||||
executionHostId: ownerHostId
|
||||
})
|
||||
set((s) => ({
|
||||
folderWorkspaces: s.folderWorkspaces.filter(
|
||||
(workspace) =>
|
||||
|
||||
@@ -18,6 +18,9 @@ import { mergeProjectCompatibilityForHostRepoChange } from '../repos/repo-catalo
|
||||
import { applyProjectGroupDeleteCascade } from './project-group-removal-state'
|
||||
import { repoWithFetchedOwner, settingsForRepoOwner } from '../repos/owner-routing'
|
||||
import { projectGroupWithFetchedOwner } from './project-group-owner-stamping'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getFolderWorkspaceHostId } from '../folder-workspaces/folder-workspace-catalog'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
|
||||
|
||||
export function createProjectGroupMutationActions(
|
||||
set: Parameters<StateCreator<AppState>>[0],
|
||||
@@ -128,7 +131,18 @@ export function createProjectGroupMutationActions(
|
||||
if (!deleted) {
|
||||
return false
|
||||
}
|
||||
set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId))
|
||||
const current = get()
|
||||
const next = applyProjectGroupDeleteCascade(current, groupId, ownerHostId)
|
||||
set(next)
|
||||
for (const workspace of current.folderWorkspaces) {
|
||||
if (next.folderWorkspaces.includes(workspace)) {
|
||||
continue
|
||||
}
|
||||
clearWorkspaceActivationRecoveryLifecycle({
|
||||
workspaceKey: folderWorkspaceKey(workspace.id),
|
||||
executionHostId: getFolderWorkspaceHostId(workspace, current.projectGroups)
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project group:', err)
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
sshTargetGenerationsEqual,
|
||||
sshTargetLabelsEqual
|
||||
} from './ssh-target-cleanup'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
|
||||
|
||||
export type RemoteWorkspaceSyncStatus = {
|
||||
phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline'
|
||||
@@ -154,8 +156,12 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) =>
|
||||
sshTargetsHydrated: true
|
||||
}
|
||||
}),
|
||||
clearRemovedSshTargetState: (targetId) =>
|
||||
set((s) => buildRemovedSshTargetCleanupPatch(s, targetId) ?? s),
|
||||
clearRemovedSshTargetState: (targetId) => {
|
||||
clearWorkspaceActivationRecoveryLifecycle({
|
||||
executionHostId: toSshExecutionHostId(targetId)
|
||||
})
|
||||
set((s) => buildRemovedSshTargetCleanupPatch(s, targetId) ?? s)
|
||||
},
|
||||
markRemoteWorkspaceHydrated: (targetId) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.remoteWorkspaceHydratedTargetIds)
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
TerminalRecoveryRemountRequest,
|
||||
TerminalRecoveryRemountResult
|
||||
} from '../../../terminals/terminal-tab-recovery-ledger'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
|
||||
|
||||
export function createSetRenamingWorktreeId(
|
||||
set: WorktreeSliceSet,
|
||||
@@ -150,6 +151,12 @@ export function createPurgeWorktreeTerminalState(
|
||||
if (purgeableWorktreeTargets.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const target of purgeableWorktreeTargets) {
|
||||
clearWorkspaceActivationRecoveryLifecycle({
|
||||
workspaceKey: typeof target === 'string' ? target : target.id,
|
||||
...(typeof target === 'string' || !target.hostId ? {} : { executionHostId: target.hostId })
|
||||
})
|
||||
}
|
||||
set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../stale-runtime-host-rows'
|
||||
import { buildWorktreePurgeState } from './worktree-purge-state'
|
||||
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
|
||||
|
||||
export function createPurgeStaleRuntimeHostState(
|
||||
set: WorktreeSliceSet,
|
||||
@@ -26,6 +27,11 @@ export function createPurgeStaleRuntimeHostState(
|
||||
if (removed.size === 0) {
|
||||
return
|
||||
}
|
||||
for (const environmentId of removed) {
|
||||
clearWorkspaceActivationRecoveryLifecycle({
|
||||
executionHostId: toRuntimeExecutionHostId(environmentId)
|
||||
})
|
||||
}
|
||||
set((s) => {
|
||||
const repoIdsWithRemovedOwners = new Set<string>()
|
||||
const survivingRepoIds = new Set<string>()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
|
||||
import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency'
|
||||
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
|
||||
import { omitRecordKeys } from './record-key-omission'
|
||||
import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
|
||||
|
||||
export function applyRemoveWorktreeSuccessState(
|
||||
set: WorktreeSliceSet,
|
||||
@@ -15,6 +16,10 @@ export function applyRemoveWorktreeSuccessState(
|
||||
// Why outside `set`: it is module-scope, not store state. Dropping it also
|
||||
// re-arms the once-per-workspace warning if this id is ever added back.
|
||||
forgetAmbiguousOwnerWarnings([worktreeId])
|
||||
clearWorkspaceActivationRecoveryLifecycle({
|
||||
workspaceKey: worktreeId,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
})
|
||||
set((s) => {
|
||||
const worktreeIds = [worktreeId]
|
||||
const omitByWorktree = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, worktreeIds)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAppStore, type AppState } from '@/store'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
|
||||
import { resetWorkspaceSurfaceProducersForTests } from '@/lib/workspace-surface-production'
|
||||
|
||||
const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc'
|
||||
const ORIGINAL_TAB_ID = '1c897bc8-973b-47b4-9449-ac5fc6b726c3'
|
||||
@@ -86,6 +87,7 @@ function makeLayout(leafId: string, ptyId: string) {
|
||||
}
|
||||
|
||||
function seedWorkspace(options: { helper?: boolean } = {}): void {
|
||||
resetWorkspaceSurfaceProducersForTests()
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
const target = makeWorktree(WORKTREE_ID, WORKTREE_PATH)
|
||||
const canary = makeWorktree(CANARY_WORKTREE_ID, CANARY_WORKTREE_PATH)
|
||||
|
||||
Reference in New Issue
Block a user