Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 0 additions & 103 deletions src/main/runtime/rpc/methods/orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 4 additions & 13 deletions src/main/runtime/rpc/methods/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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).
Expand Down
113 changes: 33 additions & 80 deletions src/renderer/src/components/OrchestratorLaunchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -101,31 +90,25 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
: null,
[projectOptions, prefillProjectId]
)
const recipes = useMemo(() => getRecipes(), [])
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null)
const [name, setName] = useState('')
const [agent, setAgent] = useState<TuiAgent | null>(null)
const [prompt, setPrompt] = useState('')
const [directorKind, setDirectorKind] = useState<DirectorKind>('llm')
const [recipeName, setRecipeName] = useState<string | null>(null)

useEffect(() => {
if (visible) {
setSelectedOptionId(prefilledProjectOptionId ?? firstProjectOptionId)
setAgent(defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null)
setName(prefillName)
setPrompt(prefillPrompt)
setDirectorKind('llm')
setRecipeName(recipes[0]?.name ?? null)
}
}, [
visible,
firstProjectOptionId,
prefilledProjectOptionId,
defaultTuiAgent,
prefillName,
prefillPrompt,
recipes
prefillPrompt
])

if (!visible) {
Expand All @@ -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()
}

Expand Down Expand Up @@ -214,16 +184,6 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
)}
/>
</div>
{experimentalOrchestrators && (
<DirectorTypePicker
kind={directorKind}
onKindChange={setDirectorKind}
showRecipeOption={experimentalOrchestrators}
recipes={recipes}
selectedRecipeName={recipeName}
onRecipeChange={setRecipeName}
/>
)}
<div className="space-y-2">
<Label htmlFor={nameId} className="text-xs">
{translate('auto.components.OrchestratorLaunchModal.name', 'Name')}
Expand All @@ -239,43 +199,36 @@ export default function OrchestratorLaunchModal(): React.JSX.Element | null {
}
/>
</div>
{/* 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 && (
<>
<div className="space-y-2">
<Label className="text-xs">
{translate('auto.components.OrchestratorLaunchModal.agent', 'Agent')}
</Label>
<AgentCombobox agents={agents} value={agent} onValueChange={setAgent} />
</div>
<div className="space-y-2">
<Label htmlFor={promptId} className="text-xs">
{translate(
'auto.components.OrchestratorLaunchModal.task',
'What should it orchestrate?'
)}
</Label>
<textarea
id={promptId}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={4}
placeholder={translate(
'auto.components.OrchestratorLaunchModal.task_placeholder',
'Describe the work — the director plans how to split it into worktrees/PRs. Optional.'
)}
className="flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
</>
)}
<div className="space-y-2">
<Label className="text-xs">
{translate('auto.components.OrchestratorLaunchModal.agent', 'Agent')}
</Label>
<AgentCombobox agents={agents} value={agent} onValueChange={setAgent} />
</div>
<div className="space-y-2">
<Label htmlFor={promptId} className="text-xs">
{translate(
'auto.components.OrchestratorLaunchModal.task',
'What should it orchestrate?'
)}
</Label>
<textarea
id={promptId}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={4}
placeholder={translate(
'auto.components.OrchestratorLaunchModal.task_placeholder',
'Describe the work — the director plans how to split it into worktrees/PRs. Optional.'
)}
className="flex w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={closeModal}>
{translate('auto.components.OrchestratorLaunchModal.cancel', 'Cancel')}
</Button>
<Button type="submit" disabled={!project || (isRecipe && !recipeName)}>
<Button type="submit" disabled={!project}>
{translate('auto.components.OrchestratorLaunchModal.launch', 'Launch Orcastrator')}
</Button>
</DialogFooter>
Expand Down
74 changes: 0 additions & 74 deletions src/renderer/src/components/director/DirectorTypePicker.test.tsx

This file was deleted.

Loading