From 8ba3cf46ab093eb5ef95bb476b3c3c92e32b8b40 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 20:59:59 -0700 Subject: [PATCH] fix(runtime): worktree create enforces the lineage host boundary (#27114) Update and hydrate refuse a parent worktree on another execution host and the renderer drops such an edge, but create recorded it anyway. Create now checks the new worktree's host before anything is created: an explicit parent on another host fails with LINEAGE_PARENT_CONTEXT_CONFLICT, and an inferred one is dropped with a warning. An unknown host on either side stays permissive, as in the projection. Co-authored-by: m4air --- .../orca-runtime-create-managed-worktree.ts | 2 +- .../orca-runtime-resolve-worktree-selector.ts | 9 +++-- .../worktree-selector-resolution.spec.ts | 34 ++++++++++++++++++ .../runtime-worktree-lineage-controller.ts | 10 ++++-- .../runtime-worktree-lineage-resolution.ts | 36 +++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 6490294512c..69931da960b 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -118,7 +118,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork } const lineageInput = args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined - const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) + const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput, repo) if (createRoute.kind === 'runtime') { throw new ExecutionHostNotDispatchableError(createRoute.hostId) } diff --git a/src/main/runtime/orca-runtime-resolve-worktree-selector.ts b/src/main/runtime/orca-runtime-resolve-worktree-selector.ts index f8c547446ad..a9fcd799e5d 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-selector.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-selector.ts @@ -105,10 +105,15 @@ export class OrcaRuntimeWithResolveWorktreeSelector extends OrcaRuntimeWithResol throw new Error('selector_not_found') } + /** `childRepo` scopes the parent to the new worktree's execution host (#12757). */ protected resolveLineageForWorktreeCreate( - input?: WorktreeLineageInput + input?: WorktreeLineageInput, + childRepo?: Repo ): Promise { - return this.worktreeLineage.resolveCreate(input) + return this.worktreeLineage.resolveCreate( + input, + childRepo ? getRepoExecutionHostId(childRepo) : undefined + ) } protected getOrchestrationDbIfAvailable(): OrchestrationDb | null { diff --git a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts index 1aa6ce2e8a4..5fedd5f83f1 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts @@ -36,6 +36,15 @@ import { store } from '../orca-runtime-test-fixtures.spec' +const LOCAL_CHILD_REPO = { + id: 'child-repo', + path: '/child', + displayName: 'child', + badgeColor: '#000', + addedAt: 0 +} +const SSH_CHILD_REPO = { ...LOCAL_CHILD_REPO, connectionId: 'other-host' } + describe('OrcaRuntimeService', () => { it('resolves branch selectors when worktrees store refs/heads-prefixed branches', async () => { vi.mocked(listWorktrees).mockResolvedValueOnce([ @@ -382,6 +391,31 @@ describe('OrcaRuntimeService', () => { ) }) + // #12757: create must refuse the cross-host edge that update refuses and the renderer drops. + it('refuses an explicit parent worktree on another execution host before creating', async () => { + const runtime = new OrcaRuntimeService(store) + const resolveLineage = runtime['resolveLineageForWorktreeCreate'].bind(runtime) + + await expect( + resolveLineage({ parentWorktree: `id:${TEST_WORKTREE_ID}` }, SSH_CHILD_REPO) + ).rejects.toMatchObject({ code: 'LINEAGE_PARENT_CONTEXT_CONFLICT' }) + await expect( + resolveLineage({ parentWorktree: `id:${TEST_WORKTREE_ID}` }, LOCAL_CHILD_REPO) + ).resolves.toMatchObject({ kind: 'lineage' }) + }) + + it('drops an inferred parent on another execution host with a warning', async () => { + const runtime = new OrcaRuntimeService(store) + const resolveLineage = runtime['resolveLineageForWorktreeCreate'].bind(runtime) + + await expect( + resolveLineage({ cwdParentWorktree: `id:${TEST_WORKTREE_ID}` }, SSH_CHILD_REPO) + ).resolves.toEqual({ + kind: 'none', + warnings: [expect.objectContaining({ code: 'LINEAGE_PARENT_CONTEXT_CONFLICT' })] + }) + }) + it('does not reuse stale in-flight worktree scans after creating a worktree', async () => { const addRetiredWorktreeName = vi.fn() const runtime = new OrcaRuntimeService({ diff --git a/src/main/runtime/runtime-worktree-lineage-controller.ts b/src/main/runtime/runtime-worktree-lineage-controller.ts index 116207d001b..93360359fba 100644 --- a/src/main/runtime/runtime-worktree-lineage-controller.ts +++ b/src/main/runtime/runtime-worktree-lineage-controller.ts @@ -1,4 +1,5 @@ import type { WorktreeLineage, WorkspaceLineage } from '../../shared/worktree/lineage-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceKey } from '../../shared/folder-workspace-types' import { folderWorkspaceKey, @@ -11,6 +12,7 @@ import type { RuntimeStore } from './runtime-store-contract' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import { RuntimeLineageError, + enforceCreateLineageHostBoundary, resolveRuntimeWorktreeCreateLineage, type ResolvedWorkspaceParent, type WorktreeLineageCandidate, @@ -30,8 +32,11 @@ type RuntimeWorktreeLineageDependencies = { export class RuntimeWorktreeLineageController { constructor(private readonly deps: RuntimeWorktreeLineageDependencies) {} - async resolveCreate(input?: WorktreeLineageInput): Promise { - return resolveRuntimeWorktreeCreateLineage(input, { + async resolveCreate( + input?: WorktreeLineageInput, + childHostId?: ExecutionHostId + ): Promise { + const resolution = await resolveRuntimeWorktreeCreateLineage(input, { resolveParent: (selector) => this.resolveParent(selector), resolveWorktreeParent: (selector) => this.resolveWorktreeParent(selector), resolveTaskCandidate: (taskId) => this.resolveTaskCandidate(taskId), @@ -48,6 +53,7 @@ export class RuntimeWorktreeLineageController { } } }) + return enforceCreateLineageHostBoundary(resolution, childHostId) } async resolveParent(selector: string): Promise { diff --git a/src/main/runtime/runtime-worktree-lineage-resolution.ts b/src/main/runtime/runtime-worktree-lineage-resolution.ts index 1a119dfe600..27c7fa089ad 100644 --- a/src/main/runtime/runtime-worktree-lineage-resolution.ts +++ b/src/main/runtime/runtime-worktree-lineage-resolution.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../shared/execution-host' import type { FolderWorkspace, WorkspaceKey } from '../../shared/folder-workspace-types' import type { WorktreeLineage, WorktreeLineageWarning } from '../../shared/worktree/lineage-types' import type { ResolvedWorktree } from './runtime-worktree-path-identity' @@ -267,3 +268,38 @@ export async function resolveRuntimeWorktreeCreateLineage( : {}) } } + +/** + * Create-time half of the lineage host boundary (#12757): update and hydrate refuse a parent on + * another execution host, and the renderer drops such an edge, so create must not persist one. + * An explicit parent fails before anything is created; an inferred one is dropped with a warning. + * An unknown host on either side stays permissive, as in the projection. + */ +export function enforceCreateLineageHostBoundary( + resolution: WorktreeLineageResolution, + childHostId: ExecutionHostId | undefined +): WorktreeLineageResolution { + if (resolution.kind !== 'lineage' || resolution.parent.type !== 'worktree') { + return resolution + } + const parentHostId = resolution.parent.worktree.hostId + if (!childHostId || !parentHostId || parentHostId === childHostId) { + return resolution + } + const message = 'Parent worktree must be on the same execution host as the new worktree.' + if (resolution.capture.confidence === 'explicit') { + throw new RuntimeLineageError('LINEAGE_PARENT_CONTEXT_CONFLICT', message, { + nextSteps: ['Pass a parent on the same execution host, or retry with --no-parent.'] + }) + } + return { + kind: 'none', + warnings: [ + { + code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', + message: `Worktree created without lineage. ${message}`, + details: { parentWorktreeId: resolution.parent.worktree.id, parentHostId, childHostId } + } + ] + } +}