From d243137e35d50709c409f3cf4278831ad8665bd2 Mon Sep 17 00:00:00 2001 From: Dong dahao <138882649+DTSFO@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:26:58 +0800 Subject: [PATCH] fix(orchestration): resolve explicit worker worktrees directly (#14275) * fix(orchestration): resolve explicit worker worktrees directly * fix(orchestration): share worker workspace resolution * fix(runtime): reject cross-host path ambiguity * fix(orchestration): share federated workspace resolution * refactor(runtime): share worktree host identity * test(orchestration): align worker lifecycle fixtures --------- Co-authored-by: Jinwoo-H --- src/main/runtime/orca-runtime.ts | 65 +++- ...ration-worker-workspace-resolution.test.ts | 305 ++++++++++++++++++ ...hestration-federation-agent-launch.test.ts | 14 +- .../rpc/methods/orchestration-federation.ts | 2 +- ...hestration-worker-release-recovery.test.ts | 2 +- .../orchestration-worker-release.test.ts | 2 +- .../rpc/methods/orchestration-workers.ts | 22 +- .../runtime/rpc/methods/orchestration.test.ts | 63 +++- ...eted-worker-retirement-resume.unit.test.ts | 2 +- 9 files changed, 433 insertions(+), 44 deletions(-) create mode 100644 src/main/runtime/orchestration-worker-workspace-resolution.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7101bef5f16..9ad85ed830f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -261,6 +261,7 @@ import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { LOCAL_EXECUTION_HOST_ID, getRepoExecutionHostId, + getWorktreeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, type ExecutionHostId @@ -2492,6 +2493,11 @@ type TerminalWorkspaceLaunchScope = { folderWorkspace: FolderWorkspace | null } +type ResolvedTerminalWorkspaceLaunchTarget = { + scope: TerminalWorkspaceLaunchScope + managedWorktree: ResolvedWorktree | null +} + type WorktreeLineageInput = { parentWorkspace?: string envParentWorkspace?: string @@ -21363,6 +21369,14 @@ export class OrcaRuntimeService { return await this.resolveWorktreeSelector(worktreeSelector) } + async showManagedTerminalWorkspace(worktreeSelector: string) { + const target = await this.resolveTerminalWorkspaceLaunchTarget(worktreeSelector) + if (!target.managedWorktree) { + throw new Error('selector_not_found') + } + return target.managedWorktree + } + async scanWorkspacePorts(repoId?: string): Promise { return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId)) } @@ -28630,7 +28644,7 @@ export class OrcaRuntimeService { private async resolveFolderWorkspaceLaunchScope( selector: string - ): Promise { + ): Promise<(TerminalWorkspaceLaunchScope & { folderWorkspace: FolderWorkspace }) | null> { const workspace = this.resolveFolderWorkspaceSelector(selector) if (!workspace) { return null @@ -28710,23 +28724,35 @@ export class OrcaRuntimeService { private async resolveTerminalWorkspaceLaunchScope( selector: string ): Promise { + return (await this.resolveTerminalWorkspaceLaunchTarget(selector)).scope + } + + private async resolveTerminalWorkspaceLaunchTarget( + selector: string + ): Promise { const floatingTerminalSelector = selector === FLOATING_TERMINAL_WORKTREE_ID || selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` if (floatingTerminalSelector) { // Why: the floating sentinel is terminal-only — no backing repo/worktree record for other workspace APIs. return { - id: FLOATING_TERMINAL_WORKTREE_ID, - path: homedir(), - connectionId: null, - repo: null, - folderWorkspace: null + scope: { + id: FLOATING_TERMINAL_WORKTREE_ID, + path: homedir(), + connectionId: null, + repo: null, + folderWorkspace: null + }, + managedWorktree: null } } const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) if (folderScope) { - return folderScope + return { + scope: folderScope, + managedWorktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace) + } } const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector @@ -28735,11 +28761,14 @@ export class OrcaRuntimeService { const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) ?? null return { - id: worktree.id, - path: worktree.path, - connectionId: repo?.connectionId ?? null, - repo, - folderWorkspace: null + scope: { + id: worktree.id, + path: worktree.path, + connectionId: repo?.connectionId ?? null, + repo, + folderWorkspace: null + }, + managedWorktree: worktree } } @@ -28814,8 +28843,16 @@ export class OrcaRuntimeService { runtimePathsEqual(worktree.path, selector.slice(5)) ) if (candidates.length > 1) { - // Why: the same physical path can appear under multiple repo IDs; a path selector is exact, so take the first row over a dup-registration ambiguity. - candidates = [candidates[0]] + const hostIds = new Set( + candidates.map((worktree) => { + const repo = this.store?.getRepo(worktree.repoId) + return getWorktreeExecutionHostId(worktree, repo) + }) + ) + // Why: duplicate registrations on one host describe one path; identical paths on different hosts do not. + if (hostIds.size === 1) { + candidates = [candidates[0]] + } } } else if (selector.startsWith('branch:')) { const branchSelector = selector.slice(7) diff --git a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts new file mode 100644 index 00000000000..4ab96e249c7 --- /dev/null +++ b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts @@ -0,0 +1,305 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const scanLocalRepoWorktreesForResolution = vi.hoisted(() => vi.fn()) +vi.mock('./repo-worktree-resolution-scan', () => ({ scanLocalRepoWorktreesForResolution })) + +const getSshGitProvider = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider, + getSshGitProviderGeneration: vi.fn(() => 0), + requireSshGitProvider: (connectionId: string) => getSshGitProvider(connectionId) +})) + +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import type { FolderWorkspace, ProjectGroup, Repo, WorktreeMeta } from '../../shared/types' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { OrcaRuntimeService } from './orca-runtime' + +const REPO_ID = 'repo-1' +const REPO_PATH = '/repo' +const WORKTREE_PATH = '/repo/feature' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` + +function makeMeta(displayName: string, hostId?: WorktreeMeta['hostId']): WorktreeMeta { + return { + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...(hostId ? { hostId } : {}) + } +} + +function makeStore( + options: { + repos?: Repo[] + meta?: Record + folderWorkspaces?: FolderWorkspace[] + projectGroups?: ProjectGroup[] + } = {} +) { + const repos = options.repos ?? [ + { + id: REPO_ID, + path: REPO_PATH, + displayName: 'App', + badgeColor: 'blue', + addedAt: 1 + } + ] + const meta = options.meta ?? { [WORKTREE_ID]: makeMeta('Feature') } + const store = { + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getRepos: () => repos, + getAllWorktreeMeta: () => meta, + getWorktreeMeta: (id: string) => meta[id], + setWorktreeMeta: (id: string, patch: Partial) => { + meta[id] = { ...(meta[id] ?? makeMeta('')), ...patch } + return meta[id] + }, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [], + getFolderWorkspaces: () => options.folderWorkspaces ?? [], + getProjectGroups: () => options.projectGroups ?? [] + } + return store +} + +describe('orchestration worker workspace resolution', () => { + const tempPaths: string[] = [] + + beforeEach(() => { + scanLocalRepoWorktreesForResolution.mockReset().mockResolvedValue({ + ok: true, + worktrees: [ + { + path: WORKTREE_PATH, + head: 'abc', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ] + }) + getSshGitProvider.mockReset() + }) + + afterEach(async () => { + await Promise.all(tempPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))) + }) + + it.each([ + ['full id', `id:${WORKTREE_ID}`], + ['path', `path:${WORKTREE_PATH}`], + ['name', 'name:Feature'] + ])('resolves a local worktree by %s with one catalog scan', async (_label, selector) => { + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect(runtime.showManagedTerminalWorkspace(selector)).resolves.toMatchObject({ + id: WORKTREE_ID, + path: WORKTREE_PATH + }) + expect(scanLocalRepoWorktreesForResolution).toHaveBeenCalledOnce() + }) + + it('preserves a disconnected SSH worktree with unknown legacy ownership', async () => { + const remoteRepo = { + id: REPO_ID, + path: REPO_PATH, + displayName: 'Remote app', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } satisfies Repo + const runtime = new OrcaRuntimeService( + makeStore({ + repos: [remoteRepo], + meta: { [WORKTREE_ID]: makeMeta('Remote feature') } + }) as never + ) + + await expect(runtime.showManagedTerminalWorkspace(`id:${WORKTREE_ID}`)).resolves.toMatchObject({ + id: WORKTREE_ID, + hostId: 'ssh:ssh-1' + }) + }) + + it('does not fall back from the floating terminal sentinel to another workspace', async () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedTerminalWorkspace(`id:${FLOATING_TERMINAL_WORKTREE_ID}`) + ).rejects.toThrow('selector_not_found') + expect(scanLocalRepoWorktreesForResolution).not.toHaveBeenCalled() + }) + + it('rejects an ambiguous worktree name', async () => { + const secondPath = '/repo/other' + scanLocalRepoWorktreesForResolution.mockResolvedValue({ + ok: true, + worktrees: [ + { path: WORKTREE_PATH, head: 'a', branch: 'one', isBare: false, isMainWorktree: false }, + { path: secondPath, head: 'b', branch: 'two', isBare: false, isMainWorktree: false } + ] + }) + const runtime = new OrcaRuntimeService( + makeStore({ + meta: { + [WORKTREE_ID]: makeMeta('Duplicate'), + [`${REPO_ID}::${secondPath}`]: makeMeta('Duplicate') + } + }) as never + ) + + await expect(runtime.showManagedTerminalWorkspace('name:Duplicate')).rejects.toThrow( + 'selector_ambiguous' + ) + }) + + it('rejects the same worktree path on different execution hosts', async () => { + const remoteRepo = { + id: 'repo-remote', + path: '/remote-repo', + displayName: 'Remote app', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } satisfies Repo + getSshGitProvider.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: WORKTREE_PATH, + head: 'remote', + branch: 'remote-feature', + isBare: false, + isMainWorktree: false + } + ]) + }) + const runtime = new OrcaRuntimeService( + makeStore({ + repos: [makeStore().getRepos()[0], remoteRepo], + meta: { + [WORKTREE_ID]: makeMeta('Local feature'), + [`${remoteRepo.id}::${WORKTREE_PATH}`]: makeMeta('Remote feature') + } + }) as never + ) + + await expect(runtime.showManagedTerminalWorkspace(`path:${WORKTREE_PATH}`)).rejects.toThrow( + 'selector_ambiguous' + ) + }) + + it('resolves local and SSH folder workspaces without a Git catalog scan', async () => { + const localPath = await mkdtemp(join(tmpdir(), 'orca-worker-local-folder-')) + tempPaths.push(localPath) + const group = { id: 'group-1', name: 'Group', parentPath: localPath } as ProjectGroup + const localFolder = { + id: 'local-folder', + projectGroupId: group.id, + name: 'Local folder', + folderPath: localPath + } as FolderWorkspace + const remoteFolder = { + ...localFolder, + id: 'remote-folder', + name: 'Remote folder', + folderPath: '/srv/app', + connectionId: 'ssh-folder' + } + registerSshFilesystemProvider('ssh-folder', { + stat: vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 1 }) + } as never) + try { + const runtime = new OrcaRuntimeService( + makeStore({ + folderWorkspaces: [localFolder, remoteFolder], + projectGroups: [group] + }) as never + ) + + await expect( + runtime.showManagedTerminalWorkspace('folder:local-folder') + ).resolves.toMatchObject({ + id: 'folder:local-folder', + path: localPath, + hostId: 'local' + }) + await expect( + runtime.showManagedTerminalWorkspace('id:folder:remote-folder') + ).resolves.toMatchObject({ id: 'folder:remote-folder', hostId: 'ssh:ssh-folder' }) + } finally { + unregisterSshFilesystemProvider('ssh-folder') + } + expect(scanLocalRepoWorktreesForResolution).not.toHaveBeenCalled() + }) + + it('rejects a folder workspace whose execution host is ambiguous', async () => { + const folderPath = '/workspace' + const group = { id: 'group-1', name: 'Group', parentPath: folderPath } as ProjectGroup + const folder = { + id: 'folder-1', + projectGroupId: group.id, + name: 'Ambiguous folder', + folderPath + } as FolderWorkspace + const repos = [ + { id: 'local', path: '/workspace/local', projectGroupId: group.id }, + { id: 'remote', path: '/workspace/remote', projectGroupId: group.id, connectionId: 'ssh-1' } + ].map((repo) => ({ + displayName: repo.id, + badgeColor: 'blue', + addedAt: 1, + ...repo + })) as Repo[] + const runtime = new OrcaRuntimeService( + makeStore({ repos, folderWorkspaces: [folder], projectGroups: [group] }) as never + ) + + await expect(runtime.showManagedTerminalWorkspace('folder:folder-1')).rejects.toThrow( + 'folder_workspace_connection_ambiguous' + ) + expect(scanLocalRepoWorktreesForResolution).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts index 3d65496c7de..5009f6ba027 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts @@ -15,17 +15,17 @@ describe('federated worker agent launch', () => { vi.restoreAllMocks() }) - it('creates the remote worker terminal from the agent id, never as a command', async () => { + it('creates an exact folder worker terminal from the agent id, never as a command', async () => { db = new OrchestrationDb(':memory:') const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) - vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ - id: 'repo::remote-worktree' + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'folder:remote-workspace' } as never) const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ handle: 'term_remote_worker', - worktreeId: 'repo::remote-worktree', + worktreeId: 'folder:remote-workspace', title: 'worker' }) vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ @@ -60,7 +60,7 @@ describe('federated worker agent launch', () => { taskId: 'task_remote', taskSpec: 'remote cursor worker', protocolVersion: 3, - worktree: 'id:repo::remote-worktree', + worktree: 'folder:remote-workspace', agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' @@ -91,14 +91,14 @@ describe('federated worker agent launch', () => { } }) expect(createTerminal).toHaveBeenCalledWith( - 'id:repo::remote-worktree', + 'id:folder:remote-workspace', expect.objectContaining({ startupAgent: 'cursor', launchPreferences: { model: 'gpt-5.3-codex', effort: 'high' } }) ) expect(createTerminal).toHaveBeenCalledWith( - 'id:repo::remote-worktree', + 'id:folder:remote-workspace', expect.not.objectContaining({ command: expect.anything() }) ) }) diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index ffde742ecee..f1c68310931 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -129,7 +129,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ ) appendFederationSetupEffect(effects, setup) } else { - worktree = await runtime.showManagedWorktree(params.worktree).catch(() => { + worktree = await runtime.showManagedTerminalWorkspace(params.worktree).catch(() => { throw new OrchestrationError( 'worktree_not_found_on_server', `Worktree ${params.worktree} was not found on the selected worker server.` diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts index 175cac9edac..d090e15103a 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts @@ -48,7 +48,7 @@ describe('orchestration worker release recovery', () => { vi.spyOn(runtime, 'showTerminal').mockImplementation( async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never ) - vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ id: 'repo::worktree' } as never) vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts index d2c17db9ba0..b5ba5b0000a 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release.test.ts @@ -61,7 +61,7 @@ describe('orchestration worker release', () => { vi.spyOn(runtime, 'showTerminal').mockImplementation( async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never ) - vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ id: 'repo::worktree' } as never) vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 229253ec618..a71e3299929 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -60,21 +60,21 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) const coordinatorTerminal = await runtime.showTerminal(params.from) - const coordinatorWorktree = await runtime.showManagedWorktree( - `id:${coordinatorTerminal.worktreeId}` - ) - if (createsWorktree) { + const creationWorktree = createsWorktree + ? await runtime.showManagedWorktree(`id:${coordinatorTerminal.worktreeId}`) + : undefined + if (creationWorktree) { await assertOrchestrationWorktreeCreationSupported({ runtime, - repoSelector: params.repo ?? coordinatorWorktree.repoId, + repoSelector: params.repo ?? creationWorktree.repoId, existingPlacement: 'current or an exact existing folder workspace' }) } - let resolvedWorktree = createsWorktree + let resolvedWorktree = creationWorktree ? undefined : requestedWorktree === 'current' - ? coordinatorWorktree - : await runtime.showManagedWorktree(requestedWorktree) + ? await runtime.showManagedTerminalWorkspace(`id:${coordinatorTerminal.worktreeId}`) + : await runtime.showManagedTerminalWorkspace(requestedWorktree) let explicitTerminal if (params.terminal) { explicitTerminal = await runtime.showTerminal(params.terminal) @@ -96,7 +96,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ worktree: requestedWorktree, resolvedWorktreeId: resolvedWorktree?.id ?? null, name: params.name ?? null, - repo: params.repo ?? (createsWorktree ? coordinatorWorktree.repoId : null), + repo: params.repo ?? creationWorktree?.repoId ?? null, baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: agent ?? null, @@ -135,14 +135,14 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ state: 'not_applicable' } try { - if (createsWorktree) { + if (creationWorktree) { failedStage = 'worktree_create' const created = await createWorkerWorktree({ runtime, db, dispatchId: started.dispatch.id, requestedWorktree, - coordinatorWorktree, + coordinatorWorktree: creationWorktree, params, agent: agent as TuiAgent, launchPreferences: launch.preferences, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index f95b20a8b05..12487c253c1 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -9,6 +9,7 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' import { ORCHESTRATION_ASK_MAX_TIMEOUT_MS } from '../../../../shared/orchestration-ask-timeout' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages belong to one exact Dispatch and cannot target a group address.` @@ -2180,6 +2181,9 @@ describe('orchestration RPC methods', () => { vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ id: 'repo::worktree' } as never) + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + } as never) vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ handle: 'term_worker', worktreeId: 'repo::worktree', @@ -2381,17 +2385,25 @@ describe('orchestration RPC methods', () => { expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalled() }) - it('starts a fresh agent in an exact existing worktree without replaying setup', async () => { + it('starts in an exact existing worktree from a floating coordinator', async () => { setup() mockCurrentWorkerStart() const createWorktree = vi.spyOn(runtime, 'createManagedWorktree') - vi.mocked(runtime.showManagedWorktree).mockImplementation( - async (selector) => - ({ - id: selector === 'id:repo::other' ? 'repo::other' : 'repo::worktree', - repoId: 'repo' - }) as never - ) + vi.mocked(runtime.showTerminal).mockResolvedValue({ + handle: 'term_coord', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + status: 'running' + } as never) + vi.mocked(runtime.showManagedWorktree).mockImplementation(async (selector) => { + if (selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}`) { + throw new Error('selector_not_found') + } + return { id: 'repo::other', repoId: 'repo' } as never + }) + vi.mocked(runtime.showManagedTerminalWorkspace).mockResolvedValue({ + id: 'repo::other', + repoId: 'repo' + } as never) const task = db.createTask({ spec: 'existing worktree worker' }) const result = (await call('orchestration.workerStart', { @@ -2415,6 +2427,41 @@ describe('orchestration RPC methods', () => { expect.objectContaining({ startupAgent: 'codex', surfaceOwner: false }) ) expect(createWorktree).not.toHaveBeenCalled() + expect(runtime.showTerminal).toHaveBeenCalledWith('term_coord') + expect(runtime.showManagedWorktree).not.toHaveBeenCalledWith( + `id:${FLOATING_TERMINAL_WORKTREE_ID}` + ) + expect(runtime.showManagedTerminalWorkspace).toHaveBeenCalledOnce() + expect(runtime.showManagedTerminalWorkspace).toHaveBeenCalledWith('id:repo::other') + }) + + it('starts in an exact existing folder workspace from a floating coordinator', async () => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.showTerminal).mockResolvedValue({ + handle: 'term_coord', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + status: 'running' + } as never) + vi.mocked(runtime.showManagedWorktree).mockRejectedValue(new Error('selector_not_found')) + vi.mocked(runtime.showManagedTerminalWorkspace).mockResolvedValue({ + id: 'folder:workspace-1', + repoId: 'folder-workspace:group-1' + } as never) + const task = db.createTask({ spec: 'folder workspace worker' }) + + await expect( + call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + worktree: 'folder:workspace-1', + agent: 'codex' + }) + ).resolves.toMatchObject({ state: 'ready' }) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:folder:workspace-1', + expect.objectContaining({ startupAgent: 'codex', surfaceOwner: false }) + ) }) it('reuses only an explicitly selected existing agent terminal', async () => { diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts index 03292dfb0e6..ac201ab9b32 100644 --- a/tests/e2e/completed-worker-retirement-resume.unit.test.ts +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -269,7 +269,7 @@ async function releaseCompletedWorker(terminalState: 'running' | 'exited'): Prom : null ) vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) - vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ id: WORKTREE_ID } as never) + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ id: WORKTREE_ID } as never) vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ handle: TERMINAL_HANDLE, worktreeId: WORKTREE_ID,