feat: support opencode worktree root lanes
This commit is contained in:
parent
08b1de7fa2
commit
b748818795
8 changed files with 1055 additions and 94 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<RuntimeLanePlannerMemberInput, 'name'>
|
||||
): 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<TeamRuntimeLanePlan, { 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(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ export type {
|
|||
TeamRuntimeLanePlanSuccess,
|
||||
} from './core/domain/planTeamRuntimeLanes';
|
||||
export {
|
||||
buildOpenCodeSecondaryLaneId,
|
||||
buildPlannedMemberLaneIdentity,
|
||||
fromProvisioningMembers,
|
||||
isMixedOpenCodeSideLanePlan,
|
||||
isOpenCodeSideLanePlan,
|
||||
isPureOpenCodeLanePlan,
|
||||
isPureOpenCodeWorktreeRootLanePlan,
|
||||
planTeamRuntimeLanes,
|
||||
} from './core/domain/planTeamRuntimeLanes';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProvisioningRun, 'request'>,
|
||||
run: Pick<ProvisioningRun, 'request' | 'mixedSecondaryLanes'>,
|
||||
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<OpenCodeSecondaryRetryCandidate[]> {
|
||||
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<void> {
|
||||
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<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: {
|
||||
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 &&
|
||||
|
|
|
|||
|
|
@ -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<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 () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const adapter = new BootstrapCheckingOpenCodeRuntimeAdapter(svc);
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
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 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue