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: 103 additions & 0 deletions src/main/runtime/rpc/methods/orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 13 additions & 4 deletions src/main/runtime/rpc/methods/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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).
Expand Down
70 changes: 70 additions & 0 deletions src/renderer/src/lib/director-worktree-shell.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
71 changes: 71 additions & 0 deletions src/renderer/src/lib/director-worktree-shell.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<ReturnType<typeof useAppStore.getState>['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<DirectorWorktreeShell | null> {
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
}
}
44 changes: 6 additions & 38 deletions src/renderer/src/lib/orchestrator-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -49,52 +49,20 @@ export async function launchOrchestratorForProject(
project: Project,
options?: LaunchOrchestratorOptions
): Promise<boolean> {
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<ReturnType<typeof store.createWorktree>>['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,
Expand Down
Loading