Merge branch 'feat/opencode-worktree-root-lanes' into dev

This commit is contained in:
777genius 2026-06-06 21:08:11 +03:00
commit d15a17a1fc
8 changed files with 1055 additions and 94 deletions

View file

@ -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', () => { it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => {
const result = planTeamRuntimeLanes({ const result = planTeamRuntimeLanes({
leadProviderId: 'anthropic', leadProviderId: 'anthropic',

View file

@ -44,6 +44,16 @@ export type TeamRuntimeLanePlan =
allMembers: PlannedRuntimeMember[]; allMembers: PlannedRuntimeMember[];
sideLanes: []; sideLanes: [];
} }
| {
mode: 'pure_opencode_worktree_root_lanes';
primaryMembers: PlannedRuntimeMember[];
allMembers: PlannedRuntimeMember[];
sideLanes: {
laneId: string;
providerId: 'opencode';
member: PlannedRuntimeMember;
}[];
}
| { | {
mode: 'mixed_opencode_side_lanes'; mode: 'mixed_opencode_side_lanes';
primaryMembers: PlannedRuntimeMember[]; primaryMembers: PlannedRuntimeMember[];
@ -111,9 +121,16 @@ export function buildPlannedMemberLaneIdentity(params: {
}; };
} }
export function buildOpenCodeSecondaryLaneId(
member: Pick<RuntimeLanePlannerMemberInput, 'name'>
): string {
return `secondary:opencode:${member.name.trim()}`;
}
export function planTeamRuntimeLanes(params: { export function planTeamRuntimeLanes(params: {
leadProviderId?: TeamProviderId; leadProviderId?: TeamProviderId;
members: readonly RuntimeLanePlannerMemberInput[]; members: readonly RuntimeLanePlannerMemberInput[];
baseCwd?: string;
}): TeamRuntimeLanePlanResult { }): TeamRuntimeLanePlanResult {
const leadProviderId = normalizeLeadProviderId(params.leadProviderId); const leadProviderId = normalizeLeadProviderId(params.leadProviderId);
const allMembers = normalizePlannedMembers(params.members, 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.', '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 { return {
ok: true, ok: true,
plan: { plan: {
@ -175,18 +213,37 @@ export function isMixedOpenCodeSideLanePlan(
return plan.mode === 'mixed_opencode_side_lanes'; 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( export function isPureOpenCodeLanePlan(
plan: TeamRuntimeLanePlan plan: TeamRuntimeLanePlan
): plan is Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode' }> { ): plan is Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode' }> {
return plan.mode === 'pure_opencode'; return plan.mode === 'pure_opencode';
} }
export function isPureOpenCodeWorktreeRootLanePlan(
plan: TeamRuntimeLanePlan
): plan is Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode_worktree_root_lanes' }> {
return plan.mode === 'pure_opencode_worktree_root_lanes';
}
export function fromProvisioningMembers( export function fromProvisioningMembers(
leadProviderId: TeamProviderId | undefined, leadProviderId: TeamProviderId | undefined,
members: readonly TeamProvisioningMemberInput[] members: readonly TeamProvisioningMemberInput[],
options: { baseCwd?: string } = {}
): TeamRuntimeLanePlanResult { ): TeamRuntimeLanePlanResult {
return planTeamRuntimeLanes({ return planTeamRuntimeLanes({
leadProviderId, leadProviderId,
baseCwd: options.baseCwd,
members: members.map((member) => ({ members: members.map((member) => ({
name: member.name, name: member.name,
role: member.role, role: member.role,

View file

@ -9,9 +9,12 @@ export type {
TeamRuntimeLanePlanSuccess, TeamRuntimeLanePlanSuccess,
} from './core/domain/planTeamRuntimeLanes'; } from './core/domain/planTeamRuntimeLanes';
export { export {
buildOpenCodeSecondaryLaneId,
buildPlannedMemberLaneIdentity, buildPlannedMemberLaneIdentity,
fromProvisioningMembers, fromProvisioningMembers,
isMixedOpenCodeSideLanePlan, isMixedOpenCodeSideLanePlan,
isOpenCodeSideLanePlan,
isPureOpenCodeLanePlan, isPureOpenCodeLanePlan,
isPureOpenCodeWorktreeRootLanePlan,
planTeamRuntimeLanes, planTeamRuntimeLanes,
} from './core/domain/planTeamRuntimeLanes'; } from './core/domain/planTeamRuntimeLanes';

View file

@ -45,7 +45,7 @@ describe('createTeamRuntimeLaneCoordinator', () => {
{ name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' }, { 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', () => { it('drops stale hard-failure reasons when secondary OpenCode evidence later confirms alive', () => {

View file

@ -1,7 +1,7 @@
import { buildMixedPersistedLaunchSnapshot } from '@features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot'; import { buildMixedPersistedLaunchSnapshot } from '@features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot';
import { import {
fromProvisioningMembers, fromProvisioningMembers,
isMixedOpenCodeSideLanePlan, isOpenCodeSideLanePlan,
type TeamRuntimeLanePlan, type TeamRuntimeLanePlan,
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes'; } from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
@ -11,6 +11,7 @@ export interface TeamRuntimeLaneCoordinator {
planProvisioningMembers(params: { planProvisioningMembers(params: {
leadProviderId?: TeamProviderId; leadProviderId?: TeamProviderId;
members: TeamCreateRequest['members']; members: TeamCreateRequest['members'];
baseCwd?: string;
hasOpenCodeRuntimeAdapter: boolean; hasOpenCodeRuntimeAdapter: boolean;
}): TeamRuntimeLanePlan; }): TeamRuntimeLanePlan;
buildAggregateLaunchSnapshot( buildAggregateLaunchSnapshot(
@ -22,13 +23,15 @@ export interface TeamRuntimeLaneCoordinator {
export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator { export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator {
return { return {
planProvisioningMembers(params) { planProvisioningMembers(params) {
const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members); const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members, {
baseCwd: params.baseCwd,
});
if (!lanePlan.ok) { if (!lanePlan.ok) {
throw new Error(lanePlan.message); throw new Error(lanePlan.message);
} }
if (isMixedOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) { if (isOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) {
throw new Error( 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; return lanePlan.plan;
@ -37,7 +40,7 @@ export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator {
return buildMixedPersistedLaunchSnapshot(params); return buildMixedPersistedLaunchSnapshot(params);
}, },
isMixedSideLanePlan(plan) { isMixedSideLanePlan(plan) {
return isMixedOpenCodeSideLanePlan(plan); return isOpenCodeSideLanePlan(plan);
}, },
}; };
} }

View file

@ -17,8 +17,10 @@ import {
resolveCodexRuntimeSelection, resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/main'; } from '@features/codex-runtime-profile/main';
import { import {
buildOpenCodeSecondaryLaneId,
buildPlannedMemberLaneIdentity, buildPlannedMemberLaneIdentity,
isMixedOpenCodeSideLanePlan, isOpenCodeSideLanePlan,
isPureOpenCodeWorktreeRootLanePlan,
type TeamRuntimeLanePlan, type TeamRuntimeLanePlan,
} from '@features/team-runtime-lanes'; } from '@features/team-runtime-lanes';
import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main'; import { createTeamRuntimeLaneCoordinator } from '@features/team-runtime-lanes/main';
@ -4037,6 +4039,26 @@ export class TeamProvisioningService {
const canonicalMemberName = const canonicalMemberName =
metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; 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); const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
if (runtimeRun?.providerId === 'opencode') { if (runtimeRun?.providerId === 'opencode') {
const laneIdentity = buildPlannedMemberLaneIdentity({ const laneIdentity = buildPlannedMemberLaneIdentity({
@ -9860,11 +9882,13 @@ export class TeamProvisioningService {
private planRuntimeLanesOrThrow( private planRuntimeLanesOrThrow(
leadProviderId: TeamProviderId | undefined, leadProviderId: TeamProviderId | undefined,
members: TeamCreateRequest['members'] members: TeamCreateRequest['members'],
baseCwd?: string
): TeamRuntimeLanePlan { ): TeamRuntimeLanePlan {
return this.runtimeLaneCoordinator.planProvisioningMembers({ return this.runtimeLaneCoordinator.planProvisioningMembers({
leadProviderId, leadProviderId,
members, members,
baseCwd,
hasOpenCodeRuntimeAdapter: this.getOpenCodeRuntimeAdapter() !== null, hasOpenCodeRuntimeAdapter: this.getOpenCodeRuntimeAdapter() !== null,
}); });
} }
@ -9872,7 +9896,7 @@ export class TeamProvisioningService {
private createMixedSecondaryLaneStates( private createMixedSecondaryLaneStates(
plan: TeamRuntimeLanePlan plan: TeamRuntimeLanePlan
): MixedSecondaryRuntimeLaneState[] { ): MixedSecondaryRuntimeLaneState[] {
if (!isMixedOpenCodeSideLanePlan(plan)) { if (!isOpenCodeSideLanePlan(plan)) {
return []; return [];
} }
return plan.sideLanes.map((sideLane) => ({ return plan.sideLanes.map((sideLane) => ({
@ -9890,11 +9914,42 @@ export class TeamProvisioningService {
} }
private createMixedSecondaryLaneStateForMember( private createMixedSecondaryLaneStateForMember(
run: Pick<ProvisioningRun, 'request'>, run: Pick<ProvisioningRun, 'request' | 'mixedSecondaryLanes'>,
member: TeamCreateRequest['members'][number] member: TeamCreateRequest['members'][number]
): MixedSecondaryRuntimeLaneState { ): 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({ const laneIdentity = buildPlannedMemberLaneIdentity({
leadProviderId: resolveTeamProviderId(run.request.providerId), leadProviderId,
member: { member: {
name: member.name, name: member.name,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
@ -17099,9 +17154,11 @@ export class TeamProvisioningService {
): Promise<OpenCodeSecondaryRetryCandidate[]> { ): Promise<OpenCodeSecondaryRetryCandidate[]> {
const teamName = run.teamName; const teamName = run.teamName;
const leadProviderId = resolveTeamProviderId(run.request.providerId); 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( 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()) { if (!this.getOpenCodeRuntimeAdapter()) {
@ -17158,35 +17215,49 @@ export class TeamProvisioningService {
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
continue; continue;
} }
if (normalizeOptionalTeamProviderId(configuredMember.providerId) !== 'opencode') { const desiredProviderId =
normalizeOptionalTeamProviderId(configuredMember.providerId) ?? leadProviderId;
if (desiredProviderId !== 'opencode') {
continue; continue;
} }
const laneIdentity = buildPlannedMemberLaneIdentity({ const existingLane = (run.mixedSecondaryLanes ?? []).find((lane) =>
leadProviderId, matchesTeamMemberIdentity(lane.member.name, memberName)
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 liveEntry = run.memberSpawnStatuses.get(memberName); const liveEntry = run.memberSpawnStatuses.get(memberName);
const persistedMember = const persistedMemberByName =
persistedSnapshot?.members[memberName] ?? persistedSnapshot?.members[memberName] ??
Object.values(persistedSnapshot?.members ?? {}).find( Object.values(persistedSnapshot?.members ?? {}).find((member) =>
(member) => member.laneId === laneIdentity.laneId 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 ( if (
this.isRetryableFailedOpenCodeSecondaryLane({ this.isRetryableFailedOpenCodeSecondaryLane({
@ -17195,7 +17266,7 @@ export class TeamProvisioningService {
existingLane, existingLane,
}) })
) { ) {
candidates.push({ memberName, laneId: laneIdentity.laneId }); candidates.push({ memberName, laneId });
} }
} }
return candidates; return candidates;
@ -17529,9 +17600,9 @@ export class TeamProvisioningService {
): Promise<void> { ): Promise<void> {
const run = this.getMutableAliveRunOrThrow(teamName); const run = this.getMutableAliveRunOrThrow(teamName);
const leadProviderId = resolveTeamProviderId(run.request.providerId); const leadProviderId = resolveTeamProviderId(run.request.providerId);
if (leadProviderId === 'opencode') { if (leadProviderId === 'opencode' && (run.mixedSecondaryLanes?.length ?? 0) === 0) {
throw new Error( 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()) { if (!this.getOpenCodeRuntimeAdapter()) {
@ -17562,7 +17633,8 @@ export class TeamProvisioningService {
if (isLeadMember({ name: configuredMember.name, agentType: configuredMember.agentType })) { if (isLeadMember({ name: configuredMember.name, agentType: configuredMember.agentType })) {
throw new Error('Lead lane reattach is not supported'); throw new Error('Lead lane reattach is not supported');
} }
const desiredProviderId = normalizeOptionalTeamProviderId(configuredMember.providerId); const desiredProviderId =
normalizeOptionalTeamProviderId(configuredMember.providerId) ?? leadProviderId;
if (desiredProviderId !== 'opencode') { if (desiredProviderId !== 'opencode') {
throw new Error( throw new Error(
`Controlled reattach is only supported for OpenCode-owned members. "${memberName}" remains on the primary runtime owner.` `Controlled reattach is only supported for OpenCode-owned members. "${memberName}" remains on the primary runtime owner.`
@ -19806,7 +19878,7 @@ export class TeamProvisioningService {
return memberCwds[0]; return memberCwds[0];
} }
throw new Error( 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.'
); );
} }
@ -19952,18 +20024,6 @@ export class TeamProvisioningService {
return params.members; 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'] = []; const nextMembers: TeamCreateRequest['members'] = [];
for (const member of params.members) { for (const member of params.members) {
const providerId = normalizeTeamMemberProviderId(member.providerId); const providerId = normalizeTeamMemberProviderId(member.providerId);
@ -21026,7 +21086,11 @@ export class TeamProvisioningService {
allEffectiveMemberSpecs 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 primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
primaryMemberNames.has(member.name) primaryMemberNames.has(member.name)
@ -21609,6 +21673,21 @@ export class TeamProvisioningService {
providerBackendId: launchRequest.providerBackendId, providerBackendId: launchRequest.providerBackendId,
}); });
await this.writeOpenCodeTeamConfig(launchRequest, effectiveMembers); 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({ return this.runOpenCodeTeamRuntimeAdapterLaunch({
request: launchRequest, request: launchRequest,
@ -21668,6 +21747,21 @@ export class TeamProvisioningService {
existingTasks, existingTasks,
false 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({ return this.runOpenCodeTeamRuntimeAdapterLaunch({
request: launchRequest, request: launchRequest,
@ -21678,6 +21772,425 @@ export class TeamProvisioningService {
}); });
} }
private createOpenCodeAggregateProvisioningRun(params: {
runId: string;
startedAt: string;
progress: TeamProvisioningProgress;
request: TeamCreateRequest | TeamLaunchRequest;
members: TeamCreateRequest['members'];
lanePlan: Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode_worktree_root_lanes' }>;
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<TeamRuntimeLaunchResult | null> {
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<TeamRuntimeLanePlan, { mode: 'pure_opencode_worktree_root_lanes' }>;
prompt: string;
sourceWarning?: string;
onProgress: (progress: TeamProvisioningProgress) => void;
}): Promise<TeamLaunchResponse> {
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: { private async runOpenCodeTeamRuntimeAdapterLaunch(input: {
request: TeamCreateRequest | TeamLaunchRequest; request: TeamCreateRequest | TeamLaunchRequest;
members: TeamCreateRequest['members']; members: TeamCreateRequest['members'];
@ -22323,7 +22836,11 @@ export class TeamProvisioningService {
allEffectiveMemberSpecs 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 primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
primaryMemberNames.has(member.name) primaryMemberNames.has(member.name)
@ -25124,6 +25641,9 @@ export class TeamProvisioningService {
if (!run && this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId) { if (!run && this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId) {
return true; return true;
} }
if (run && this.hasSecondaryRuntimeRuns(teamName)) {
return !run.processKilled && !run.cancelRequested;
}
return run?.child != null && !run.processKilled && !run.cancelRequested; return run?.child != null && !run.processKilled && !run.cancelRequested;
} }
@ -30000,7 +30520,7 @@ export class TeamProvisioningService {
const leadProviderId = const leadProviderId =
normalizeOptionalTeamProviderId(leadLaunchIdentity?.providerId) ?? normalizeOptionalTeamProviderId(leadLaunchIdentity?.providerId) ??
normalizeOptionalTeamProviderId(teamMeta?.providerId); normalizeOptionalTeamProviderId(teamMeta?.providerId);
if (!leadProviderId || leadProviderId === 'opencode') { if (!leadProviderId) {
return null; return null;
} }
@ -30078,13 +30598,39 @@ export class TeamProvisioningService {
let recoveredAny = false; let recoveredAny = false;
for (const member of activeMembers) { for (const member of activeMembers) {
const laneIdentity = buildPlannedMemberLaneIdentity({ const persistedMember =
leadProviderId, persistedSnapshot?.members?.[member.name] ?? bootstrapSnapshot?.members?.[member.name];
member: { const laneIdentity =
name: member.name, leadProviderId === 'opencode'
providerId: normalizeOptionalTeamProviderId(member.providerId), ? (() => {
}, 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 ( if (
laneIdentity.laneKind !== 'secondary' || laneIdentity.laneKind !== 'secondary' ||
@ -30095,8 +30641,6 @@ export class TeamProvisioningService {
} }
let laneEntry = laneIndex.lanes[laneIdentity.laneId]; let laneEntry = laneIndex.lanes[laneIdentity.laneId];
const persistedMember =
persistedSnapshot?.members?.[member.name] ?? bootstrapSnapshot?.members?.[member.name];
if ( if (
!laneEntry && !laneEntry &&
persistedMember && persistedMember &&

View file

@ -38,6 +38,7 @@ import {
} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
import type { TeamMemberWorktreeManager } from '../../../../src/main/services/team/TeamMemberWorktreeManager';
import { import {
getMixedLaunchFallbackRecoveryError, getMixedLaunchFallbackRecoveryError,
TeamProvisioningService, TeamProvisioningService,
@ -263,6 +264,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<TeamMemberWorktreeManager, 'ensureMemberWorktree'> = {
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 () => { it('accepts pure OpenCode runtime bootstrap check-ins during adapter launch', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const adapter = new BootstrapCheckingOpenCodeRuntimeAdapter(svc); const adapter = new BootstrapCheckingOpenCodeRuntimeAdapter(svc);

View file

@ -19003,10 +19003,61 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId); 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(); allowConsoleLogs();
const adapterLaunch = vi.fn(); const bobWorktree = path.join(tempClaudeRoot, 'worktrees', 'bob');
const { svc } = createSafeLaunchService(); const worktreeManager = {
ensureMemberWorktree: vi.fn(async () => ({
baseRepoPath: tempClaudeRoot,
worktreePath: bobWorktree,
branchName: 'agent-teams/test/bob',
})),
};
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
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( svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([ new TeamRuntimeAdapterRegistry([
{ {
@ -19019,32 +19070,71 @@ describe('TeamProvisioningService', () => {
]) ])
); );
await expect( const { runId } = await svc.createTeam(
svc.createTeam( {
{ teamName: 'opencode-multi-worktree-lanes',
teamName: 'blocked-opencode-multi-worktree', cwd: tempClaudeRoot,
cwd: tempClaudeRoot, providerId: 'opencode',
providerId: 'opencode', providerBackendId: 'adapter',
providerBackendId: 'adapter', model: 'big-pickle',
model: 'big-pickle', members: [
members: [ {
{ name: 'bob',
name: 'bob', providerId: 'opencode',
providerId: 'opencode', model: 'minimax-m2.5-free',
model: 'minimax-m2.5-free', isolation: 'worktree',
isolation: 'worktree', },
}, {
{ name: 'tom',
name: 'tom', providerId: 'opencode',
providerId: 'opencode', model: 'nemotron-3-super-free',
model: 'nemotron-3-super-free', },
}, ],
], },
}, () => {}
() => {} );
)
).rejects.toThrow('Multiple OpenCode members in one lane cannot use separate worktrees yet'); expect(worktreeManager.ensureMemberWorktree).toHaveBeenCalledWith({
expect(adapterLaunch).not.toHaveBeenCalled(); 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 }),
}),
]);
}); });
}); });