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 <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 20:59:59 -07:00
committed by GitHub
co-authored by m4air
parent b23623681b
commit 8ba3cf46ab
5 changed files with 86 additions and 5 deletions
@@ -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)
}
@@ -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<WorktreeLineageResolution> {
return this.worktreeLineage.resolveCreate(input)
return this.worktreeLineage.resolveCreate(
input,
childRepo ? getRepoExecutionHostId(childRepo) : undefined
)
}
protected getOrchestrationDbIfAvailable(): OrchestrationDb | null {
@@ -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({
@@ -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<WorktreeLineageResolution> {
return resolveRuntimeWorktreeCreateLineage(input, {
async resolveCreate(
input?: WorktreeLineageInput,
childHostId?: ExecutionHostId
): Promise<WorktreeLineageResolution> {
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<ResolvedWorkspaceParent> {
@@ -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 }
}
]
}
}