diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 27553ed97aa..7793cb160f2 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1450,6 +1450,109 @@ 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 29ce0520944..5dc15a1cedc 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -128,7 +128,12 @@ const TaskCreateParams = z.object({ displayName: OptionalString, deps: OptionalString, parent: OptionalString, - callerTerminalHandle: 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 }) const TaskListParams = z.object({ @@ -387,9 +392,13 @@ 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. - const targetKey = await runtime.resolveOrchestrationTargetKeyForTerminal( - params.callerTerminalHandle - ) + // 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) // 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/lib/director-worktree-shell.test.ts b/src/renderer/src/lib/director-worktree-shell.test.ts new file mode 100644 index 00000000000..0347f8ac0d2 --- /dev/null +++ b/src/renderer/src/lib/director-worktree-shell.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project } from '../../../shared/types' + +const harness = vi.hoisted(() => ({ + createWorktree: vi.fn(), + repos: [] as { id: string; worktreeBaseRef?: string }[], + toastError: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(harness), { + getState: () => ({ createWorktree: harness.createWorktree, repos: harness.repos }) + }) +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => harness.toastError(...args) } })) + +import { createDirectorWorktreeShell } from './director-worktree-shell' + +// createWorktree's positional signature: the createdWithAgent flag — the arg that +// makes activation relaunch an agent — sits well after displayName. +const CREATED_WITH_AGENT_ARG_INDEX = 10 +const SETUP_DECISION_ARG_INDEX = 3 + +const PROJECT: Project = { + id: 'proj_1', + displayName: 'Demo', + sourceRepoIds: ['repo_1'] +} as unknown as Project + +beforeEach(() => { + vi.clearAllMocks() + harness.repos = [{ id: 'repo_1', worktreeBaseRef: 'main' }] + harness.createWorktree.mockResolvedValue({ worktree: { id: 'wt_director' }, setup: undefined }) +}) + +describe('createDirectorWorktreeShell', () => { + it('creates the shell token-free: no agent is seeded into the director pane', async () => { + const shell = await createDirectorWorktreeShell(PROJECT, { label: 'My Recipe' }) + + expect(shell).toEqual({ worktreeId: 'wt_director', setup: undefined }) + expect(harness.createWorktree).toHaveBeenCalledTimes(1) + + const args = harness.createWorktree.mock.calls[0] + // 'skip' setup (a director coordinates, it doesn't build)... + expect(args[SETUP_DECISION_ARG_INDEX]).toBe('skip') + // ...and CRUCIALLY no createdWithAgent — otherwise activation would relaunch an + // LLM in the director pane, breaking the token-free invariant. + expect(args[CREATED_WITH_AGENT_ARG_INDEX]).toBeUndefined() + }) + + it('returns null and toasts when the project has no repo', async () => { + const shell = await createDirectorWorktreeShell( + { ...PROJECT, sourceRepoIds: [] } as unknown as Project, + { label: 'x' } + ) + expect(shell).toBeNull() + expect(harness.createWorktree).not.toHaveBeenCalled() + expect(harness.toastError).toHaveBeenCalledTimes(1) + }) + + it('returns null and toasts when worktree creation fails', async () => { + harness.createWorktree.mockRejectedValue(new Error('boom')) + const shell = await createDirectorWorktreeShell(PROJECT, { label: 'x' }) + expect(shell).toBeNull() + expect(harness.toastError).toHaveBeenCalledWith('boom') + }) +}) diff --git a/src/renderer/src/lib/director-worktree-shell.ts b/src/renderer/src/lib/director-worktree-shell.ts new file mode 100644 index 00000000000..b0bf43f3282 --- /dev/null +++ b/src/renderer/src/lib/director-worktree-shell.ts @@ -0,0 +1,71 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { ORCASTRATOR_DISPLAY_PREFIX } from '@/store/slices/orchestrators' +import { translate } from '@/i18n/i18n' +import type { Project } from '../../../shared/types' + +// Why: the director's own dedicated worktree — hidden from Projects, shown only in +// the ORCASTRATORS section (the display prefix), so a director never couples to the +// project's primary checkout. This is the shell BOTH director kinds share: the LLM +// Orcastrator seeds /orcastrate + an agent into it, while the token-free recipe +// director (#9) leaves it agent-free and uses it purely as the lineage anchor + +// `.orcastrate` log home + the coordinator's operating worktree. + +export type DirectorWorktreeShell = { + worktreeId: string + setup: Awaited['createWorktree']>>['setup'] +} + +/** + * Create the hidden director worktree shell for a project. This creates ONLY the + * worktree — it does NOT start an agent or seed any prompt, so it is token-free by + * construction; callers layer agent startup on top when they want an LLM director. + * Surfaces failures via toast and returns null (no repo / create failed). + */ +export async function createDirectorWorktreeShell( + project: Project, + options: { label: string } +): Promise { + const repoId = project.sourceRepoIds[0] + if (!repoId) { + toast.error( + translate( + 'auto.lib.orchestrator.launch.no_repo', + 'This project has no repo to launch an Orcastrator in.' + ) + ) + return null + } + + const store = useAppStore.getState() + const repo = store.repos.find((entry) => entry.id === repoId) + try { + // Why: 'skip' setup — a director coordinates, it doesn't build, so it does not + // need the repo's setup scripts run in its checkout. + // Token-free invariant (#9): this call MUST NOT pass `createdWithAgent`. The + // shell is agent-free only because that arg stays undefined — otherwise + // activateAndRevealWorktree's `opts?.startup ?? buildCreatedAgentReopenStartup(wt)` + // fallback would relaunch an LLM in the director pane (a director-pane token + // cost). The arg list intentionally stops at displayName for exactly this reason. + const result = await store.createWorktree( + repoId, + `orcastrator-${options.label}`, + repo?.worktreeBaseRef, + 'skip', + undefined, + undefined, + `${ORCASTRATOR_DISPLAY_PREFIX}${options.label}` + ) + return { worktreeId: result.worktree.id, setup: result.setup } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.lib.orchestrator.launch.create_failed', + 'Failed to create the Orcastrator.' + ) + ) + return null + } +} diff --git a/src/renderer/src/lib/orchestrator-launch.ts b/src/renderer/src/lib/orchestrator-launch.ts index f103ca8c6c5..a92cb213648 100644 --- a/src/renderer/src/lib/orchestrator-launch.ts +++ b/src/renderer/src/lib/orchestrator-launch.ts @@ -9,7 +9,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildDirectWorkItemStartupOpts } from '@/lib/launch-work-item-direct-agent' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' -import { ORCASTRATOR_DISPLAY_PREFIX } from '@/store/slices/orchestrators' +import { createDirectorWorktreeShell } from '@/lib/director-worktree-shell' import { translate } from '@/i18n/i18n' import type { Project, TuiAgent } from '../../../shared/types' @@ -49,52 +49,20 @@ export async function launchOrchestratorForProject( project: Project, options?: LaunchOrchestratorOptions ): Promise { - const repoId = project.sourceRepoIds[0] - if (!repoId) { - toast.error( - translate( - 'auto.lib.orchestrator.launch.no_repo', - 'This project has no repo to launch an Orcastrator in.' - ) - ) - return false - } - const store = useAppStore.getState() - const repo = store.repos.find((entry) => entry.id === repoId) const settings = store.settings const agent = options?.agent ?? resolveCoordinatorAgent(settings?.defaultTuiAgent) const label = options?.name?.trim() || project.displayName const task = options?.prompt?.trim() const promptContent = task ? `${ORCASTRATE_PROMPT} ${task}` : ORCASTRATE_PROMPT - let worktreeId: string - let setup: Awaited>['setup'] - try { - // Why: 'skip' setup — a director coordinates, it doesn't build, so it does - // not need the repo's setup scripts run in its checkout. - const result = await store.createWorktree( - repoId, - `orcastrator-${label}`, - repo?.worktreeBaseRef, - 'skip', - undefined, - undefined, - `${ORCASTRATOR_DISPLAY_PREFIX}${label}` - ) - worktreeId = result.worktree.id - setup = result.setup - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.lib.orchestrator.launch.create_failed', - 'Failed to create the Orcastrator.' - ) - ) + // Why: the worktree shell is identical for both director kinds; the Orcastrator + // is just this shell PLUS the coordinator agent + /orcastrate seeded below. + const shell = await createDirectorWorktreeShell(project, { label }) + if (!shell) { return false } + const { worktreeId, setup } = shell const startupPlan = buildAgentStartupPlan({ agent, diff --git a/src/renderer/src/lib/recipe-director-launch.test.ts b/src/renderer/src/lib/recipe-director-launch.test.ts new file mode 100644 index 00000000000..5c807a18eee --- /dev/null +++ b/src/renderer/src/lib/recipe-director-launch.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project } from '../../../shared/types' + +// A mutable harness the mocked modules read, reset per test. +const harness = vi.hoisted(() => ({ + createWorktree: vi.fn(), + registerOrchestrator: vi.fn(), + settings: { defaultTuiAgent: 'codex' } as { defaultTuiAgent?: string }, + activate: vi.fn(), + taskCreate: vi.fn(), + run: vi.fn(), + runtimeCall: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(harness), { + getState: () => ({ + createWorktree: harness.createWorktree, + registerOrchestrator: harness.registerOrchestrator, + repos: [], + settings: harness.settings + }) + }) +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: (...args: unknown[]) => harness.activate(...args) +})) + +// The shell helper is exercised separately; here we stub it so the launch test +// stays focused on the compile → taskCreate → run wiring and the token-free path. +vi.mock('@/lib/director-worktree-shell', () => ({ + createDirectorWorktreeShell: vi.fn(async () => ({ worktreeId: 'wt_director', setup: undefined })) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: (...args: unknown[]) => harness.toastError(...args) } +})) + +import { launchRecipeDirector } from './recipe-director-launch' +import { IMPLEMENT_THEN_REVIEW } from './recipe-director-recipes' + +const PROJECT: Project = { + id: 'proj_1', + displayName: 'Demo', + sourceRepoIds: ['repo_1'] +} as unknown as Project + +beforeEach(() => { + vi.clearAllMocks() + harness.settings = { defaultTuiAgent: 'codex' } + harness.activate.mockReturnValue({ primaryTabId: 'tab_1' }) + let n = 0 + harness.taskCreate.mockImplementation(async () => ({ task: { id: `task_${++n}` } })) + harness.run.mockResolvedValue({ runId: 'run_1', status: 'running' }) + harness.runtimeCall.mockResolvedValue({ ok: true, result: { handle: 'coordinator_term' } }) + // window.api wiring + ;(globalThis as unknown as { window: unknown }).window = { + api: { + orchestration: { taskCreate: harness.taskCreate, run: harness.run }, + runtime: { call: harness.runtimeCall } + } + } +}) + +describe('launchRecipeDirector', () => { + it('compiles the recipe into ordered taskCreate calls stamped to the shell', async () => { + const ok = await launchRecipeDirector(PROJECT, IMPLEMENT_THEN_REVIEW) + expect(ok).toBe(true) + + expect(harness.taskCreate).toHaveBeenCalledTimes(2) + const [implementCall] = harness.taskCreate.mock.calls[0] + const [reviewCall] = harness.taskCreate.mock.calls[1] + + // implement: first, no deps, stamped to the director worktree. + expect(implementCall.taskTitle).toBe('implement') + expect(implementCall.deps).toBeUndefined() + expect(implementCall.targetWorktree).toBe('id:wt_director') + expect(implementCall.spec).toMatch(/^track: \S+/) + + // review: depends on implement's created id, same target. + expect(reviewCall.taskTitle).toBe('review') + expect(reviewCall.deps).toBe(JSON.stringify(['task_1'])) + expect(reviewCall.targetWorktree).toBe('id:wt_director') + }) + + it('starts a worktree-backed run anchored on the shell with the default worker agent', async () => { + await launchRecipeDirector(PROJECT, IMPLEMENT_THEN_REVIEW) + + expect(harness.run).toHaveBeenCalledTimes(1) + expect(harness.run).toHaveBeenCalledWith( + expect.objectContaining({ + spec: 'recipe:implement_then_review', + worktree: 'id:wt_director', + worktreeBacked: true, + workerAgent: 'codex', + from: 'coordinator_term' + }) + ) + // Tasks are created BEFORE the run starts (so run-start adoption sees them). + expect(harness.taskCreate.mock.invocationCallOrder[1]).toBeLessThan( + harness.run.mock.invocationCallOrder[0] + ) + }) + + it('is token-free: never seeds an agent or prompt into the director shell', async () => { + await launchRecipeDirector(PROJECT, IMPLEMENT_THEN_REVIEW) + + // The shell is activated with NO startup payload — no agent command, no + // /orcastrate paste. The only agent in the whole flow is the run's workerAgent. + expect(harness.activate).toHaveBeenCalledTimes(1) + const [worktreeId, opts] = harness.activate.mock.calls[0] + expect(worktreeId).toBe('wt_director') + expect(opts.startup).toBeUndefined() + expect(opts.issueCommand).toBeUndefined() + }) + + it('falls back to claude when the default agent is a blank shell', async () => { + harness.settings = { defaultTuiAgent: 'blank' } + await launchRecipeDirector(PROJECT, IMPLEMENT_THEN_REVIEW) + expect(harness.run).toHaveBeenCalledWith(expect.objectContaining({ workerAgent: 'claude' })) + }) + + it('still starts the run when the shell terminal handle cannot be resolved', async () => { + harness.runtimeCall.mockResolvedValue({ ok: false, error: { message: 'no_active_terminal' } }) + const ok = await launchRecipeDirector(PROJECT, IMPLEMENT_THEN_REVIEW) + expect(ok).toBe(true) + const [runParams] = harness.run.mock.calls[0] + expect(runParams.from).toBeUndefined() + expect(runParams.worktreeBacked).toBe(true) + }) + + it('aborts (no tasks, no run) when the shell cannot be created', async () => { + const shellModule = await import('@/lib/director-worktree-shell') + vi.mocked(shellModule.createDirectorWorktreeShell).mockResolvedValueOnce(null) + + const ok = await launchRecipeDirector(PROJECT, IMPLEMENT_THEN_REVIEW) + expect(ok).toBe(false) + expect(harness.taskCreate).not.toHaveBeenCalled() + expect(harness.run).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/recipe-director-launch.ts b/src/renderer/src/lib/recipe-director-launch.ts new file mode 100644 index 00000000000..1ea100fb542 --- /dev/null +++ b/src/renderer/src/lib/recipe-director-launch.ts @@ -0,0 +1,154 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { createDirectorWorktreeShell } from '@/lib/director-worktree-shell' +import { translate } from '@/i18n/i18n' +import { compileRecipe, type Recipe } from './recipe-director-recipes' +import type { Project, TuiAgent } from '../../../shared/types' + +// Why (#9): the recipe director runs a FIXED recipe with ZERO director LLM tokens. +// It creates the same hidden worktree shell the LLM Orcastrator uses, but leaves it +// agent-free; the merged worktree-backed coordinator then creates per-track child +// worktrees and launches the real WORKER agents (the only LLM cost) — review +// continues implement's branch (one PR) because same-track tasks share a worktree. + +// Why: the worker agent does the actual coding, so it must be a real agent (the run +// rejects a worktree-backed run without one). A 'blank' default means "plain shell" +// — fall back to Claude Code, the same resolution the LLM coordinator uses. +function resolveWorkerAgent(defaultTuiAgent: TuiAgent | 'blank' | null | undefined): TuiAgent { + return defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : 'claude' +} + +// Why: the run's coordinator inbox AND the live Control Panel key on the director +// pane (getPaneKeyForTerminalHandle(coordinator_handle)). Binding the run's `from` +// to the shell's terminal handle surfaces the DAG under the director and routes +// worker_done/heartbeat to a real inbox. +// +// Opportunistic by design: we do NOT await terminal readiness before resolving the +// active handle (unlike the LLM path, which waits via pasteDraftWhenAgentReady — it +// has an agent to wait for; a blank shell has no readiness signal). On a cold shell +// the resolve can miss, in which case the run derives its own coordinator handle — +// the run still works correctly, the DAG just isn't pane-anchored to the director +// (cosmetic). Pane-anchoring is owned by the #11 picker, which can await readiness. +async function resolveDirectorShellHandle(worktreeSelector: string): Promise { + try { + const response = await window.api.runtime.call({ + method: 'terminal.resolveActive', + params: { worktree: worktreeSelector } + }) + if (!response.ok) { + return undefined + } + const handle = (response.result as { handle?: unknown } | null)?.handle + return typeof handle === 'string' ? handle : undefined + } catch { + return undefined + } +} + +export type LaunchRecipeDirectorOptions = { + /** Human name for the director (shown in the ORCASTRATORS list). */ + name?: string + /** Worker agent override; defaults to the user's default coding agent. */ + workerAgent?: TuiAgent +} + +/** + * Launch a token-free recipe director for a project. Flow: + * 1. create the no-LLM director worktree shell (no agent, no prompt); + * 2. compile the recipe and create one task per recipe task (track hint in the + * spec, deps wired key→created id), stamped to the shell worktree so the run + * adopts them; + * 3. start a worktree-backed coordinator run anchored on the shell. + * The director shell itself runs no agent → no director LLM tokens. The live + * Control Panel renders the run automatically. + * + * GATING: this function is intentionally UNGATED. The `experimentalOrchestrators` + * gate belongs at the call site — the #11 director-type picker, which does not + * exist yet. The caller (#11) MUST check `experimentalOrchestrators` before + * invoking this; do not call it from any always-on UI path. + */ +export async function launchRecipeDirector( + project: Project, + recipe: Recipe, + options?: LaunchRecipeDirectorOptions +): Promise { + const store = useAppStore.getState() + const settings = store.settings + const workerAgent = resolveWorkerAgent(options?.workerAgent ?? settings?.defaultTuiAgent) + const label = options?.name?.trim() || `${project.displayName} · ${recipe.name}` + + // Compile FIRST so a malformed recipe fails before we create any worktree. + const compiled = compileRecipe(recipe) + + const shell = await createDirectorWorktreeShell(project, { label }) + if (!shell) { + return false + } + + // Why: activate WITHOUT any agent startup payload — a blank terminal, no agent, + // no /orcastrate. This is the token-free invariant: nothing here seeds an LLM + // into the director shell. It still gives the shell a focusable surface (and a + // terminal handle for pane-anchoring the run). + const activation = activateAndRevealWorktree(shell.worktreeId, { + sidebarRevealBehavior: 'auto', + setup: shell.setup + }) + if (!activation) { + toast.error( + translate('auto.lib.orchestrator.launch.no_workspace', 'Could not open the Orcastrator.') + ) + return false + } + + store.registerOrchestrator({ + id: shell.worktreeId, + projectId: project.id, + projectName: label, + worktreeId: shell.worktreeId, + tabId: activation.primaryTabId ?? '', + launchedAt: Date.now() + }) + + const worktreeSelector = `id:${shell.worktreeId}` + + // Create tasks in dependency order, resolving each recipe-local dependsOn key to + // the real task id returned by the previous create. targetWorktree stamps the + // task to the shell so run-start adoptUnownedTasks claims it. + const idByKey = new Map() + for (const task of compiled) { + // Why: compileRecipe already topo-sorts and validates deps, so every dependsOn + // key MUST already be in idByKey. Throw on a miss rather than silently dropping + // it — a dropped dep would let review go ready before implement and surface far + // downstream as the coordinator's confusing same-track ordering refusal. + const depIds = task.dependsOn.map((key) => { + const id = idByKey.get(key) + if (id === undefined) { + throw new Error( + `Recipe '${recipe.name}' task '${task.key}' depends on unmapped key '${key}'` + ) + } + return id + }) + const { task: created } = await window.api.orchestration.taskCreate({ + spec: task.spec, + taskTitle: task.key, + displayName: `${recipe.name}: ${task.key}`, + deps: depIds.length > 0 ? JSON.stringify(depIds) : undefined, + targetWorktree: worktreeSelector + }) + idByKey.set(task.key, created.id) + } + + const coordinatorHandle = await resolveDirectorShellHandle(worktreeSelector) + + await window.api.orchestration.run({ + spec: `recipe:${recipe.name}`, + worktree: worktreeSelector, + worktreeBacked: true, + workerAgent, + ...(coordinatorHandle ? { from: coordinatorHandle } : {}) + }) + + return true +} diff --git a/src/renderer/src/lib/recipe-director-recipes.test.ts b/src/renderer/src/lib/recipe-director-recipes.test.ts new file mode 100644 index 00000000000..e568ac1a8aa --- /dev/null +++ b/src/renderer/src/lib/recipe-director-recipes.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { compileRecipe, IMPLEMENT_THEN_REVIEW, type Recipe } from './recipe-director-recipes' + +// Mirror the coordinator's track-hint contract (parseTrackFromSpec): a leading +// `track: ` line on its own line. Kept local so this renderer test does not +// import main-side code across the layer boundary. +function trackHintOf(spec: string): string | null { + const match = spec.match(/^[ \t]*track:[ \t]*(\S+)[ \t]*$/im) + return match ? match[1] : null +} + +describe('compileRecipe', () => { + it('compiles implement_then_review to two same-track tasks with review after implement', () => { + const compiled = compileRecipe(IMPLEMENT_THEN_REVIEW) + + expect(compiled.map((t) => t.key)).toEqual(['implement', 'review']) + + const implement = compiled[0] + const review = compiled[1] + + // Review waits on implement → one ordered chain (satisfies the coordinator's + // same-track safe-ordering guard) and a real implement→review handoff. + expect(implement.dependsOn).toEqual([]) + expect(review.dependsOn).toEqual(['implement']) + + // Both carry a `track:` hint the coordinator parses, and it is the SAME track + // → one worktree, one branch, one PR. + const implementTrack = trackHintOf(implement.spec) + const reviewTrack = trackHintOf(review.spec) + expect(implementTrack).not.toBeNull() + expect(implementTrack).toBe(reviewTrack) + }) + + it('puts the track hint on the first line so the worker spec follows it', () => { + const compiled = compileRecipe(IMPLEMENT_THEN_REVIEW) + for (const task of compiled) { + // First line is the hint; the body (worker instructions) follows after it. + expect(task.spec).toMatch(/^track: \S+\n/) + const body = task.spec.replace(/^track: \S+\n+/, '') + expect(body).not.toMatch(/^track:/m) + expect(body.trim().length).toBeGreaterThan(0) + } + }) + + it('emits dependencies before dependents regardless of declaration order', () => { + const recipe: Recipe = { + name: 'r', + description: 'd', + tasks: [ + { key: 'b', spec: 'b', dependsOn: ['a'] }, + { key: 'a', spec: 'a' } + ] + } + expect(compileRecipe(recipe).map((t) => t.key)).toEqual(['a', 'b']) + }) + + it('rejects an unknown dependency', () => { + const recipe: Recipe = { + name: 'r', + description: 'd', + tasks: [{ key: 'a', spec: 'a', dependsOn: ['missing'] }] + } + expect(() => compileRecipe(recipe)).toThrow(/unknown task 'missing'/) + }) + + it('rejects a dependency cycle', () => { + const recipe: Recipe = { + name: 'r', + description: 'd', + tasks: [ + { key: 'a', spec: 'a', dependsOn: ['b'] }, + { key: 'b', spec: 'b', dependsOn: ['a'] } + ] + } + expect(() => compileRecipe(recipe)).toThrow(/cycle/) + }) + + it('rejects a self-dependency', () => { + const recipe: Recipe = { + name: 'r', + description: 'd', + tasks: [{ key: 'a', spec: 'a', dependsOn: ['a'] }] + } + expect(() => compileRecipe(recipe)).toThrow(/depends on itself/) + }) + + it('rejects duplicate task keys', () => { + const recipe: Recipe = { + name: 'r', + description: 'd', + tasks: [ + { key: 'a', spec: 'a' }, + { key: 'a', spec: 'a2' } + ] + } + expect(() => compileRecipe(recipe)).toThrow(/duplicate task keys/) + }) +}) diff --git a/src/renderer/src/lib/recipe-director-recipes.ts b/src/renderer/src/lib/recipe-director-recipes.ts new file mode 100644 index 00000000000..b562bccb343 --- /dev/null +++ b/src/renderer/src/lib/recipe-director-recipes.ts @@ -0,0 +1,137 @@ +// Built-in recipes for the token-free recipe director (#9). A recipe is a fixed, +// deterministic task DAG the director compiles into orchestration.taskCreate calls +// — no director LLM tokens are spent (the worker agents do the coding). User- +// editable recipes (YAML), the full starter set (#10), and the DirectorBackend +// abstraction (#8) are deliberately out of scope: this module ships the concrete +// `implement_then_review` recipe and the pure compiler the launch path uses. + +/** A single unit of work in a recipe. Compiled 1:1 into an orchestration task. */ +export type RecipeTask = { + /** Stable, recipe-local key. Used to wire `dependsOn` to created task ids and + * (when no explicit `track`) as the worktree-track hint. */ + key: string + /** The worker's task instructions — the `--- TASK ---` block they receive. */ + spec: string + /** Worktree-track hint. Same-track tasks share one worktree/branch/PR (the + * implement→review handoff). Defaults to `key` (per-task track) when unset. */ + track?: string + /** Recipe-local keys of tasks that must FINISH before this one runs. Same-track + * tasks MUST be totally ordered by deps or the coordinator refuses the run. */ + dependsOn?: string[] + // Per-task agent override is intentionally omitted: #9 runs every task with the + // run's single workerAgent. Re-add when #10/#11 need heterogeneous agents (it + // also needs a per-task agent param on orchestration.taskCreate/dispatch). +} + +/** A fixed, deterministic task DAG the director runs without spending LLM tokens. */ +export type Recipe = { + name: string + description: string + tasks: RecipeTask[] +} + +// Why: implement and review share ONE track so they run in the same worktree — +// review continues implement's branch → one PR, a real handoff. review.dependsOn +// = [implement] both encodes that handoff AND satisfies the coordinator's +// same-track safe-ordering guard (two unordered same-track tasks are refused +// because they would race into one checkout). +const IMPLEMENT_THEN_REVIEW_TRACK = 'implement-review' + +/** The one canonical recipe this PR ships: implement, then review, on a single + * track (one worktree → one PR) with review gated on implement finishing. */ +export const IMPLEMENT_THEN_REVIEW: Recipe = { + name: 'implement_then_review', + description: + 'Implement the change, then review it on the same branch — one worktree, one PR, ' + + 'with the reviewer handed the implementer’s finished work.', + tasks: [ + { + key: 'implement', + track: IMPLEMENT_THEN_REVIEW_TRACK, + spec: + 'Implement the requested change end to end. Make focused commits, keep the ' + + 'build/tests green, and open a PR for your branch. When done, report what you ' + + 'changed and call out anything the reviewer should scrutinize.' + }, + { + key: 'review', + track: IMPLEMENT_THEN_REVIEW_TRACK, + dependsOn: ['implement'], + spec: + 'Review the implementation on this branch. Check correctness, tests, and ' + + 'adherence to the repo’s conventions. Apply small fixes directly; for larger ' + + 'concerns, leave clear review notes. Confirm the PR is ready (or say why not).' + } + ] +} + +/** A recipe task lowered to the inputs `orchestration.taskCreate` needs, minus the + * resolved dependency ids (those exist only after each create returns, so the + * launch path resolves `dependsOn` keys → ids as it goes). */ +export type CompiledRecipeTask = { + key: string + /** Final spec including the `track:` hint line the coordinator parses + strips. */ + spec: string + /** Recipe-local keys this task waits on (resolved to task ids at launch). */ + dependsOn: string[] +} + +// Why (slice 2 / coordinator §3.3): a task declares its track in the spec text via +// a leading `track: ` line — the same low-friction channel the coordinator +// parses (and strips before the worker sees the spec). Put it on its own first +// line so parseTrackFromSpec matches it. +function prependTrackHint(spec: string, track: string): string { + return `track: ${track}\n\n${spec}` +} + +/** + * Compile a recipe into dependency-ordered taskCreate inputs. Pure and + * deterministic: the launch path walks the result in order, calling taskCreate + * for each and mapping `key → created id` so later tasks' `dependsOn` keys resolve + * to real ids. Throws on an unknown/self/cyclic dependency so a malformed recipe + * fails fast at compile time rather than producing a stuck run. + */ +export function compileRecipe(recipe: Recipe): CompiledRecipeTask[] { + const byKey = new Map(recipe.tasks.map((task) => [task.key, task])) + if (byKey.size !== recipe.tasks.length) { + throw new Error(`Recipe '${recipe.name}' has duplicate task keys`) + } + + // Why: same-track tasks must be totally ordered for the coordinator, and deps + // drive that order — so emit tasks in a topological order. A stable DFS keeps + // the output deterministic (tests can assert exact ordering). + const ordered: CompiledRecipeTask[] = [] + const state = new Map() + + const visit = (key: string): void => { + const phase = state.get(key) + if (phase === 'done') { + return + } + if (phase === 'visiting') { + throw new Error(`Recipe '${recipe.name}' has a dependency cycle at task '${key}'`) + } + const task = byKey.get(key) + if (!task) { + throw new Error(`Recipe '${recipe.name}' references unknown task '${key}'`) + } + state.set(key, 'visiting') + for (const dep of task.dependsOn ?? []) { + if (dep === key) { + throw new Error(`Recipe '${recipe.name}' task '${key}' depends on itself`) + } + visit(dep) + } + state.set(key, 'done') + ordered.push({ + key: task.key, + spec: prependTrackHint(task.spec, task.track ?? task.key), + dependsOn: [...(task.dependsOn ?? [])] + }) + } + + for (const task of recipe.tasks) { + visit(task.key) + } + return ordered +} diff --git a/src/shared/orchestration-binding.ts b/src/shared/orchestration-binding.ts index 36f7e00d497..81b300c4d26 100644 --- a/src/shared/orchestration-binding.ts +++ b/src/shared/orchestration-binding.ts @@ -77,6 +77,12 @@ export type OrchestrationTaskCreateParams = { deps?: string parent?: string callerTerminalHandle?: string + // Why (#9): a worktree selector (e.g. `id:`) that stamps the task's + // target_key via the same resolver orchestration.run uses for `worktree`. Lets + // the renderer recipe director — which has the director worktree id, not a live + // terminal handle — create tasks the run will adopt. Takes precedence over + // `callerTerminalHandle` in the handler. + targetWorktree?: string } /** Result of orchestration.taskCreate. */