From d9785c7c2c3f3ac4d5ffb678f767dea8cac02c51 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:46:44 +0900 Subject: [PATCH] feat(team-mode): add team runtime create with tests --- .../team-mode/team-runtime/create.test.ts | 375 ++++++++++++++++++ src/features/team-mode/team-runtime/create.ts | 260 ++++++++++++ 2 files changed, 635 insertions(+) create mode 100644 src/features/team-mode/team-runtime/create.test.ts create mode 100644 src/features/team-mode/team-runtime/create.ts diff --git a/src/features/team-mode/team-runtime/create.test.ts b/src/features/team-mode/team-runtime/create.test.ts new file mode 100644 index 000000000..1fb72b891 --- /dev/null +++ b/src/features/team-mode/team-runtime/create.test.ts @@ -0,0 +1,375 @@ +/// + +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { access, mkdtemp, readdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +import { TeamModeConfigSchema } from "../../../config/schema/team-mode" +import type { ExecutorContext } from "../../../tools/delegate-task/executor-types" +import type { BackgroundTask, LaunchInput } from "../../background-agent/types" +import { BackgroundManager } from "../../background-agent/manager" +import { loadRuntimeState } from "../team-state-store/store" +import type { TeamSpec } from "../types" + +const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({ + agentToUse: `${member.name}-agent`, + model: { providerID: "openai", modelID: "gpt-5.4-mini" }, + fallbackChain: undefined, + systemContent: `system:${member.name}`, +})) + +mock.module("./resolve-member", () => ({ resolveMember: resolveMemberMock })) + +const { createTeamRun, TeamRunCreateError } = await import("./create") + +function createConfig(baseDir: string, maxParallelMembers = 4) { + return TeamModeConfigSchema.parse({ base_dir: baseDir, max_parallel_members: maxParallelMembers, max_wall_clock_minutes: 1 }) +} + +function createSpec(memberCount: number, withWorktrees = false): TeamSpec { + return { + version: 1, + name: "alpha-team", + createdAt: Date.now(), + leadAgentId: "member-1", + members: Array.from({ length: memberCount }, (_, index) => ({ + kind: "category", + name: `member-${index + 1}`, + category: ["quick", "deep", "artistry"][index] ?? "deep", + prompt: `prompt-${index + 1}`, + backendType: "in-process", + isActive: true, + color: `color-${index + 1}`, + ...(withWorktrees ? { worktreePath: `./worktrees/member-${index + 1}` } : {}), + })), + } +} + +function createContext(baseDir: string, manager: BackgroundManager): ExecutorContext & { client: { session: { create: ReturnType } } } { + return { + client: { session: { create: mock(async () => ({ data: { id: "forbidden" } })) } } as ExecutorContext["client"] & { session: { create: ReturnType } }, + manager, + directory: baseDir, + } +} + +function createManager( + baseDir: string, + launchImpl: (input: LaunchInput) => Promise, + getTaskImpl: (taskId: string) => BackgroundTask | undefined = () => undefined, +): { manager: BackgroundManager; launchMock: ReturnType; cancelTaskMock: ReturnType } { + const manager = new BackgroundManager({ client: {} as ExecutorContext["client"], directory: baseDir } as ConstructorParameters[0]) + const launchMock = mock((input: LaunchInput) => launchImpl(input)) + const getTaskMock = mock((taskId: string) => getTaskImpl(taskId)) + const cancelTaskMock = mock(async () => true) + manager.launch = launchMock + manager.getTask = getTaskMock + manager.cancelTask = cancelTaskMock + return { manager, launchMock, cancelTaskMock } +} + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath) + return true + } catch { + return false + } +} + +async function loadSingleRuntimeState(baseDir: string) { + const [teamRunId] = await readdir(path.join(baseDir, "runtime")) + return await loadRuntimeState(teamRunId ?? "", createConfig(baseDir)) +} + +describe("createTeamRun", () => { + const temporaryDirectories: string[] = [] + + beforeEach(() => { + resolveMemberMock.mockClear() + }) + + afterAll(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true }))) + }) + + test("spawns 3 members through BackgroundManager.launch without direct session creation", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-create-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager, launchMock } = createManager(baseDir, async () => ({ id: `task-${++launchCount}`, sessionID: `session-${launchCount}`, status: "running" } as BackgroundTask)) + const context = createContext(baseDir, manager) + + // when + const runtimeState = await createTeamRun(createSpec(3), "lead-session", context, createConfig(baseDir), manager) + + // then + expect(launchMock).toHaveBeenCalledTimes(3) + expect(context.client.session.create).toHaveBeenCalledTimes(0) + expect(runtimeState.status).toBe("active") + expect(runtimeState.members.map((member) => member.sessionId)).toEqual(["session-1", "session-2", "session-3"]) + expect((launchMock.mock.calls as Array<[LaunchInput]>).every(([input]) => input.suppressTmuxSpawn === true)).toBe(true) + }) + + test("persists the resolved subagent_type and model on each spawned runtime member", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-subagent-type-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager } = createManager(baseDir, async () => ({ id: `task-${++launchCount}`, sessionID: `session-${launchCount}`, status: "running" } as BackgroundTask)) + + // when + const runtimeState = await createTeamRun(createSpec(3), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager) + + // then + expect(runtimeState.members.map((member) => ({ + name: member.name, + subagent_type: member.subagent_type, + model: member.model, + }))).toEqual([ + { name: "member-1", subagent_type: "member-1-agent", model: { providerID: "openai", modelID: "gpt-5.4-mini" } }, + { name: "member-2", subagent_type: "member-2-agent", model: { providerID: "openai", modelID: "gpt-5.4-mini" } }, + { name: "member-3", subagent_type: "member-3-agent", model: { providerID: "openai", modelID: "gpt-5.4-mini" } }, + ]) + }) + + test("member prompt only documents member-safe communication tools", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-member-prompt-")) + temporaryDirectories.push(baseDir) + const { manager, launchMock } = createManager(baseDir, async () => ({ + id: "task-1", + sessionID: "session-1", + status: "running", + } as BackgroundTask)) + + // when + await createTeamRun(createSpec(1), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager) + const firstPrompt = (launchMock.mock.calls as Array<[LaunchInput]>)[0]?.[0].prompt ?? "" + + // then + expect(firstPrompt).toContain("Do not call lead-only lifecycle tools") + expect(firstPrompt).not.toContain("3. Request shutdown via `team_shutdown_request`") + expect(firstPrompt).toContain("Include `summary` and `references`") + expect(firstPrompt).toContain("Move to `status: \"in_progress\"` when you start working") + expect(firstPrompt).toContain("delegate-task: Do not call this") + expect(firstPrompt).toContain("lead can decide whether to request shutdown") + }) + + test("rolls back launched members in reverse order when a later spawn fails", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-rollback-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager, cancelTaskMock } = createManager(baseDir, async () => { + launchCount += 1 + if (launchCount === 4) throw new Error("launch-4 failed") + return { id: `task-${launchCount}`, sessionID: `session-${launchCount}`, status: "running" } as BackgroundTask + }) + + // when + const result = createTeamRun(createSpec(4), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager) + + // then + try { + await result + throw new Error("expected createTeamRun to reject") + } catch (error) { + expect(error).toBeInstanceOf(TeamRunCreateError) + } + expect((cancelTaskMock.mock.calls as Array<[string]>).map(([taskId]) => taskId)).toEqual(["task-3", "task-2", "task-1"]) + expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed") + }) + + test("removes all created worktrees when spawn fails after worktree creation", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-worktree-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager } = createManager(baseDir, async () => { + launchCount += 1 + if (launchCount === 2) throw new Error("launch-2 failed") + return { id: `task-${launchCount}`, sessionID: `session-${launchCount}`, status: "running" } as BackgroundTask + }) + const spec = createSpec(2, true) + + // when + try { + await createTeamRun(spec, "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager) + throw new Error("expected createTeamRun to reject") + } catch (error) { + expect(error).toBeInstanceOf(TeamRunCreateError) + } + + // then + expect(await pathExists(path.resolve(baseDir, "./worktrees/member-1"))).toBe(false) + expect(await pathExists(path.resolve(baseDir, "./worktrees/member-2"))).toBe(false) + }) + + test("returns the existing runtime on repeated calls with the same spec and lead session", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-idempotent-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager, launchMock } = createManager(baseDir, async () => ({ id: `task-${++launchCount}`, sessionID: `session-${launchCount}`, status: "running" } as BackgroundTask)) + const spec = createSpec(2) + const context = createContext(baseDir, manager) + + // when + const firstRuntime = await createTeamRun(spec, "lead-session", context, createConfig(baseDir), manager) + const secondRuntime = await createTeamRun(spec, "lead-session", context, createConfig(baseDir), manager) + + // then + expect(firstRuntime.teamRunId).toBe(secondRuntime.teamRunId) + expect(launchMock).toHaveBeenCalledTimes(2) + }) + + test("never exceeds max_parallel_members while spawning", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-parallel-")) + temporaryDirectories.push(baseDir) + let inFlight = 0 + let maxInFlight = 0 + let launchCount = 0 + const { manager } = createManager(baseDir, async () => { + launchCount += 1 + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 10)) + inFlight -= 1 + return { id: `task-${launchCount}`, sessionID: `session-${launchCount}`, status: "running" } as BackgroundTask + }) + + // when + await createTeamRun(createSpec(8), "lead-session", createContext(baseDir, manager), createConfig(baseDir, 4), manager) + + // then + expect(maxInFlight).toBeLessThanOrEqual(4) + }) + + test("reuses the caller session for the lead when the lead matches the caller agent", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-caller-lead-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager, launchMock } = createManager(baseDir, async (input) => ({ + id: `task-${++launchCount}`, + sessionID: `${input.agent}-session-${launchCount}`, + status: "running", + } as BackgroundTask)) + const spec: TeamSpec = { + version: 1, + name: "alpha-team", + createdAt: Date.now(), + leadAgentId: "lead", + members: [ + { kind: "subagent_type", name: "lead", subagent_type: "sisyphus", backendType: "in-process", isActive: true }, + { kind: "category", name: "member-1", category: "quick", prompt: "prompt-1", backendType: "in-process", isActive: true }, + ], + } + + // when + const runtimeState = await createTeamRun( + spec, + "lead-session", + createContext(baseDir, manager), + createConfig(baseDir), + manager, + undefined, + { callerAgentTypeId: "sisyphus" }, + ) + + // then + expect(launchMock).toHaveBeenCalledTimes(1) + expect(launchMock.mock.calls[0]?.[0]).toMatchObject({ description: "Create team member alpha-team/member-1" }) + expect(resolveMemberMock).toHaveBeenCalledTimes(1) + expect(resolveMemberMock.mock.calls[0]?.[0]).toMatchObject({ name: "member-1" }) + expect(runtimeState.members.map((member) => ({ name: member.name, sessionId: member.sessionId }))).toEqual([ + { name: "lead", sessionId: "lead-session" }, + { name: "member-1", sessionId: "member-1-agent-session-1" }, + ]) + }) + + test("persists the reused caller lead's subagent_type so live deliveries can pin it", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-caller-lead-pin-")) + temporaryDirectories.push(baseDir) + const { manager } = createManager(baseDir, async (input) => ({ + id: `task-${input.agent}`, + sessionID: `${input.agent}-session`, + status: "running", + } as BackgroundTask)) + const spec: TeamSpec = { + version: 1, + name: "alpha-team", + createdAt: Date.now(), + leadAgentId: "lead", + members: [ + { kind: "subagent_type", name: "lead", subagent_type: "sisyphus", backendType: "in-process", isActive: true }, + { kind: "category", name: "worker", category: "quick", prompt: "work hard", backendType: "in-process", isActive: true }, + ], + } + + // when + const runtimeState = await createTeamRun( + spec, + "ses_caller_sisyphus", + createContext(baseDir, manager), + createConfig(baseDir), + manager, + undefined, + { callerAgentTypeId: "sisyphus" }, + ) + + // then + const leadMember = runtimeState.members.find((member) => member.name === "lead") + expect(leadMember?.sessionId).toBe("ses_caller_sisyphus") + expect(leadMember?.subagent_type).toBe("sisyphus") + expect(leadMember?.model).toBeUndefined() + }) + + test("reuses the caller session for the lead even when the lead subagent_type differs", async () => { + // given + const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-explicit-lead-")) + temporaryDirectories.push(baseDir) + let launchCount = 0 + const { manager, launchMock } = createManager(baseDir, async (input) => ({ + id: `task-${++launchCount}`, + sessionID: `${input.agent}-session-${launchCount}`, + status: "running", + } as BackgroundTask)) + const spec: TeamSpec = { + version: 1, + name: "alpha-team", + createdAt: Date.now(), + leadAgentId: "captain", + members: [ + { kind: "subagent_type", name: "captain", subagent_type: "atlas", backendType: "in-process", isActive: true }, + { kind: "category", name: "member-1", category: "quick", prompt: "prompt-1", backendType: "in-process", isActive: true }, + ], + } + + // when + const runtimeState = await createTeamRun( + spec, + "lead-session", + createContext(baseDir, manager), + createConfig(baseDir), + manager, + undefined, + { callerAgentTypeId: "sisyphus" }, + ) + + // then + expect(launchMock).toHaveBeenCalledTimes(1) + expect(launchMock.mock.calls.map(([input]) => input.description)).toEqual([ + "Create team member alpha-team/member-1", + ]) + expect(runtimeState.members.map((member) => ({ name: member.name, sessionId: member.sessionId }))).toEqual([ + { name: "captain", sessionId: "lead-session" }, + { name: "member-1", sessionId: "member-1-agent-session-1" }, + ]) + }) +}) diff --git a/src/features/team-mode/team-runtime/create.ts b/src/features/team-mode/team-runtime/create.ts new file mode 100644 index 000000000..590425105 --- /dev/null +++ b/src/features/team-mode/team-runtime/create.ts @@ -0,0 +1,260 @@ +import { access, mkdir } from "node:fs/promises" +import path from "node:path" + +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import { QUESTION_DENIED_SESSION_PERMISSION } from "../../../shared/question-denied-session-permission" +import type { ExecutorContext } from "../../../tools/delegate-task/executor-types" +import type { BackgroundTask } from "../../background-agent/types" +import type { BackgroundManager } from "../../background-agent/manager" +import type { TmuxSessionManager } from "../../tmux-subagent/manager" +import { ensureBaseDirs, getInboxDir, getTeamSpecPath, resolveBaseDir } from "../team-registry/paths" +import { createRuntimeState, listActiveTeams, loadRuntimeState, transitionRuntimeState } from "../team-state-store/store" +import { registerTeamSession } from "../team-session-registry" +import type { RuntimeState, TeamSpec } from "../types" +import { activateTeamLayout } from "./activate-team-layout" +import { cleanupTeamRunResources } from "./cleanup-team-run-resources" +import { buildTeammateCommunicationAddendum } from "../member-guidance" +import { resolveMember } from "./resolve-member" +import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead" +import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions" + +const SESSION_ID_POLL_MS = 25 + +type SpawnedMemberResource = { + taskId?: string + worktreePath?: string +} + +type CreateTeamRunOptions = { + callerAgentTypeId?: string + parentMessageID?: string +} + +export class TeamRunCreateError extends Error { + constructor( + message: string, + public readonly cleanupReport: { + cancelledTaskIds: string[] + removedLayout: boolean + removedWorktrees: string[] + errors: string[] + }, + cause: Error, + ) { + super(`${message}: ${cause.message}`) + this.name = "TeamRunCreateError" + this.cause = cause + } +} + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +async function pathExists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch { + return false + } +} + +async function resolveSpecSource(spec: TeamSpec, ctx: ExecutorContext, config: TeamModeConfig): Promise<"project" | "user"> { + const baseDir = resolveBaseDir(config) + if (await pathExists(getTeamSpecPath(baseDir, spec.name, "project", ctx.directory))) return "project" + if (await pathExists(getTeamSpecPath(baseDir, spec.name, "user"))) return "user" + return "project" +} + +async function findExistingRuntime(spec: TeamSpec, leadSessionId: string, config: TeamModeConfig): Promise { + for (const candidate of await listActiveTeams(config)) { + if (candidate.teamName !== spec.name || (candidate.status !== "creating" && candidate.status !== "active")) continue + const runtimeState = await loadRuntimeState(candidate.teamRunId, config).catch(() => undefined) + if (runtimeState?.leadSessionId === leadSessionId) return runtimeState + } +} + +async function createMemberWorktree(memberWorktreePath: string, projectRoot: string): Promise { + const absolutePath = path.isAbsolute(memberWorktreePath) ? memberWorktreePath : path.resolve(projectRoot, memberWorktreePath) + await mkdir(absolutePath, { recursive: true }) + return absolutePath +} + +async function waitForTaskSessionId(bgMgr: BackgroundManager, task: BackgroundTask, deadlineAt: number): Promise { + let sessionId = task.sessionID + while (!sessionId) { + if (Date.now() > deadlineAt) throw new Error(`timed out waiting for child session for task ${task.id}`) + const updatedTask = bgMgr.getTask(task.id) + if (updatedTask?.status === "error" || updatedTask?.status === "cancelled" || updatedTask?.status === "interrupt") { + throw new Error(updatedTask.error ?? `task ${task.id} failed before session creation`) + } + sessionId = updatedTask?.sessionID + if (!sessionId) await new Promise((resolve) => setTimeout(resolve, SESSION_ID_POLL_MS)) + } + return sessionId +} + +function buildMemberPrompt( + spec: TeamSpec, + member: TeamSpec["members"][number], + teamRunId: string, + config: TeamModeConfig, + worktreePath?: string, +): string { + const promptLines = [`Team: ${spec.name}`, `TeamRunId: ${teamRunId}`, `Member: ${member.name}`] + if (worktreePath) promptLines.push(`Worktree: ${worktreePath}`) + if (member.prompt) promptLines.push(member.prompt) + promptLines.push(buildTeammateCommunicationAddendum(config)) + return promptLines.join("\n") +} + +export async function createTeamRun( + spec: TeamSpec, + leadSessionId: string, + ctx: ExecutorContext, + config: TeamModeConfig, + bgMgr: BackgroundManager, + tmuxMgr?: TmuxSessionManager, + options?: CreateTeamRunOptions, +): Promise { + const existingRuntime = await findExistingRuntime(spec, leadSessionId, config) + if (existingRuntime) return existingRuntime + + const activeTeams = await listActiveTeams(config) + const activeRunIds = new Set(activeTeams.map((t) => t.teamRunId)) + sweepStaleTeamSessions(activeRunIds).catch(() => {}) + + const baseDir = resolveBaseDir(config) + await ensureBaseDirs(baseDir) + const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId) + let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config) + if (reusesCallerLeadSession && spec.leadAgentId) { + const callerLeadSubagentType = options?.callerAgentTypeId + registerTeamSession(leadSessionId, { + teamRunId: runtimeState.teamRunId, + memberName: spec.leadAgentId, + role: "lead", + }) + runtimeState = await transitionRuntimeState(runtimeState.teamRunId, (currentState) => ({ + ...currentState, + members: currentState.members.map((member) => member.name === spec.leadAgentId + ? { + ...member, + sessionId: leadSessionId, + status: "running", + ...(callerLeadSubagentType ? { subagent_type: callerLeadSubagentType } : {}), + } + : member), + }), config) + } + await Promise.all(spec.members.map((member) => mkdir(getInboxDir(baseDir, runtimeState.teamRunId, member.name), { recursive: true }))) + + const deadlineAt = Date.now() + (config.max_wall_clock_minutes * 60_000) + const resources: SpawnedMemberResource[] = spec.members.map(() => ({})) + let createdLayout = false + + try { + let nextMemberIndex = 0 + let failure: Error | undefined + const workerCount = Math.min(config.max_parallel_members, spec.members.length) + const categoryExamples = Object.keys(ctx.userCategories ?? {}).join(", ") + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (!failure) { + if (Date.now() > deadlineAt) { + failure = new Error("team creation exceeded max_wall_clock_minutes") + return + } + const memberIndex = nextMemberIndex++ + const member = spec.members[memberIndex] + if (!member) return + const resource = resources[memberIndex] + if (!resource) return + + try { + if (member.worktreePath) resource.worktreePath = await createMemberWorktree(member.worktreePath, ctx.directory) + if (reusesCallerLeadSession && member.name === spec.leadAgentId) { + if (resource.worktreePath) { + await transitionRuntimeState(runtimeState.teamRunId, (currentState) => ({ + ...currentState, + members: currentState.members.map((currentMember, currentIndex) => currentIndex === memberIndex + ? { ...currentMember, worktreePath: resource.worktreePath } + : currentMember), + }), config) + } + continue + } + const resolvedMember = await resolveMember(member, ctx, categoryExamples, spec.leadAgentId) + const task = await bgMgr.launch({ + description: `Create team member ${spec.name}/${member.name}`, + prompt: buildMemberPrompt(spec, member, runtimeState.teamRunId, config, resource.worktreePath), + agent: resolvedMember.agentToUse, + parentSessionID: leadSessionId, + parentMessageID: options?.parentMessageID ?? `team-create:${runtimeState.teamRunId}:${member.name}`, + teamRunId: runtimeState.teamRunId, + suppressTmuxSpawn: true, + model: resolvedMember.model, + fallbackChain: resolvedMember.fallbackChain, + skillContent: resolvedMember.systemContent, + category: member.kind === "category" ? member.category : undefined, + sessionPermission: QUESTION_DENIED_SESSION_PERMISSION, + }) + resource.taskId = task.id + const sessionId = await waitForTaskSessionId(bgMgr, task, deadlineAt) + registerTeamSession(sessionId, { + teamRunId: runtimeState.teamRunId, + memberName: member.name, + role: member.name === spec.leadAgentId ? "lead" : "member", + }) + const persistedModel = resolvedMember.model + ? { + providerID: resolvedMember.model.providerID, + modelID: resolvedMember.model.modelID, + ...(resolvedMember.model.variant ? { variant: resolvedMember.model.variant } : {}), + ...(resolvedMember.model.reasoningEffort ? { reasoningEffort: resolvedMember.model.reasoningEffort } : {}), + ...(resolvedMember.model.temperature !== undefined ? { temperature: resolvedMember.model.temperature } : {}), + ...(resolvedMember.model.top_p !== undefined ? { top_p: resolvedMember.model.top_p } : {}), + ...(resolvedMember.model.maxTokens !== undefined ? { maxTokens: resolvedMember.model.maxTokens } : {}), + ...(resolvedMember.model.thinking ? { thinking: resolvedMember.model.thinking } : {}), + } + : undefined + await transitionRuntimeState(runtimeState.teamRunId, (currentState) => ({ + ...currentState, + members: currentState.members.map((currentMember, currentIndex) => currentIndex === memberIndex + ? { + ...currentMember, + sessionId, + status: "running", + worktreePath: resource.worktreePath, + subagent_type: resolvedMember.agentToUse, + ...(member.kind === "category" ? { category: member.category } : {}), + ...(persistedModel ? { model: persistedModel } : {}), + } + : currentMember), + }), config) + } catch (error) { + failure = normalizeError(error) + return + } + } + })) + + if (failure) throw failure + + const launchedRuntimeState = await loadRuntimeState(runtimeState.teamRunId, config) + createdLayout = await activateTeamLayout(launchedRuntimeState, config, ctx.directory, tmuxMgr) + + return await transitionRuntimeState(runtimeState.teamRunId, (currentState) => ({ ...currentState, status: "active" }), config) + } catch (error) { + const cleanupReport = await cleanupTeamRunResources({ + teamRunId: runtimeState.teamRunId, + config, + resources, + bgMgr, + tmuxMgr, + createdLayout, + }) + throw new TeamRunCreateError(`Failed to create team run '${spec.name}'`, cleanupReport, normalizeError(error)) + } +}