Merge branch 'feat/opencode-worktree-root-lanes' into dev
This commit is contained in:
commit
d15a17a1fc
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', () => {
|
it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => {
|
||||||
const result = planTeamRuntimeLanes({
|
const result = planTeamRuntimeLanes({
|
||||||
leadProviderId: 'anthropic',
|
leadProviderId: 'anthropic',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue