diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 7793cb160f2..27553ed97aa 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1450,109 +1450,6 @@ describe('orchestration RPC methods', () => { expect(db.getTask(result.task.id)?.coordinator_run_id).toBeNull() expect(db.getTask(result.task.id)?.target_key).toBeNull() }) - - it('stamps target_key from --target-worktree (#9 recipe director path)', async () => { - setup() - // The renderer recipe director has the director worktree id, not a terminal - // handle, so it passes a worktree selector. It resolves through the SAME - // resolveOrchestrationTargetKey the run uses, so the keys match for adoption. - const targetSpy = vi - .spyOn(runtime, 'resolveOrchestrationTargetKey') - .mockResolvedValue('worktree:director1') - const terminalSpy = vi.spyOn(runtime, 'resolveOrchestrationTargetKeyForTerminal') - - const result = (await call('orchestration.taskCreate', { - spec: 'implement', - targetWorktree: 'id:director1' - })) as { task: { id: string } } - - expect(targetSpy).toHaveBeenCalledWith('id:director1') - // --target-worktree wins: the terminal resolver is not consulted. - expect(terminalSpy).not.toHaveBeenCalled() - expect(db.getTask(result.task.id)?.target_key).toBe('worktree:director1') - }) - - it('refuses a task whose --target-worktree does not resolve (fail closed)', async () => { - setup() - vi.spyOn(runtime, 'resolveOrchestrationTargetKey').mockRejectedValue( - new Error('selector_not_found') - ) - - await expect( - call('orchestration.taskCreate', { spec: 'x', targetWorktree: 'id:gone' }) - ).rejects.toThrow(/selector_not_found/) - }) - }) - - describe('orchestration.taskCreate ↔ run target_key adoption (#9 end-to-end)', () => { - // Why (#9, F1 clash seam): the task path (taskCreate --target-worktree) and the - // run path (orchestration.run --worktree) must resolve the SAME target_key, or - // run-start adoption silently fails to claim the task and the recipe never - // dispatches. These tests prove the binding through the REAL resolver and the - // REAL db.adoptUnownedTasks — not a mocked resolver returning a matched literal. - // Only the lowest-level filesystem worktree lookup is stubbed (id:X → worktree - // X); resolveOrchestrationTargetKey and adoptUnownedTasks run their real code. - function stubWorktreeLookup(): void { - ;( - runtime as unknown as { - resolveWorktreeSelector: (selector: string) => Promise<{ id: string }> - } - ).resolveWorktreeSelector = async (selector) => ({ id: selector.replace(/^id:/, '') }) - } - - it('adopts a task stamped via --target-worktree into a run on the same worktree', async () => { - setup() - stubWorktreeLookup() - - // Task path: the renderer recipe director stamps the task by worktree id. - const { task } = (await call('orchestration.taskCreate', { - spec: 'implement', - targetWorktree: 'id:W' - })) as { task: { id: string; target_key: string | null } } - - // Run path: resolve target_key the EXACT way orchestration.run does, start - // the run, then adopt the EXACT way the coordinator's executeLoop does. - const runTargetKey = await runtime.resolveOrchestrationTargetKey('id:W') - const run = db.startCoordinatorRun({ - spec: 'recipe:implement_then_review', - coordinatorHandle: 'coordinator-e2e', - targetKey: runTargetKey, - worktreeBacked: true, - workerAgent: 'claude' - }) - const bound = db.adoptUnownedTasks(run.id, db.getCoordinatorRun(run.id)?.target_key ?? null) - - // Both paths resolved worktree:W → adoption binds the task to the run. - expect(task.target_key).toBe('worktree:W') - expect(bound).toBe(1) - expect(db.getTask(task.id)?.coordinator_run_id).toBe(run.id) - }) - - it('does NOT adopt a task stamped to a different worktree (fails if keys ever diverge)', async () => { - setup() - stubWorktreeLookup() - - // Stamped to a DIFFERENT worktree than the run targets. This is the negative - // control: it makes the positive test meaningful — if taskCreate and run ever - // resolved the same key for different worktree inputs (the divergence we - // guard against), this task would wrongly bind and this assertion would fail. - const { task } = (await call('orchestration.taskCreate', { - spec: 'implement', - targetWorktree: 'id:OTHER' - })) as { task: { id: string } } - - const runTargetKey = await runtime.resolveOrchestrationTargetKey('id:W') - const run = db.startCoordinatorRun({ - spec: 'recipe', - coordinatorHandle: 'coordinator-e2e-2', - targetKey: runTargetKey, - worktreeBacked: true, - workerAgent: 'claude' - }) - db.adoptUnownedTasks(run.id, db.getCoordinatorRun(run.id)?.target_key ?? null) - - expect(db.getTask(task.id)?.coordinator_run_id).toBeNull() - }) }) describe('orchestration.reset', () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 5dc15a1cedc..29ce0520944 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -128,12 +128,7 @@ const TaskCreateParams = z.object({ displayName: OptionalString, deps: OptionalString, parent: OptionalString, - callerTerminalHandle: OptionalString, - // Why (#9): the recipe director runs in the renderer and holds the director - // worktree id, not a live terminal handle. A worktree selector lets it stamp - // the task's target directly — symmetric with orchestration.run's `worktree`, - // and using the SAME resolver so the keys match and run-start adoption binds. - targetWorktree: OptionalString + callerTerminalHandle: OptionalString }) const TaskListParams = z.object({ @@ -392,13 +387,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ // Why (#12): stamp the task with its OWN target (the creating terminal's // worktree) so adoption only binds it to a same-target run — never poached // by a concurrent run on another target. - // Why (#9): an explicit --target-worktree wins (the renderer recipe director - // has a worktree id, not a terminal handle) and resolves through the same - // resolveOrchestrationTargetKey that orchestration.run uses, so the task's - // key matches the run's and run-start adoptUnownedTasks claims it. - const targetKey = params.targetWorktree - ? await runtime.resolveOrchestrationTargetKey(params.targetWorktree) - : await runtime.resolveOrchestrationTargetKeyForTerminal(params.callerTerminalHandle) + const targetKey = await runtime.resolveOrchestrationTargetKeyForTerminal( + params.callerTerminalHandle + ) // Why (#12): a task created while a run is active belongs to that run so // the coordinator's run-scoped listTasks sees it — but only the run on the // SAME target (getActiveCoordinatorRunForTarget, not the global latest). diff --git a/src/renderer/src/components/OrchestratorLaunchModal.tsx b/src/renderer/src/components/OrchestratorLaunchModal.tsx index ed1695eaef0..e7a74900bf4 100644 --- a/src/renderer/src/components/OrchestratorLaunchModal.tsx +++ b/src/renderer/src/components/OrchestratorLaunchModal.tsx @@ -17,13 +17,7 @@ import { buildNewWorkspaceCreateTargetOptions } from '@/lib/new-workspace-projec import { getComposerEligibleRepos } from '@/lib/new-workspace-composer-repo' import { getAgentCatalog } from '@/lib/agent-catalog' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' -import { DirectorTypePicker } from '@/components/director/DirectorTypePicker' -import { - LlmDirectorBackend, - RecipeDirectorBackend, - type DirectorKind -} from '@/lib/director-backend' -import { getRecipes } from '@/lib/recipe-director-recipes' +import { launchOrchestratorForProject } from '@/lib/orchestrator-launch' import { translate } from '@/i18n/i18n' import type { TuiAgent } from '../../../shared/types' @@ -55,11 +49,6 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null { const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) - // Why: #11 owns the gate — the Recipe director option only appears under the - // experimental flag; with it off the modal behaves exactly as it did before. - const experimentalOrchestrators = useAppStore( - (s) => s.settings?.experimentalOrchestrators ?? false - ) const nameId = useId() const promptId = useId() @@ -101,13 +90,10 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null { : null, [projectOptions, prefillProjectId] ) - const recipes = useMemo(() => getRecipes(), []) const [selectedOptionId, setSelectedOptionId] = useState(null) const [name, setName] = useState('') const [agent, setAgent] = useState(null) const [prompt, setPrompt] = useState('') - const [directorKind, setDirectorKind] = useState('llm') - const [recipeName, setRecipeName] = useState(null) useEffect(() => { if (visible) { @@ -115,8 +101,6 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null { setAgent(defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null) setName(prefillName) setPrompt(prefillPrompt) - setDirectorKind('llm') - setRecipeName(recipes[0]?.name ?? null) } }, [ visible, @@ -124,8 +108,7 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null { prefilledProjectOptionId, defaultTuiAgent, prefillName, - prefillPrompt, - recipes + prefillPrompt ]) if (!visible) { @@ -138,28 +121,15 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null { ? (projects.find((p) => p.id === selectedOption.projectId) ?? null) : null - // The Recipe director is gated; if the flag is off, only the Smart path is live. - const isRecipe = directorKind === 'recipe' && experimentalOrchestrators - const handleLaunch = (): void => { if (!project) { return } - // Dispatch through the DirectorBackend abstraction (#8) instead of branching - // on the kind at the call site. - if (isRecipe) { - const recipe = recipes.find((entry) => entry.name === recipeName) - if (!recipe) { - return - } - void new RecipeDirectorBackend(recipe).launch(project, { name: name.trim() || undefined }) - } else { - void new LlmDirectorBackend().launch(project, { - name: name.trim() || undefined, - agent: agent ?? undefined, - prompt: prompt.trim() || undefined - }) - } + void launchOrchestratorForProject(project, { + name: name.trim() || undefined, + agent: agent ?? undefined, + prompt: prompt.trim() || undefined + }) closeModal() } @@ -214,16 +184,6 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null { )} /> - {experimentalOrchestrators && ( - - )}
- {/* Why: agent + task are the Smart director's coordinator-LLM inputs. The - Recipe director runs a fixed, token-free workflow with no director LLM - to seed, so these are hidden in recipe mode. */} - {!isRecipe && ( - <> -
- - -
-
- -