mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user