From 4f38daf6409c14a6fc9f1fb5fa87b639ca42f7bf Mon Sep 17 00:00:00 2001 From: zaridan <1617679+zaridan@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:16:30 -0700 Subject: [PATCH 1/4] feat(orchestration): recipe model + token-free recipe compiler (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the built-in Recipe/RecipeTask types and the one canonical recipe this PR ships, `implement_then_review` — implement + review on the SAME track (one worktree → one PR) with review.dependsOn=[implement] so review runs after implement finishes (a real handoff that also satisfies the coordinator's same-track safe-ordering guard). `compileRecipe` lowers a recipe into dependency-ordered taskCreate inputs: it topo-sorts by deps, prepends the coordinator's `track:` spec hint, and fails fast on unknown/self/cyclic deps. Pure + deterministic so the launch path can walk it, calling taskCreate per task and resolving dependsOn keys to created ids as it goes. No director LLM tokens involved. Tests assert the implement_then_review compilation (2 tasks, same track, review after implement) against the coordinator's real parseTrackFromSpec. Deferred: full recipe set (#10), DirectorBackend abstraction (#8), picker UI (#11). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/recipe-director-recipes.test.ts | 89 +++++++++++ .../src/lib/recipe-director-recipes.ts | 140 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/renderer/src/lib/recipe-director-recipes.test.ts create mode 100644 src/renderer/src/lib/recipe-director-recipes.ts 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..7656d044905 --- /dev/null +++ b/src/renderer/src/lib/recipe-director-recipes.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { compileRecipe, IMPLEMENT_THEN_REVIEW, type Recipe } from './recipe-director-recipes' +import { parseTrackFromSpec } from '../../../main/runtime/orchestration/coordinator' + +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 = parseTrackFromSpec(implement.spec).trackKey + const reviewTrack = parseTrackFromSpec(review.spec).trackKey + expect(implementTrack).not.toBeNull() + expect(implementTrack).toBe(reviewTrack) + }) + + it('strips the track hint cleanly so the worker spec survives', () => { + const compiled = compileRecipe(IMPLEMENT_THEN_REVIEW) + for (const task of compiled) { + const { strippedSpec } = parseTrackFromSpec(task.spec) + expect(strippedSpec).not.toMatch(/^track:/m) + expect(strippedSpec.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..7effcc59d2d --- /dev/null +++ b/src/renderer/src/lib/recipe-director-recipes.ts @@ -0,0 +1,140 @@ +// 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. + +import type { TuiAgent } from '../../../shared/types' + +/** 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 worker agent override; defaults to the run's `workerAgent`. */ + agent?: TuiAgent +} + +/** 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[] + agent?: TuiAgent +} + +// 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 ?? [])], + ...(task.agent ? { agent: task.agent } : {}) + }) + } + + for (const task of recipe.tasks) { + visit(task.key) + } + return ordered +} From 774b910361ee9be45b7ebc6fab9dad1f42b2d739 Mon Sep 17 00:00:00 2001 From: zaridan <1617679+zaridan@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:17:40 -0700 Subject: [PATCH 2/4] feat(orchestration): taskCreate --target-worktree for renderer directors (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orchestration.taskCreate stamps a task's target_key from the creating terminal's worktree, but the renderer recipe director has the director worktree id, not a live terminal handle. Add an optional `targetWorktree` selector that stamps target_key through the SAME resolveOrchestrationTargetKey that orchestration.run uses for `worktree` — so a pre-created task's key matches the run's target and run-start adoptUnownedTasks claims it. Precedence: an explicit targetWorktree wins over callerTerminalHandle; it fails closed (refuses the task) when the selector doesn't resolve, mirroring run's guard. Plumbed through the shared preload binding type; preload/web forward params verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime/rpc/methods/orchestration.test.ts | 32 +++++++++++++++++++ src/main/runtime/rpc/methods/orchestration.ts | 17 +++++++--- src/shared/orchestration-binding.ts | 6 ++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 27553ed97aa..46a3e9ca98c 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1450,6 +1450,38 @@ 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.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/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. */ From f1903cc21e57c4aa89002a4a7ff5fb91be20af31 Mon Sep 17 00:00:00 2001 From: zaridan <1617679+zaridan@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:23:28 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat(orchestration):=20launchRecipeDirector?= =?UTF-8?q?=20=E2=80=94=20token-free=20recipe=20director=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the hidden director worktree-shell creation shared by both director kinds into createDirectorWorktreeShell (no agent, no prompt → token-free by construction); launchOrchestratorForProject now layers the coordinator agent + /orcastrate on top of it. Add launchRecipeDirector(project, recipe): create the no-LLM shell, activate it with NO startup payload (blank terminal — the token-free invariant), then compile the recipe and create one task per recipe task (track hint in the spec, deps wired key→created id, targetWorktree stamping each to the shell so run-start adoption claims them), then start a worktree-backed coordinator run anchored on the shell with the user's default coding agent as the worker. The run's `from` is bound to the shell's terminal handle (best-effort) so the live Control Panel keys the DAG on the director pane; on a miss the run still starts. The only LLM cost is the worker agent(s) doing implement/review. Tests: recipe→taskCreate compilation (ordered, same-track, review deps implement), token-free invariant (activation gets no agent startup), the launch issues the right taskCreate + worktree-backed run calls, blank-agent fallback to claude, and graceful shell-create/handle-resolve failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/director-worktree-shell.ts | 66 ++++++++ src/renderer/src/lib/orchestrator-launch.ts | 44 +----- .../src/lib/recipe-director-launch.test.ts | 147 ++++++++++++++++++ .../src/lib/recipe-director-launch.ts | 134 ++++++++++++++++ .../src/lib/recipe-director-recipes.test.ts | 23 ++- 5 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/lib/director-worktree-shell.ts create mode 100644 src/renderer/src/lib/recipe-director-launch.test.ts create mode 100644 src/renderer/src/lib/recipe-director-launch.ts 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..c02288272ed --- /dev/null +++ b/src/renderer/src/lib/director-worktree-shell.ts @@ -0,0 +1,66 @@ +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. + 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..51f11a0dbdc --- /dev/null +++ b/src/renderer/src/lib/recipe-director-launch.ts @@ -0,0 +1,134 @@ +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. Best-effort: the shell's blank terminal +// is created asynchronously, so on a miss we let the run derive its own handle — +// the run still works, it just isn't pane-anchored to the director. +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. + */ +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) { + const depIds = task.dependsOn + .map((key) => idByKey.get(key)) + .filter((id): id is string => id !== undefined) + 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 index 7656d044905..e568ac1a8aa 100644 --- a/src/renderer/src/lib/recipe-director-recipes.test.ts +++ b/src/renderer/src/lib/recipe-director-recipes.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest' import { compileRecipe, IMPLEMENT_THEN_REVIEW, type Recipe } from './recipe-director-recipes' -import { parseTrackFromSpec } from '../../../main/runtime/orchestration/coordinator' + +// 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', () => { @@ -18,18 +25,20 @@ describe('compileRecipe', () => { // Both carry a `track:` hint the coordinator parses, and it is the SAME track // → one worktree, one branch, one PR. - const implementTrack = parseTrackFromSpec(implement.spec).trackKey - const reviewTrack = parseTrackFromSpec(review.spec).trackKey + const implementTrack = trackHintOf(implement.spec) + const reviewTrack = trackHintOf(review.spec) expect(implementTrack).not.toBeNull() expect(implementTrack).toBe(reviewTrack) }) - it('strips the track hint cleanly so the worker spec survives', () => { + 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) { - const { strippedSpec } = parseTrackFromSpec(task.spec) - expect(strippedSpec).not.toMatch(/^track:/m) - expect(strippedSpec.trim().length).toBeGreaterThan(0) + // 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) } }) From 2a1d2b4bc4689fee3f0d38a105e052b6352cc677 Mon Sep 17 00:00:00 2001 From: zaridan <1617679+zaridan@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:50:10 -0700 Subject: [PATCH 4/4] =?UTF-8?q?test(orchestration):=20harden=20recipe=20di?= =?UTF-8?q?rector=20=E2=80=94=20e2e=20adoption=20+=20token-free=20guard=20?= =?UTF-8?q?(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge hardenings from the PR #23 review panel (no blockers; both load-bearing properties confirmed). Tighten the seams that have clash history. End-to-end target_key adoption test (the F1 clash seam): prove the task path (taskCreate --target-worktree) and the run path (run --worktree) resolve the SAME key through the REAL resolveOrchestrationTargetKey + REAL adoptUnownedTasks — a real in-memory OrchestrationDb, only the filesystem worktree lookup stubbed (id:X→worktree X). A task stamped to id:W is adopted by a run on id:W; a negative control (task on id:OTHER) is NOT adopted, so the test fails if the two paths ever diverge. Token-free invariant guard: comment at createDirectorWorktreeShell's createWorktree call documenting that it MUST NOT pass createdWithAgent (else activation's buildCreatedAgentReopenStartup fallback relaunches an LLM in the director pane), plus a test exercising the REAL shell helper that asserts the createdWithAgent arg stays undefined and setup is 'skip'. Nits: drop the dead per-task agent plumbing from Recipe/CompiledRecipeTask (re-add when #10/#11 need heterogeneous agents); throw on an unmapped dependsOn key in the launch loop instead of silently dropping it; document that the #11 picker MUST gate launchRecipeDirector on experimentalOrchestrators; document the shell-handle anchoring as opportunistic (no readiness await, fail-safe to a derived coordinator handle). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime/rpc/methods/orchestration.test.ts | 71 +++++++++++++++++++ .../src/lib/director-worktree-shell.test.ts | 70 ++++++++++++++++++ .../src/lib/director-worktree-shell.ts | 5 ++ .../src/lib/recipe-director-launch.ts | 32 +++++++-- .../src/lib/recipe-director-recipes.ts | 11 ++- 5 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/lib/director-worktree-shell.test.ts diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 46a3e9ca98c..7793cb160f2 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1484,6 +1484,77 @@ describe('orchestration RPC methods', () => { }) }) + 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', () => { function seedResetState(): void { db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) 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 index c02288272ed..b0bf43f3282 100644 --- a/src/renderer/src/lib/director-worktree-shell.ts +++ b/src/renderer/src/lib/director-worktree-shell.ts @@ -42,6 +42,11 @@ export async function createDirectorWorktreeShell( 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}`, diff --git a/src/renderer/src/lib/recipe-director-launch.ts b/src/renderer/src/lib/recipe-director-launch.ts index 51f11a0dbdc..1ea100fb542 100644 --- a/src/renderer/src/lib/recipe-director-launch.ts +++ b/src/renderer/src/lib/recipe-director-launch.ts @@ -22,9 +22,14 @@ function resolveWorkerAgent(defaultTuiAgent: TuiAgent | 'blank' | null | undefin // 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. Best-effort: the shell's blank terminal -// is created asynchronously, so on a miss we let the run derive its own handle — -// the run still works, it just isn't pane-anchored to the director. +// 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({ @@ -57,6 +62,11 @@ export type LaunchRecipeDirectorOptions = { * 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, @@ -107,9 +117,19 @@ export async function launchRecipeDirector( // task to the shell so run-start adoptUnownedTasks claims it. const idByKey = new Map() for (const task of compiled) { - const depIds = task.dependsOn - .map((key) => idByKey.get(key)) - .filter((id): id is string => id !== undefined) + // 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, diff --git a/src/renderer/src/lib/recipe-director-recipes.ts b/src/renderer/src/lib/recipe-director-recipes.ts index 7effcc59d2d..b562bccb343 100644 --- a/src/renderer/src/lib/recipe-director-recipes.ts +++ b/src/renderer/src/lib/recipe-director-recipes.ts @@ -5,8 +5,6 @@ // abstraction (#8) are deliberately out of scope: this module ships the concrete // `implement_then_review` recipe and the pure compiler the launch path uses. -import type { TuiAgent } from '../../../shared/types' - /** 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 @@ -20,8 +18,9 @@ export type RecipeTask = { /** 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 worker agent override; defaults to the run's `workerAgent`. */ - agent?: TuiAgent + // 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. */ @@ -75,7 +74,6 @@ export type CompiledRecipeTask = { spec: string /** Recipe-local keys this task waits on (resolved to task ids at launch). */ dependsOn: string[] - agent?: TuiAgent } // Why (slice 2 / coordinator §3.3): a task declares its track in the spec text via @@ -128,8 +126,7 @@ export function compileRecipe(recipe: Recipe): CompiledRecipeTask[] { ordered.push({ key: task.key, spec: prependTrackHint(task.spec, task.track ?? task.key), - dependsOn: [...(task.dependsOn ?? [])], - ...(task.agent ? { agent: task.agent } : {}) + dependsOn: [...(task.dependsOn ?? [])] }) }