From b74881879520a81f9ff9877088e25d7ab51a419b Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 6 Jun 2026 21:03:47 +0300 Subject: [PATCH] feat: support opencode worktree root lanes --- .../__tests__/planTeamRuntimeLanes.test.ts | 84 +++ .../core/domain/planTeamRuntimeLanes.ts | 59 +- src/features/team-runtime-lanes/index.ts | 3 + .../createTeamRuntimeLaneCoordinator.test.ts | 2 +- .../createTeamRuntimeLaneCoordinator.ts | 13 +- .../services/team/TeamProvisioningService.ts | 660 ++++++++++++++++-- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 180 +++++ .../team/TeamProvisioningService.test.ts | 148 +++- 8 files changed, 1055 insertions(+), 94 deletions(-) diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts index 272f28b0..a0729e80 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts @@ -112,6 +112,90 @@ describe('planTeamRuntimeLanes', () => { }); }); + it('creates worktree-root OpenCode lanes for pure OpenCode teams with isolated members', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'opencode', + baseCwd: '/repo', + members: [ + { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + cwd: '/repo/.worktrees/bob', + }, + { + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + cwd: '/repo/.worktrees/tom', + }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + plan: { + mode: 'pure_opencode_worktree_root_lanes', + primaryMembers: [], + sideLanes: [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + cwd: '/repo/.worktrees/bob', + }), + }, + { + laneId: 'secondary:opencode:tom', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + cwd: '/repo/.worktrees/tom', + }), + }, + ], + }, + }); + }); + + it('keeps base-cwd OpenCode members on primary and isolated members on worktree lanes', () => { + const result = planTeamRuntimeLanes({ + leadProviderId: 'opencode', + baseCwd: '/repo', + members: [ + { name: 'lead-dev', providerId: 'opencode', model: 'big-pickle' }, + { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + cwd: '/repo/.worktrees/bob', + }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + plan: { + mode: 'pure_opencode_worktree_root_lanes', + primaryMembers: [expect.objectContaining({ name: 'lead-dev', providerId: 'opencode' })], + sideLanes: [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + cwd: '/repo/.worktrees/bob', + }), + }, + ], + }, + }); + }); + it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => { const result = planTeamRuntimeLanes({ leadProviderId: 'anthropic', diff --git a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts index 63b1e011..8581cd0d 100644 --- a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts +++ b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts @@ -44,6 +44,16 @@ export type TeamRuntimeLanePlan = allMembers: PlannedRuntimeMember[]; sideLanes: []; } + | { + mode: 'pure_opencode_worktree_root_lanes'; + primaryMembers: PlannedRuntimeMember[]; + allMembers: PlannedRuntimeMember[]; + sideLanes: { + laneId: string; + providerId: 'opencode'; + member: PlannedRuntimeMember; + }[]; + } | { mode: 'mixed_opencode_side_lanes'; primaryMembers: PlannedRuntimeMember[]; @@ -111,9 +121,16 @@ export function buildPlannedMemberLaneIdentity(params: { }; } +export function buildOpenCodeSecondaryLaneId( + member: Pick +): string { + return `secondary:opencode:${member.name.trim()}`; +} + export function planTeamRuntimeLanes(params: { leadProviderId?: TeamProviderId; members: readonly RuntimeLanePlannerMemberInput[]; + baseCwd?: string; }): TeamRuntimeLanePlanResult { const leadProviderId = normalizeLeadProviderId(params.leadProviderId); const allMembers = normalizePlannedMembers(params.members, leadProviderId); @@ -129,6 +146,27 @@ export function planTeamRuntimeLanes(params: { 'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic or Codex when you mix OpenCode with other providers.', }; } + const normalizedBaseCwd = params.baseCwd?.trim(); + const worktreeRootMembers = allMembers.filter((member) => { + const memberCwd = member.cwd?.trim(); + return Boolean(memberCwd && (!normalizedBaseCwd || memberCwd !== normalizedBaseCwd)); + }); + if (worktreeRootMembers.length > 0 && allMembers.length > 1) { + const worktreeRootMemberNames = new Set(worktreeRootMembers.map((member) => member.name)); + return { + ok: true, + plan: { + mode: 'pure_opencode_worktree_root_lanes', + primaryMembers: allMembers.filter((member) => !worktreeRootMemberNames.has(member.name)), + allMembers, + sideLanes: worktreeRootMembers.map((member) => ({ + laneId: buildOpenCodeSecondaryLaneId(member), + providerId: 'opencode', + member, + })), + }, + }; + } return { ok: true, plan: { @@ -175,18 +213,37 @@ export function isMixedOpenCodeSideLanePlan( return plan.mode === 'mixed_opencode_side_lanes'; } +export function isOpenCodeSideLanePlan( + plan: TeamRuntimeLanePlan +): plan is Extract< + TeamRuntimeLanePlan, + { mode: 'mixed_opencode_side_lanes' | 'pure_opencode_worktree_root_lanes' } +> { + return ( + plan.mode === 'mixed_opencode_side_lanes' || plan.mode === 'pure_opencode_worktree_root_lanes' + ); +} + export function isPureOpenCodeLanePlan( plan: TeamRuntimeLanePlan ): plan is Extract { return plan.mode === 'pure_opencode'; } +export function isPureOpenCodeWorktreeRootLanePlan( + plan: TeamRuntimeLanePlan +): plan is Extract { + return plan.mode === 'pure_opencode_worktree_root_lanes'; +} + export function fromProvisioningMembers( leadProviderId: TeamProviderId | undefined, - members: readonly TeamProvisioningMemberInput[] + members: readonly TeamProvisioningMemberInput[], + options: { baseCwd?: string } = {} ): TeamRuntimeLanePlanResult { return planTeamRuntimeLanes({ leadProviderId, + baseCwd: options.baseCwd, members: members.map((member) => ({ name: member.name, role: member.role, diff --git a/src/features/team-runtime-lanes/index.ts b/src/features/team-runtime-lanes/index.ts index e8ae757b..bdd3e8da 100644 --- a/src/features/team-runtime-lanes/index.ts +++ b/src/features/team-runtime-lanes/index.ts @@ -9,9 +9,12 @@ export type { TeamRuntimeLanePlanSuccess, } from './core/domain/planTeamRuntimeLanes'; export { + buildOpenCodeSecondaryLaneId, buildPlannedMemberLaneIdentity, fromProvisioningMembers, isMixedOpenCodeSideLanePlan, + isOpenCodeSideLanePlan, isPureOpenCodeLanePlan, + isPureOpenCodeWorktreeRootLanePlan, planTeamRuntimeLanes, } from './core/domain/planTeamRuntimeLanes'; diff --git a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts index a5875ba9..c09ddfd6 100644 --- a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts +++ b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts @@ -45,7 +45,7 @@ describe('createTeamRuntimeLaneCoordinator', () => { { name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' }, ], }) - ).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter'); + ).toThrow('OpenCode side lanes require the OpenCode runtime adapter'); }); it('drops stale hard-failure reasons when secondary OpenCode evidence later confirms alive', () => { diff --git a/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts b/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts index 3b05e370..bb51019e 100644 --- a/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts +++ b/src/features/team-runtime-lanes/main/composition/createTeamRuntimeLaneCoordinator.ts @@ -1,7 +1,7 @@ import { buildMixedPersistedLaunchSnapshot } from '@features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot'; import { fromProvisioningMembers, - isMixedOpenCodeSideLanePlan, + isOpenCodeSideLanePlan, type TeamRuntimeLanePlan, } from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; @@ -11,6 +11,7 @@ export interface TeamRuntimeLaneCoordinator { planProvisioningMembers(params: { leadProviderId?: TeamProviderId; members: TeamCreateRequest['members']; + baseCwd?: string; hasOpenCodeRuntimeAdapter: boolean; }): TeamRuntimeLanePlan; buildAggregateLaunchSnapshot( @@ -22,13 +23,15 @@ export interface TeamRuntimeLaneCoordinator { export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator { return { planProvisioningMembers(params) { - const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members); + const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members, { + baseCwd: params.baseCwd, + }); if (!lanePlan.ok) { throw new Error(lanePlan.message); } - if (isMixedOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) { + if (isOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) { throw new Error( - 'Mixed teams with OpenCode side lanes require the OpenCode runtime adapter to be registered.' + 'OpenCode side lanes require the OpenCode runtime adapter to be registered.' ); } return lanePlan.plan; @@ -37,7 +40,7 @@ export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator { return buildMixedPersistedLaunchSnapshot(params); }, isMixedSideLanePlan(plan) { - return isMixedOpenCodeSideLanePlan(plan); + return isOpenCodeSideLanePlan(plan); }, }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0402addd..4297d177 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -17,8 +17,10 @@ import { resolveCodexRuntimeSelection, } from '@features/codex-runtime-profile/main'; import { + buildOpenCodeSecondaryLaneId, buildPlannedMemberLaneIdentity, - isMixedOpenCodeSideLanePlan, + isOpenCodeSideLanePlan, + isPureOpenCodeWorktreeRootLanePlan, type TeamRuntimeLanePlan, } from '@features/team-runtime-lanes'; import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main'; @@ -4042,6 +4044,26 @@ export class TeamProvisioningService { const canonicalMemberName = metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; + const secondaryRuntimeRun = this.getSecondaryRuntimeRuns(teamName).find((run) => + matchesTeamMemberIdentity(run.memberName, canonicalMemberName) + ); + if (secondaryRuntimeRun) { + const memberRuntimeCwd = + secondaryRuntimeRun.cwd?.trim() || metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: secondaryRuntimeRun.laneId, + laneIdentity: { + laneId: secondaryRuntimeRun.laneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); if (runtimeRun?.providerId === 'opencode') { const laneIdentity = buildPlannedMemberLaneIdentity({ @@ -9865,11 +9887,13 @@ export class TeamProvisioningService { private planRuntimeLanesOrThrow( leadProviderId: TeamProviderId | undefined, - members: TeamCreateRequest['members'] + members: TeamCreateRequest['members'], + baseCwd?: string ): TeamRuntimeLanePlan { return this.runtimeLaneCoordinator.planProvisioningMembers({ leadProviderId, members, + baseCwd, hasOpenCodeRuntimeAdapter: this.getOpenCodeRuntimeAdapter() !== null, }); } @@ -9877,7 +9901,7 @@ export class TeamProvisioningService { private createMixedSecondaryLaneStates( plan: TeamRuntimeLanePlan ): MixedSecondaryRuntimeLaneState[] { - if (!isMixedOpenCodeSideLanePlan(plan)) { + if (!isOpenCodeSideLanePlan(plan)) { return []; } return plan.sideLanes.map((sideLane) => ({ @@ -9895,11 +9919,42 @@ export class TeamProvisioningService { } private createMixedSecondaryLaneStateForMember( - run: Pick, + run: Pick, member: TeamCreateRequest['members'][number] ): MixedSecondaryRuntimeLaneState { + const leadProviderId = resolveTeamProviderId(run.request.providerId); + const existingLane = (run.mixedSecondaryLanes ?? []).find((lane) => + matchesTeamMemberIdentity(lane.member.name, member.name) + ); + if (leadProviderId === 'opencode') { + const memberCwd = member.cwd?.trim(); + const baseCwd = run.request.cwd?.trim(); + const laneId = + existingLane?.laneId ?? + (memberCwd && (!baseCwd || memberCwd !== baseCwd) + ? buildOpenCodeSecondaryLaneId(member) + : null); + if (!laneId) { + throw new Error( + `Member "${member.name}" is not eligible for an OpenCode secondary runtime lane` + ); + } + return { + laneId, + providerId: 'opencode', + member: { + ...member, + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + }; + } + const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId: resolveTeamProviderId(run.request.providerId), + leadProviderId, member: { name: member.name, providerId: normalizeOptionalTeamProviderId(member.providerId), @@ -17072,9 +17127,11 @@ export class TeamProvisioningService { ): Promise { const teamName = run.teamName; const leadProviderId = resolveTeamProviderId(run.request.providerId); - if (leadProviderId === 'opencode') { + const isOpenCodeAggregateRun = + leadProviderId === 'opencode' && (run.mixedSecondaryLanes?.length ?? 0) > 0; + if (leadProviderId === 'opencode' && !isOpenCodeAggregateRun) { throw new Error( - 'Retrying OpenCode secondary lanes is only supported for mixed teams with a non-OpenCode lead.' + 'Retrying OpenCode secondary lanes requires an active OpenCode worktree lane run.' ); } if (!this.getOpenCodeRuntimeAdapter()) { @@ -17131,35 +17188,49 @@ export class TeamProvisioningService { if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { continue; } - if (normalizeOptionalTeamProviderId(configuredMember.providerId) !== 'opencode') { + const desiredProviderId = + normalizeOptionalTeamProviderId(configuredMember.providerId) ?? leadProviderId; + if (desiredProviderId !== 'opencode') { continue; } - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: memberName, - providerId: 'opencode', - }, - }); - if ( - laneIdentity.laneKind !== 'secondary' || - laneIdentity.laneOwnerProviderId !== 'opencode' - ) { - continue; - } - - const existingLane = (run.mixedSecondaryLanes ?? []).find( - (lane) => - lane.laneId === laneIdentity.laneId || - matchesTeamMemberIdentity(lane.member.name, memberName) + const existingLane = (run.mixedSecondaryLanes ?? []).find((lane) => + matchesTeamMemberIdentity(lane.member.name, memberName) ); const liveEntry = run.memberSpawnStatuses.get(memberName); - const persistedMember = + const persistedMemberByName = persistedSnapshot?.members[memberName] ?? - Object.values(persistedSnapshot?.members ?? {}).find( - (member) => member.laneId === laneIdentity.laneId + Object.values(persistedSnapshot?.members ?? {}).find((member) => + matchesTeamMemberIdentity(member.name, memberName) ); + let laneId: string | null = null; + if (leadProviderId === 'opencode') { + const persistedLaneId = persistedMemberByName?.laneId?.startsWith('secondary:opencode:') + ? persistedMemberByName.laneId + : null; + laneId = existingLane?.laneId ?? persistedLaneId; + if (!laneId) { + continue; + } + } else { + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: memberName, + providerId: 'opencode', + }, + }); + if ( + laneIdentity.laneKind !== 'secondary' || + laneIdentity.laneOwnerProviderId !== 'opencode' + ) { + continue; + } + laneId = laneIdentity.laneId; + } + const persistedMember = + persistedMemberByName ?? + Object.values(persistedSnapshot?.members ?? {}).find((member) => member.laneId === laneId); if ( this.isRetryableFailedOpenCodeSecondaryLane({ @@ -17168,7 +17239,7 @@ export class TeamProvisioningService { existingLane, }) ) { - candidates.push({ memberName, laneId: laneIdentity.laneId }); + candidates.push({ memberName, laneId }); } } return candidates; @@ -17502,9 +17573,9 @@ export class TeamProvisioningService { ): Promise { const run = this.getMutableAliveRunOrThrow(teamName); const leadProviderId = resolveTeamProviderId(run.request.providerId); - if (leadProviderId === 'opencode') { + if (leadProviderId === 'opencode' && (run.mixedSecondaryLanes?.length ?? 0) === 0) { throw new Error( - 'OpenCode-led mixed teams are not supported in this phase. Stop the team and relaunch with a non-OpenCode lead.' + 'OpenCode secondary lane reattach requires an active OpenCode worktree lane run.' ); } if (!this.getOpenCodeRuntimeAdapter()) { @@ -17535,7 +17606,8 @@ export class TeamProvisioningService { if (isLeadMember({ name: configuredMember.name, agentType: configuredMember.agentType })) { throw new Error('Lead lane reattach is not supported'); } - const desiredProviderId = normalizeOptionalTeamProviderId(configuredMember.providerId); + const desiredProviderId = + normalizeOptionalTeamProviderId(configuredMember.providerId) ?? leadProviderId; if (desiredProviderId !== 'opencode') { throw new Error( `Controlled reattach is only supported for OpenCode-owned members. "${memberName}" remains on the primary runtime owner.` @@ -19779,7 +19851,7 @@ export class TeamProvisioningService { return memberCwds[0]; } throw new Error( - 'OpenCode runtime lanes support exactly one project path in this release. Use mixed-team OpenCode side lanes for per-teammate worktree isolation.' + 'OpenCode runtime lanes support exactly one project path per lane. Use separate OpenCode worktree-root lanes for per-teammate worktree isolation.' ); } @@ -19902,18 +19974,6 @@ export class TeamProvisioningService { return params.members; } - if ( - isPureOpenCodeProvisioningRequest({ - providerId: params.leadProviderId, - members: params.members, - }) && - params.members.length > 1 - ) { - throw new Error( - 'OpenCode worktree isolation currently supports mixed-team OpenCode side lanes or one-member OpenCode runtime lanes. Multiple OpenCode members in one lane cannot use separate worktrees yet.' - ); - } - const nextMembers: TeamCreateRequest['members'] = []; for (const member of params.members) { const providerId = normalizeTeamMemberProviderId(member.providerId); @@ -20976,7 +21036,11 @@ export class TeamProvisioningService { allEffectiveMemberSpecs ) ); - const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); + const lanePlan = this.planRuntimeLanesOrThrow( + request.providerId, + allEffectiveMemberSpecs, + request.cwd + ); const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => primaryMemberNames.has(member.name) @@ -21555,6 +21619,21 @@ export class TeamProvisioningService { providerBackendId: launchRequest.providerBackendId, }); await this.writeOpenCodeTeamConfig(launchRequest, effectiveMembers); + const lanePlan = this.planRuntimeLanesOrThrow( + launchRequest.providerId, + effectiveMembers, + launchRequest.cwd + ); + if (isPureOpenCodeWorktreeRootLanePlan(lanePlan)) { + return this.runOpenCodeWorktreeRootAggregateLaunch({ + request: launchRequest, + members: effectiveMembers, + lanePlan, + prompt: launchRequest.prompt?.trim() ?? '', + sourceWarning: undefined, + onProgress, + }); + } return this.runOpenCodeTeamRuntimeAdapterLaunch({ request: launchRequest, @@ -21610,6 +21689,21 @@ export class TeamProvisioningService { existingTasks, false ); + const lanePlan = this.planRuntimeLanesOrThrow( + launchRequest.providerId, + effectiveMembers, + launchRequest.cwd + ); + if (isPureOpenCodeWorktreeRootLanePlan(lanePlan)) { + return this.runOpenCodeWorktreeRootAggregateLaunch({ + request: launchRequest, + members: effectiveMembers, + lanePlan, + prompt, + sourceWarning: warning, + onProgress, + }); + } return this.runOpenCodeTeamRuntimeAdapterLaunch({ request: launchRequest, @@ -21620,6 +21714,425 @@ export class TeamProvisioningService { }); } + private createOpenCodeAggregateProvisioningRun(params: { + runId: string; + startedAt: string; + progress: TeamProvisioningProgress; + request: TeamCreateRequest | TeamLaunchRequest; + members: TeamCreateRequest['members']; + lanePlan: Extract; + onProgress: (progress: TeamProvisioningProgress) => void; + }): ProvisioningRun { + return { + runId: params.runId, + teamName: params.request.teamName, + startedAt: params.startedAt, + progress: params.progress, + stdoutBuffer: '', + stderrBuffer: '', + claudeLogLines: [], + lastClaudeLogStream: null, + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + stdoutParserCarry: '', + stdoutParserCarryIsCompleteJson: false, + stdoutParserCarryLooksLikeClaudeJson: false, + deterministicBootstrapMemberSpawnSeen: false, + deterministicBootstrapMemberResultSeen: false, + processKilled: false, + finalizingByTimeout: false, + cancelRequested: false, + teamsBasePathsToProbe: getTeamsBasePathsToProbe(), + child: null, + timeoutHandle: null, + fsMonitorHandle: null, + onProgress: params.onProgress, + expectedMembers: params.members.map((member) => member.name), + request: { + ...params.request, + members: params.members, + } as TeamCreateRequest, + allEffectiveMembers: params.members, + effectiveMembers: params.lanePlan.primaryMembers, + launchIdentity: null, + mixedSecondaryLanes: this.createMixedSecondaryLaneStates(params.lanePlan), + lastLogProgressAt: 0, + lastDataReceivedAt: 0, + lastStdoutReceivedAt: 0, + stallCheckHandle: null, + stallWarningIndex: null, + preStallMessage: null, + lastRetryAt: 0, + apiRetryWarningIndex: null, + apiErrorWarningEmitted: false, + fsPhase: 'all_files_found', + waitingTasksSince: null, + provisioningComplete: false, + processClosed: false, + requiresFirstRealTurnSuccess: false, + firstRealTurnSucceeded: false, + mcpConfigPath: null, + memberMcpConfigPaths: [], + bootstrapSpecPath: null, + bootstrapUserPromptPath: null, + isLaunch: true, + launchStateClearedForRun: false, + deterministicBootstrap: false, + workspaceTrustPlan: null, + workspaceTrustExecution: null, + workspaceTrustDiagnostics: null, + workspaceTrustRetryAttempted: false, + leadRelayCapture: null, + activeCrossTeamReplyHints: [], + leadMsgSeq: 0, + liveLeadTextBuffer: null, + pendingToolCalls: [], + activeToolCalls: new Map(), + pendingDirectCrossTeamSendRefresh: false, + lastLeadTextEmitMs: 0, + silentUserDmForward: null, + silentUserDmForwardClearHandle: null, + pendingInboxRelayCandidates: [], + provisioningOutputParts: [], + provisioningTraceLines: [], + lastProvisioningTraceKey: null, + provisioningOutputIndexByMessageId: new Map(), + detectedSessionId: null, + leadActivityState: 'active', + authFailureRetried: false, + authRetryInProgress: false, + leadContextUsage: null, + spawnContext: null, + anthropicApiKeyHelper: null, + pendingApprovals: new Map(), + processedPermissionRequestIds: new Set(), + pendingPostCompactReminder: false, + postCompactReminderInFlight: false, + suppressPostCompactReminderOutput: false, + pendingGeminiPostLaunchHydration: false, + geminiPostLaunchHydrationInFlight: false, + geminiPostLaunchHydrationSent: false, + suppressGeminiPostLaunchHydrationOutput: false, + memberSpawnStatuses: new Map(), + memberSpawnToolUseIds: new Map(), + pendingMemberRestarts: new Map(), + memberSpawnLeadInboxCursorByMember: new Map(), + lastDeterministicBootstrapSeq: 0, + lastMemberSpawnAuditAt: 0, + lastMemberSpawnAuditConfigReadWarningAt: 0, + lastMemberSpawnAuditMissingWarningAt: new Map(), + }; + } + + private async launchOpenCodeAggregatePrimaryLane(params: { + run: ProvisioningRun; + adapter: TeamLaunchRuntimeAdapter; + prompt: string; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): Promise { + if (params.run.effectiveMembers.length === 0) { + return null; + } + + const teamName = params.run.teamName; + const runId = params.run.runId; + const launchCwd = this.getOpenCodeRuntimeLaunchCwd( + params.run.request.cwd, + params.run.effectiveMembers + ); + const migration = await migrateLegacyOpenCodeRuntimeState({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + state: migration.degraded ? 'degraded' : 'active', + diagnostics: migration.diagnostics, + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + runId, + }); + + const expectedMembers: TeamRuntimeMemberSpec[] = params.run.effectiveMembers.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: 'opencode', + model: member.model ?? params.run.request.model, + effort: member.effort ?? params.run.request.effort, + cwd: member.cwd?.trim() || launchCwd, + })); + const launchInput: TeamRuntimeLaunchInput = { + runId, + laneId: 'primary', + teamName, + cwd: launchCwd, + prompt: params.prompt, + providerId: 'opencode', + model: params.run.request.model, + effort: params.run.request.effort, + skipPermissions: params.run.request.skipPermissions !== false, + expectedMembers, + previousLaunchState: params.previousLaunchState, + }; + const launchResult = await params.adapter.launch(launchInput); + const { snapshot, result } = await this.persistOpenCodeRuntimeAdapterLaunchResult( + launchResult, + launchInput + ); + const snapshotStatuses = snapshotToMemberSpawnStatuses(snapshot); + for (const member of expectedMembers) { + const status = snapshotStatuses[member.name]; + if (status) { + params.run.memberSpawnStatuses.set(member.name, status); + } + } + this.syncOpenCodeRuntimeToolApprovals({ + teamName, + runId, + laneId: 'primary', + cwd: launchCwd, + members: result.members, + expectedMembers, + teamColor: params.run.request.color, + teamDisplayName: params.run.request.displayName, + }); + if (result.teamLaunchState !== 'partial_failure') { + this.runtimeAdapterRunByTeam.set(teamName, { + runId, + providerId: 'opencode', + cwd: launchCwd, + members: result.members, + }); + } + return result; + } + + private summarizeOpenCodeAggregateLaunchState(input: { + primaryResult: TeamRuntimeLaunchResult | null; + lanes: readonly MixedSecondaryRuntimeLaneState[]; + }): TeamRuntimeLaunchResult['teamLaunchState'] { + const states = [ + input.primaryResult?.teamLaunchState, + ...input.lanes.map((lane) => lane.result?.teamLaunchState), + ].filter((state): state is TeamRuntimeLaunchResult['teamLaunchState'] => Boolean(state)); + if (states.length === 0 || states.some((state) => state === 'partial_failure')) { + return 'partial_failure'; + } + if ( + states.some((state) => state === 'partial_pending') || + input.lanes.some((lane) => !lane.result) + ) { + return 'partial_pending'; + } + return 'clean_success'; + } + + private async runOpenCodeWorktreeRootAggregateLaunch(input: { + request: TeamCreateRequest | TeamLaunchRequest; + members: TeamCreateRequest['members']; + lanePlan: Extract; + prompt: string; + sourceWarning?: string; + onProgress: (progress: TeamProvisioningProgress) => void; + }): Promise { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + throw new Error('OpenCode runtime adapter is not registered'); + } + + const stopAllGenerationAtStart = this.stopAllTeamsGeneration; + const previousRuntimeRun = this.runtimeAdapterRunByTeam.get(input.request.teamName); + if (previousRuntimeRun?.providerId === 'opencode') { + await this.stopOpenCodeRuntimeAdapterTeam(input.request.teamName, previousRuntimeRun.runId); + } + if (this.hasSecondaryRuntimeRuns(input.request.teamName)) { + await this.stopMixedSecondaryRuntimeLanes(input.request.teamName); + } + const previousPendingRunId = this.provisioningRunByTeam.get(input.request.teamName); + const previousRuntimeProgress = previousPendingRunId + ? this.runtimeAdapterProgressByRunId.get(previousPendingRunId) + : null; + if ( + previousPendingRunId && + previousRuntimeProgress && + this.isCancellableRuntimeAdapterProgress(previousRuntimeProgress) + ) { + await this.cancelRuntimeAdapterProvisioning(previousPendingRunId, previousRuntimeProgress); + } + if (this.stopAllTeamsGeneration !== stopAllGenerationAtStart) { + return this.recordCancelledOpenCodeRuntimeAdapterLaunch( + input.request.teamName, + input.sourceWarning, + input.onProgress + ); + } + + const runId = randomUUID(); + const startedAt = nowIso(); + const initialProgress: TeamProvisioningProgress = { + runId, + teamName: input.request.teamName, + state: 'validating', + message: 'Validating OpenCode worktree lane launch gate', + startedAt, + updatedAt: startedAt, + warnings: input.sourceWarning ? [input.sourceWarning] : undefined, + }; + this.provisioningRunByTeam.set(input.request.teamName, runId); + const initialRuntimeProgress = this.setRuntimeAdapterProgress( + initialProgress, + input.onProgress + ); + this.resetTeamScopedTransientStateForNewRun(input.request.teamName); + const previousLaunchState = await this.launchStateStore.read(input.request.teamName); + await this.clearPersistedLaunchState(input.request.teamName); + + const run = this.createOpenCodeAggregateProvisioningRun({ + runId, + startedAt, + progress: initialRuntimeProgress, + request: input.request, + members: input.members, + lanePlan: input.lanePlan, + onProgress: input.onProgress, + }); + this.runs.set(runId, run); + this.invalidateRuntimeSnapshotCaches(input.request.teamName); + + const launching = this.setRuntimeAdapterProgress( + { + ...initialRuntimeProgress, + state: 'spawning', + message: 'Starting OpenCode worktree runtime lanes', + updatedAt: nowIso(), + }, + input.onProgress + ); + run.progress = launching; + + try { + const primaryResult = await this.launchOpenCodeAggregatePrimaryLane({ + run, + adapter, + prompt: input.prompt, + previousLaunchState, + }); + for (const lane of run.mixedSecondaryLanes) { + if (run.cancelRequested || run.processKilled) { + break; + } + await this.launchSingleMixedSecondaryLane(run, lane); + } + + run.provisioningComplete = true; + const launchState = this.summarizeOpenCodeAggregateLaunchState({ + primaryResult, + lanes: run.mixedSecondaryLanes, + }); + const launchPhase = launchState === 'partial_pending' ? 'active' : 'finished'; + const snapshot = await this.persistLaunchStateSnapshot(run, launchPhase); + if (snapshot) { + this.syncRunMemberSpawnStatusesFromSnapshot(run, snapshot); + } + + const success = launchState === 'clean_success'; + const pending = launchState === 'partial_pending'; + const failed = launchState === 'partial_failure'; + const finalProgress = this.setRuntimeAdapterProgress( + { + ...launching, + state: success || pending ? 'ready' : 'failed', + message: success + ? 'OpenCode worktree lanes are ready' + : pending + ? 'OpenCode worktree lanes are waiting for runtime evidence or permissions' + : 'OpenCode worktree lane launch failed readiness gate', + messageSeverity: pending ? 'warning' : failed ? 'error' : undefined, + updatedAt: nowIso(), + error: failed + ? run.mixedSecondaryLanes + .flatMap((lane) => lane.diagnostics) + .filter(Boolean) + .join('\n') || 'OpenCode worktree lane launch failed' + : undefined, + cliLogsTail: + run.mixedSecondaryLanes.flatMap((lane) => lane.diagnostics).join('\n') || undefined, + configReady: true, + }, + input.onProgress + ); + run.progress = finalProgress; + if (success || pending) { + this.setAliveRunId(input.request.teamName, runId); + } else { + this.deleteAliveRunId(input.request.teamName); + this.runtimeAdapterRunByTeam.delete(input.request.teamName); + } + if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { + this.provisioningRunByTeam.delete(input.request.teamName); + } + this.invalidateRuntimeSnapshotCaches(input.request.teamName); + this.teamChangeEmitter?.({ + type: 'process', + teamName: input.request.teamName, + runId, + detail: finalProgress.state, + }); + return { runId }; + } catch (error) { + if ( + this.cancelledRuntimeAdapterRunIds.delete(runId) || + this.provisioningRunByTeam.get(input.request.teamName) !== runId + ) { + return { runId }; + } + for (const lane of run.mixedSecondaryLanes) { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: lane.laneId, + }).catch(() => undefined); + this.deleteSecondaryRuntimeRun(input.request.teamName, lane.laneId); + } + if (run.effectiveMembers.length > 0) { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: 'primary', + }).catch(() => undefined); + } + const message = error instanceof Error ? error.message : String(error); + const failedProgress = this.setRuntimeAdapterProgress( + { + ...launching, + state: 'failed', + message: 'OpenCode worktree lane launch failed', + messageSeverity: 'error', + updatedAt: nowIso(), + error: message, + cliLogsTail: message, + }, + input.onProgress + ); + run.progress = failedProgress; + if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { + this.provisioningRunByTeam.delete(input.request.teamName); + } + this.runtimeAdapterRunByTeam.delete(input.request.teamName); + this.deleteAliveRunId(input.request.teamName); + this.invalidateRuntimeSnapshotCaches(input.request.teamName); + throw error; + } + } + private async runOpenCodeTeamRuntimeAdapterLaunch(input: { request: TeamCreateRequest | TeamLaunchRequest; members: TeamCreateRequest['members']; @@ -22264,7 +22777,11 @@ export class TeamProvisioningService { allEffectiveMemberSpecs ) ); - const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); + const lanePlan = this.planRuntimeLanesOrThrow( + request.providerId, + allEffectiveMemberSpecs, + request.cwd + ); const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => primaryMemberNames.has(member.name) @@ -25057,6 +25574,9 @@ export class TeamProvisioningService { if (!run && this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId) { return true; } + if (run && this.hasSecondaryRuntimeRuns(teamName)) { + return !run.processKilled && !run.cancelRequested; + } return run?.child != null && !run.processKilled && !run.cancelRequested; } @@ -29933,7 +30453,7 @@ export class TeamProvisioningService { const leadProviderId = normalizeOptionalTeamProviderId(leadLaunchIdentity?.providerId) ?? normalizeOptionalTeamProviderId(teamMeta?.providerId); - if (!leadProviderId || leadProviderId === 'opencode') { + if (!leadProviderId) { return null; } @@ -30011,13 +30531,39 @@ export class TeamProvisioningService { let recoveredAny = false; for (const member of activeMembers) { - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: member.name, - providerId: normalizeOptionalTeamProviderId(member.providerId), - }, - }); + const persistedMember = + persistedSnapshot?.members?.[member.name] ?? bootstrapSnapshot?.members?.[member.name]; + const laneIdentity = + leadProviderId === 'opencode' + ? (() => { + const persistedLaneId = persistedMember?.laneId?.startsWith('secondary:opencode:') + ? persistedMember.laneId + : null; + const generatedLaneId = buildOpenCodeSecondaryLaneId(member); + const memberCwd = member.cwd?.trim(); + const projectRoot = projectPath?.trim(); + const hasWorktreeRoot = + Boolean(memberCwd) && (!projectRoot || memberCwd !== projectRoot); + if (!persistedLaneId && !laneIndex.lanes[generatedLaneId] && !hasWorktreeRoot) { + return { + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: leadProviderId, + } as const; + } + return { + laneId: persistedLaneId ?? generatedLaneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + } as const; + })() + : buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: member.name, + providerId: normalizeOptionalTeamProviderId(member.providerId), + }, + }); if ( laneIdentity.laneKind !== 'secondary' || @@ -30028,8 +30574,6 @@ export class TeamProvisioningService { } let laneEntry = laneIndex.lanes[laneIdentity.laneId]; - const persistedMember = - persistedSnapshot?.members?.[member.name] ?? bootstrapSnapshot?.members?.[member.name]; if ( !laneEntry && persistedMember && diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 82f42194..28497c4c 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -38,6 +38,7 @@ import { } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; +import type { TeamMemberWorktreeManager } from '../../../../src/main/services/team/TeamMemberWorktreeManager'; import { getMixedLaunchFallbackRecoveryError, TeamProvisioningService, @@ -258,6 +259,185 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('launches pure OpenCode worktree members as aggregate worktree-root lanes', async () => { + const teamName = 'pure-opencode-worktree-root-lanes-safe-e2e'; + const bobWorktree = path.join(projectPath, '.agent-teams', 'bob'); + const tomWorktree = path.join(projectPath, '.agent-teams', 'tom'); + const worktreeManager: Pick = { + ensureMemberWorktree: vi.fn(async (input) => ({ + baseRepoPath: projectPath, + worktreePath: input.memberName === 'bob' ? bobWorktree : tomWorktree, + branchName: `agent-teams/${teamName}/${input.memberName}`, + })), + }; + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + worktreeManager + ); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const progressEvents: TeamProvisioningProgress[] = []; + + const { runId } = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + isolation: 'worktree', + }, + { + name: 'tom', + role: 'Reviewer', + providerId: 'opencode', + isolation: 'worktree', + }, + ], + }, + (progress) => progressEvents.push(progress) + ); + + expect(runId).toMatch(/[0-9a-f-]{36}/); + expect(worktreeManager.ensureMemberWorktree).toHaveBeenCalledTimes(2); + expect(adapter.launchInputs.map((input) => input.laneId).sort()).toEqual([ + 'secondary:opencode:bob', + 'secondary:opencode:tom', + ]); + expect(adapter.launchInputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + cwd: bobWorktree, + runtimeOnly: true, + expectedMembers: [ + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + isolation: 'worktree', + cwd: bobWorktree, + }), + ], + }), + expect.objectContaining({ + laneId: 'secondary:opencode:tom', + cwd: tomWorktree, + runtimeOnly: true, + expectedMembers: [ + expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + isolation: 'worktree', + cwd: tomWorktree, + }), + ], + }), + ]) + ); + expect(progressEvents.at(-1)).toMatchObject({ + state: 'ready', + message: 'OpenCode worktree lanes are ready', + }); + expect(svc.getAliveTeams()).toContain(teamName); + + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { + lanes: { + 'secondary:opencode:bob': { state: 'active' }, + 'secondary:opencode:tom': { state: 'active' }, + }, + } + ); + await expect( + readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:bob', + }) + ).resolves.toMatchObject({ + committed: true, + sessions: [expect.objectContaining({ memberName: 'bob' })], + }); + await expect( + readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:tom', + }) + ).resolves.toMatchObject({ + committed: true, + sessions: [expect.objectContaining({ memberName: 'tom' })], + }); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.statuses.bob).toMatchObject({ + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.tom).toMatchObject({ + launchState: 'confirmed_alive', + }); + + const launchCountBeforeRestart = adapter.launchInputs.length; + await svc.restartMember(teamName, 'bob'); + expect(adapter.stopInputs).toEqual([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + teamName, + }), + ]); + expect(adapter.launchInputs).toHaveLength(launchCountBeforeRestart + 1); + expect(adapter.launchInputs.at(-1)).toMatchObject({ + laneId: 'secondary:opencode:bob', + cwd: bobWorktree, + runtimeOnly: true, + expectedMembers: [ + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + isolation: 'worktree', + cwd: bobWorktree, + }), + ], + }); + + const stopCountBeforeRelaunch = adapter.stopInputs.length; + const launchCountBeforeRelaunch = adapter.launchInputs.length; + await svc.launchTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + }, + (progress) => progressEvents.push(progress) + ); + expect( + adapter.stopInputs + .slice(stopCountBeforeRelaunch) + .map((input) => input.laneId) + .sort() + ).toEqual(['secondary:opencode:bob', 'secondary:opencode:tom']); + expect( + adapter.launchInputs + .slice(launchCountBeforeRelaunch) + .map((input) => input.laneId) + .sort() + ).toEqual(['secondary:opencode:bob', 'secondary:opencode:tom']); + }); + it('accepts pure OpenCode runtime bootstrap check-ins during adapter launch', async () => { const svc = new TeamProvisioningService(); const adapter = new BootstrapCheckingOpenCodeRuntimeAdapter(svc); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index a6ab7b2e..2bd04a52 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -19003,10 +19003,61 @@ describe('TeamProvisioningService', () => { await svc.cancelProvisioning(runId); }); - it('rejects multi-member pure OpenCode worktree isolation instead of sharing one projectPath', async () => { + it('launches pure OpenCode worktree members through separate runtime lanes', async () => { allowConsoleLogs(); - const adapterLaunch = vi.fn(); - const { svc } = createSafeLaunchService(); + const bobWorktree = path.join(tempClaudeRoot, 'worktrees', 'bob'); + const worktreeManager = { + ensureMemberWorktree: vi.fn(async () => ({ + baseRepoPath: tempClaudeRoot, + worktreePath: bobWorktree, + branchName: 'agent-teams/test/bob', + })), + }; + const adapterLaunch = vi.fn(async (input: Record) => { + const expectedMembers = input.expectedMembers as Array<{ name: string }>; + const teamName = String(input.teamName); + const laneId = String(input.laneId); + const runId = String(input.runId); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId, + runId, + sessions: expectedMembers.map((member) => ({ + id: `oc-session-${laneId}-${member.name}`, + teamName, + memberName: member.name, + laneId, + runId, + source: 'runtime_bootstrap_checkin', + })), + }); + return { + runId, + teamName, + launchPhase: 'finished', + teamLaunchState: 'clean_success', + members: Object.fromEntries( + expectedMembers.map((member) => [ + member.name, + { + memberName: member.name, + providerId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + diagnostics: [], + }, + ]) + ), + warnings: [], + diagnostics: [], + }; + }); + const { svc } = createSafeLaunchService({ + memberWorktreeManager: worktreeManager, + }); svc.setRuntimeAdapterRegistry( new TeamRuntimeAdapterRegistry([ { @@ -19019,32 +19070,71 @@ describe('TeamProvisioningService', () => { ]) ); - await expect( - svc.createTeam( - { - teamName: 'blocked-opencode-multi-worktree', - cwd: tempClaudeRoot, - providerId: 'opencode', - providerBackendId: 'adapter', - model: 'big-pickle', - members: [ - { - name: 'bob', - providerId: 'opencode', - model: 'minimax-m2.5-free', - isolation: 'worktree', - }, - { - name: 'tom', - providerId: 'opencode', - model: 'nemotron-3-super-free', - }, - ], - }, - () => {} - ) - ).rejects.toThrow('Multiple OpenCode members in one lane cannot use separate worktrees yet'); - expect(adapterLaunch).not.toHaveBeenCalled(); + const { runId } = await svc.createTeam( + { + teamName: 'opencode-multi-worktree-lanes', + cwd: tempClaudeRoot, + providerId: 'opencode', + providerBackendId: 'adapter', + model: 'big-pickle', + members: [ + { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + isolation: 'worktree', + }, + { + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }, + ], + }, + () => {} + ); + + expect(worktreeManager.ensureMemberWorktree).toHaveBeenCalledWith({ + teamName: 'opencode-multi-worktree-lanes', + memberName: 'bob', + baseCwd: tempClaudeRoot, + }); + expect(adapterLaunch).toHaveBeenCalledTimes(2); + expect(adapterLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: 'primary', + cwd: tempClaudeRoot, + expectedMembers: [ + expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + cwd: tempClaudeRoot, + }), + ], + }) + ); + expect(adapterLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + cwd: bobWorktree, + expectedMembers: [ + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + isolation: 'worktree', + cwd: bobWorktree, + }), + ], + }) + ); + const run = (svc as any).runs.get(runId); + expect(run?.mixedSecondaryLanes).toEqual([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + state: 'finished', + member: expect.objectContaining({ name: 'bob', cwd: bobWorktree }), + }), + ]); }); });