Skip to content

Commit 19085cd

Browse files
authored
feat(orchestration): token-free RecipeDirectorBackend + implement_then_review recipe (#9)
The token-free recipe director: launchRecipeDirector(project, recipe) creates a hidden director worktree shell with NO LLM (zero director tokens), compiles the recipe to orchestration.taskCreate calls (same-track + deps for the implement→review handoff), and starts orchestration.run({worktreeBacked, workerAgent}). The merged coordinator creates the track worktree, runs implement→review in it, and the Control Panel renders the live DAG. taskCreate gains an optional targetWorktree selector so renderer directors stamp the same target_key the run resolves (verified end-to-end against real adoptUnownedTasks, with a negative control). Token-free shell guarded. Reviewed by a 3-lens panel + a hardening round; both load-bearing seams (token-free, target_key binding) verified against source. Closes #9. Part of #5.
1 parent 9d3589e commit 19085cd

10 files changed

Lines changed: 805 additions & 42 deletions

src/main/runtime/rpc/methods/orchestration.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,109 @@ describe('orchestration RPC methods', () => {
14501450
expect(db.getTask(result.task.id)?.coordinator_run_id).toBeNull()
14511451
expect(db.getTask(result.task.id)?.target_key).toBeNull()
14521452
})
1453+
1454+
it('stamps target_key from --target-worktree (#9 recipe director path)', async () => {
1455+
setup()
1456+
// The renderer recipe director has the director worktree id, not a terminal
1457+
// handle, so it passes a worktree selector. It resolves through the SAME
1458+
// resolveOrchestrationTargetKey the run uses, so the keys match for adoption.
1459+
const targetSpy = vi
1460+
.spyOn(runtime, 'resolveOrchestrationTargetKey')
1461+
.mockResolvedValue('worktree:director1')
1462+
const terminalSpy = vi.spyOn(runtime, 'resolveOrchestrationTargetKeyForTerminal')
1463+
1464+
const result = (await call('orchestration.taskCreate', {
1465+
spec: 'implement',
1466+
targetWorktree: 'id:director1'
1467+
})) as { task: { id: string } }
1468+
1469+
expect(targetSpy).toHaveBeenCalledWith('id:director1')
1470+
// --target-worktree wins: the terminal resolver is not consulted.
1471+
expect(terminalSpy).not.toHaveBeenCalled()
1472+
expect(db.getTask(result.task.id)?.target_key).toBe('worktree:director1')
1473+
})
1474+
1475+
it('refuses a task whose --target-worktree does not resolve (fail closed)', async () => {
1476+
setup()
1477+
vi.spyOn(runtime, 'resolveOrchestrationTargetKey').mockRejectedValue(
1478+
new Error('selector_not_found')
1479+
)
1480+
1481+
await expect(
1482+
call('orchestration.taskCreate', { spec: 'x', targetWorktree: 'id:gone' })
1483+
).rejects.toThrow(/selector_not_found/)
1484+
})
1485+
})
1486+
1487+
describe('orchestration.taskCreate ↔ run target_key adoption (#9 end-to-end)', () => {
1488+
// Why (#9, F1 clash seam): the task path (taskCreate --target-worktree) and the
1489+
// run path (orchestration.run --worktree) must resolve the SAME target_key, or
1490+
// run-start adoption silently fails to claim the task and the recipe never
1491+
// dispatches. These tests prove the binding through the REAL resolver and the
1492+
// REAL db.adoptUnownedTasks — not a mocked resolver returning a matched literal.
1493+
// Only the lowest-level filesystem worktree lookup is stubbed (id:X → worktree
1494+
// X); resolveOrchestrationTargetKey and adoptUnownedTasks run their real code.
1495+
function stubWorktreeLookup(): void {
1496+
;(
1497+
runtime as unknown as {
1498+
resolveWorktreeSelector: (selector: string) => Promise<{ id: string }>
1499+
}
1500+
).resolveWorktreeSelector = async (selector) => ({ id: selector.replace(/^id:/, '') })
1501+
}
1502+
1503+
it('adopts a task stamped via --target-worktree into a run on the same worktree', async () => {
1504+
setup()
1505+
stubWorktreeLookup()
1506+
1507+
// Task path: the renderer recipe director stamps the task by worktree id.
1508+
const { task } = (await call('orchestration.taskCreate', {
1509+
spec: 'implement',
1510+
targetWorktree: 'id:W'
1511+
})) as { task: { id: string; target_key: string | null } }
1512+
1513+
// Run path: resolve target_key the EXACT way orchestration.run does, start
1514+
// the run, then adopt the EXACT way the coordinator's executeLoop does.
1515+
const runTargetKey = await runtime.resolveOrchestrationTargetKey('id:W')
1516+
const run = db.startCoordinatorRun({
1517+
spec: 'recipe:implement_then_review',
1518+
coordinatorHandle: 'coordinator-e2e',
1519+
targetKey: runTargetKey,
1520+
worktreeBacked: true,
1521+
workerAgent: 'claude'
1522+
})
1523+
const bound = db.adoptUnownedTasks(run.id, db.getCoordinatorRun(run.id)?.target_key ?? null)
1524+
1525+
// Both paths resolved worktree:W → adoption binds the task to the run.
1526+
expect(task.target_key).toBe('worktree:W')
1527+
expect(bound).toBe(1)
1528+
expect(db.getTask(task.id)?.coordinator_run_id).toBe(run.id)
1529+
})
1530+
1531+
it('does NOT adopt a task stamped to a different worktree (fails if keys ever diverge)', async () => {
1532+
setup()
1533+
stubWorktreeLookup()
1534+
1535+
// Stamped to a DIFFERENT worktree than the run targets. This is the negative
1536+
// control: it makes the positive test meaningful — if taskCreate and run ever
1537+
// resolved the same key for different worktree inputs (the divergence we
1538+
// guard against), this task would wrongly bind and this assertion would fail.
1539+
const { task } = (await call('orchestration.taskCreate', {
1540+
spec: 'implement',
1541+
targetWorktree: 'id:OTHER'
1542+
})) as { task: { id: string } }
1543+
1544+
const runTargetKey = await runtime.resolveOrchestrationTargetKey('id:W')
1545+
const run = db.startCoordinatorRun({
1546+
spec: 'recipe',
1547+
coordinatorHandle: 'coordinator-e2e-2',
1548+
targetKey: runTargetKey,
1549+
worktreeBacked: true,
1550+
workerAgent: 'claude'
1551+
})
1552+
db.adoptUnownedTasks(run.id, db.getCoordinatorRun(run.id)?.target_key ?? null)
1553+
1554+
expect(db.getTask(task.id)?.coordinator_run_id).toBeNull()
1555+
})
14531556
})
14541557

14551558
describe('orchestration.reset', () => {

src/main/runtime/rpc/methods/orchestration.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,12 @@ const TaskCreateParams = z.object({
128128
displayName: OptionalString,
129129
deps: OptionalString,
130130
parent: OptionalString,
131-
callerTerminalHandle: OptionalString
131+
callerTerminalHandle: OptionalString,
132+
// Why (#9): the recipe director runs in the renderer and holds the director
133+
// worktree id, not a live terminal handle. A worktree selector lets it stamp
134+
// the task's target directly — symmetric with orchestration.run's `worktree`,
135+
// and using the SAME resolver so the keys match and run-start adoption binds.
136+
targetWorktree: OptionalString
132137
})
133138

134139
const TaskListParams = z.object({
@@ -387,9 +392,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
387392
// Why (#12): stamp the task with its OWN target (the creating terminal's
388393
// worktree) so adoption only binds it to a same-target run — never poached
389394
// by a concurrent run on another target.
390-
const targetKey = await runtime.resolveOrchestrationTargetKeyForTerminal(
391-
params.callerTerminalHandle
392-
)
395+
// Why (#9): an explicit --target-worktree wins (the renderer recipe director
396+
// has a worktree id, not a terminal handle) and resolves through the same
397+
// resolveOrchestrationTargetKey that orchestration.run uses, so the task's
398+
// key matches the run's and run-start adoptUnownedTasks claims it.
399+
const targetKey = params.targetWorktree
400+
? await runtime.resolveOrchestrationTargetKey(params.targetWorktree)
401+
: await runtime.resolveOrchestrationTargetKeyForTerminal(params.callerTerminalHandle)
393402
// Why (#12): a task created while a run is active belongs to that run so
394403
// the coordinator's run-scoped listTasks sees it — but only the run on the
395404
// SAME target (getActiveCoordinatorRunForTarget, not the global latest).
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { Project } from '../../../shared/types'
3+
4+
const harness = vi.hoisted(() => ({
5+
createWorktree: vi.fn(),
6+
repos: [] as { id: string; worktreeBaseRef?: string }[],
7+
toastError: vi.fn()
8+
}))
9+
10+
vi.mock('@/store', () => ({
11+
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(harness), {
12+
getState: () => ({ createWorktree: harness.createWorktree, repos: harness.repos })
13+
})
14+
}))
15+
16+
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
17+
18+
vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => harness.toastError(...args) } }))
19+
20+
import { createDirectorWorktreeShell } from './director-worktree-shell'
21+
22+
// createWorktree's positional signature: the createdWithAgent flag — the arg that
23+
// makes activation relaunch an agent — sits well after displayName.
24+
const CREATED_WITH_AGENT_ARG_INDEX = 10
25+
const SETUP_DECISION_ARG_INDEX = 3
26+
27+
const PROJECT: Project = {
28+
id: 'proj_1',
29+
displayName: 'Demo',
30+
sourceRepoIds: ['repo_1']
31+
} as unknown as Project
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
harness.repos = [{ id: 'repo_1', worktreeBaseRef: 'main' }]
36+
harness.createWorktree.mockResolvedValue({ worktree: { id: 'wt_director' }, setup: undefined })
37+
})
38+
39+
describe('createDirectorWorktreeShell', () => {
40+
it('creates the shell token-free: no agent is seeded into the director pane', async () => {
41+
const shell = await createDirectorWorktreeShell(PROJECT, { label: 'My Recipe' })
42+
43+
expect(shell).toEqual({ worktreeId: 'wt_director', setup: undefined })
44+
expect(harness.createWorktree).toHaveBeenCalledTimes(1)
45+
46+
const args = harness.createWorktree.mock.calls[0]
47+
// 'skip' setup (a director coordinates, it doesn't build)...
48+
expect(args[SETUP_DECISION_ARG_INDEX]).toBe('skip')
49+
// ...and CRUCIALLY no createdWithAgent — otherwise activation would relaunch an
50+
// LLM in the director pane, breaking the token-free invariant.
51+
expect(args[CREATED_WITH_AGENT_ARG_INDEX]).toBeUndefined()
52+
})
53+
54+
it('returns null and toasts when the project has no repo', async () => {
55+
const shell = await createDirectorWorktreeShell(
56+
{ ...PROJECT, sourceRepoIds: [] } as unknown as Project,
57+
{ label: 'x' }
58+
)
59+
expect(shell).toBeNull()
60+
expect(harness.createWorktree).not.toHaveBeenCalled()
61+
expect(harness.toastError).toHaveBeenCalledTimes(1)
62+
})
63+
64+
it('returns null and toasts when worktree creation fails', async () => {
65+
harness.createWorktree.mockRejectedValue(new Error('boom'))
66+
const shell = await createDirectorWorktreeShell(PROJECT, { label: 'x' })
67+
expect(shell).toBeNull()
68+
expect(harness.toastError).toHaveBeenCalledWith('boom')
69+
})
70+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { toast } from 'sonner'
2+
import { useAppStore } from '@/store'
3+
import { ORCASTRATOR_DISPLAY_PREFIX } from '@/store/slices/orchestrators'
4+
import { translate } from '@/i18n/i18n'
5+
import type { Project } from '../../../shared/types'
6+
7+
// Why: the director's own dedicated worktree — hidden from Projects, shown only in
8+
// the ORCASTRATORS section (the display prefix), so a director never couples to the
9+
// project's primary checkout. This is the shell BOTH director kinds share: the LLM
10+
// Orcastrator seeds /orcastrate + an agent into it, while the token-free recipe
11+
// director (#9) leaves it agent-free and uses it purely as the lineage anchor +
12+
// `.orcastrate` log home + the coordinator's operating worktree.
13+
14+
export type DirectorWorktreeShell = {
15+
worktreeId: string
16+
setup: Awaited<ReturnType<ReturnType<typeof useAppStore.getState>['createWorktree']>>['setup']
17+
}
18+
19+
/**
20+
* Create the hidden director worktree shell for a project. This creates ONLY the
21+
* worktree — it does NOT start an agent or seed any prompt, so it is token-free by
22+
* construction; callers layer agent startup on top when they want an LLM director.
23+
* Surfaces failures via toast and returns null (no repo / create failed).
24+
*/
25+
export async function createDirectorWorktreeShell(
26+
project: Project,
27+
options: { label: string }
28+
): Promise<DirectorWorktreeShell | null> {
29+
const repoId = project.sourceRepoIds[0]
30+
if (!repoId) {
31+
toast.error(
32+
translate(
33+
'auto.lib.orchestrator.launch.no_repo',
34+
'This project has no repo to launch an Orcastrator in.'
35+
)
36+
)
37+
return null
38+
}
39+
40+
const store = useAppStore.getState()
41+
const repo = store.repos.find((entry) => entry.id === repoId)
42+
try {
43+
// Why: 'skip' setup — a director coordinates, it doesn't build, so it does not
44+
// need the repo's setup scripts run in its checkout.
45+
// Token-free invariant (#9): this call MUST NOT pass `createdWithAgent`. The
46+
// shell is agent-free only because that arg stays undefined — otherwise
47+
// activateAndRevealWorktree's `opts?.startup ?? buildCreatedAgentReopenStartup(wt)`
48+
// fallback would relaunch an LLM in the director pane (a director-pane token
49+
// cost). The arg list intentionally stops at displayName for exactly this reason.
50+
const result = await store.createWorktree(
51+
repoId,
52+
`orcastrator-${options.label}`,
53+
repo?.worktreeBaseRef,
54+
'skip',
55+
undefined,
56+
undefined,
57+
`${ORCASTRATOR_DISPLAY_PREFIX}${options.label}`
58+
)
59+
return { worktreeId: result.worktree.id, setup: result.setup }
60+
} catch (error) {
61+
toast.error(
62+
error instanceof Error
63+
? error.message
64+
: translate(
65+
'auto.lib.orchestrator.launch.create_failed',
66+
'Failed to create the Orcastrator.'
67+
)
68+
)
69+
return null
70+
}
71+
}

src/renderer/src/lib/orchestrator-launch.ts

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
99
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
1010
import { buildDirectWorkItemStartupOpts } from '@/lib/launch-work-item-direct-agent'
1111
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
12-
import { ORCASTRATOR_DISPLAY_PREFIX } from '@/store/slices/orchestrators'
12+
import { createDirectorWorktreeShell } from '@/lib/director-worktree-shell'
1313
import { translate } from '@/i18n/i18n'
1414
import type { Project, TuiAgent } from '../../../shared/types'
1515

@@ -49,52 +49,20 @@ export async function launchOrchestratorForProject(
4949
project: Project,
5050
options?: LaunchOrchestratorOptions
5151
): Promise<boolean> {
52-
const repoId = project.sourceRepoIds[0]
53-
if (!repoId) {
54-
toast.error(
55-
translate(
56-
'auto.lib.orchestrator.launch.no_repo',
57-
'This project has no repo to launch an Orcastrator in.'
58-
)
59-
)
60-
return false
61-
}
62-
6352
const store = useAppStore.getState()
64-
const repo = store.repos.find((entry) => entry.id === repoId)
6553
const settings = store.settings
6654
const agent = options?.agent ?? resolveCoordinatorAgent(settings?.defaultTuiAgent)
6755
const label = options?.name?.trim() || project.displayName
6856
const task = options?.prompt?.trim()
6957
const promptContent = task ? `${ORCASTRATE_PROMPT} ${task}` : ORCASTRATE_PROMPT
7058

71-
let worktreeId: string
72-
let setup: Awaited<ReturnType<typeof store.createWorktree>>['setup']
73-
try {
74-
// Why: 'skip' setup — a director coordinates, it doesn't build, so it does
75-
// not need the repo's setup scripts run in its checkout.
76-
const result = await store.createWorktree(
77-
repoId,
78-
`orcastrator-${label}`,
79-
repo?.worktreeBaseRef,
80-
'skip',
81-
undefined,
82-
undefined,
83-
`${ORCASTRATOR_DISPLAY_PREFIX}${label}`
84-
)
85-
worktreeId = result.worktree.id
86-
setup = result.setup
87-
} catch (error) {
88-
toast.error(
89-
error instanceof Error
90-
? error.message
91-
: translate(
92-
'auto.lib.orchestrator.launch.create_failed',
93-
'Failed to create the Orcastrator.'
94-
)
95-
)
59+
// Why: the worktree shell is identical for both director kinds; the Orcastrator
60+
// is just this shell PLUS the coordinator agent + /orcastrate seeded below.
61+
const shell = await createDirectorWorktreeShell(project, { label })
62+
if (!shell) {
9663
return false
9764
}
65+
const { worktreeId, setup } = shell
9866

9967
const startupPlan = buildAgentStartupPlan({
10068
agent,

0 commit comments

Comments
 (0)