fix(team): launch live roster members directly
This commit is contained in:
parent
ab3be12b94
commit
0d46aac5c0
11 changed files with 1401 additions and 507 deletions
|
|
@ -147,10 +147,6 @@ import { TeamConfigReader } from '../services/team/TeamConfigReader';
|
|||
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
|
||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import {
|
||||
buildAddMemberSpawnMessage,
|
||||
type RuntimeBootstrapMemberMcpLaunchConfig,
|
||||
} from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
|
||||
|
||||
|
|
@ -1474,40 +1470,6 @@ function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
|
|||
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
|
||||
}
|
||||
|
||||
async function sendLiveAddMemberSpawnPrompt(input: {
|
||||
provisioning: TeamProvisioningService;
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
leadName: string;
|
||||
projectPath?: string;
|
||||
member: RuntimeRosterMutationMember;
|
||||
}): Promise<void> {
|
||||
let mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null = null;
|
||||
try {
|
||||
mcpLaunchConfig = await input.provisioning.prepareLiveMemberMcpLaunchConfig({
|
||||
teamName: input.teamName,
|
||||
cwd: input.member.cwd?.trim() || input.projectPath,
|
||||
mcpPolicy: input.member.mcpPolicy,
|
||||
});
|
||||
const spawnMessage = buildAddMemberSpawnMessage(
|
||||
input.teamName,
|
||||
input.displayName,
|
||||
input.leadName,
|
||||
input.member,
|
||||
mcpLaunchConfig
|
||||
);
|
||||
await input.provisioning.sendMessageToTeam(input.teamName, spawnMessage);
|
||||
} catch (error) {
|
||||
await input.provisioning
|
||||
.discardLiveMemberMcpLaunchConfig({
|
||||
teamName: input.teamName,
|
||||
mcpLaunchConfig,
|
||||
})
|
||||
.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function didOpenCodeRosterMemberChange(
|
||||
previous: RuntimeRosterMutationMember | undefined,
|
||||
next: RuntimeRosterMutationMember | undefined
|
||||
|
|
@ -1611,7 +1573,7 @@ async function restorePreviousMembersMetaSnapshot(options: {
|
|||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to restore exact live OpenCode roster metadata for ${teamName}: ${
|
||||
`Failed to restore exact live roster metadata for ${teamName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
|
|
@ -1626,7 +1588,7 @@ async function restorePreviousMembersMetaSnapshot(options: {
|
|||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to roll back fallback live OpenCode roster metadata for ${teamName}: ${
|
||||
`Failed to roll back fallback live roster metadata for ${teamName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
|
|
@ -1634,14 +1596,14 @@ async function restorePreviousMembersMetaSnapshot(options: {
|
|||
}
|
||||
}
|
||||
|
||||
async function rollbackOpenCodeLiveRosterMutation(options: {
|
||||
async function rollbackLiveRosterMutation(options: {
|
||||
teamName: string;
|
||||
teamDataService: TeamDataService;
|
||||
provisioning: TeamProvisioningService;
|
||||
previousMembers: RuntimeRosterMutationMember[];
|
||||
previousMembersMeta: TeamMembersMetaFile | null;
|
||||
restoreOpenCodeMemberNames?: string[];
|
||||
detachOpenCodeMemberNames?: string[];
|
||||
restoreLiveMemberNames?: string[];
|
||||
detachLiveMemberNames?: string[];
|
||||
}): Promise<void> {
|
||||
const {
|
||||
teamName,
|
||||
|
|
@ -1649,10 +1611,25 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
|
|||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreOpenCodeMemberNames = [],
|
||||
detachOpenCodeMemberNames = [],
|
||||
restoreLiveMemberNames = [],
|
||||
detachLiveMemberNames = [],
|
||||
} = options;
|
||||
|
||||
const detachNames = Array.from(
|
||||
new Set(detachLiveMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
);
|
||||
for (const memberName of detachNames) {
|
||||
try {
|
||||
await provisioning.detachLiveRosterMember(teamName, memberName);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to clean up live roster member for ${teamName}/${memberName} during rollback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const metadataRestored = await restorePreviousMembersMetaSnapshot({
|
||||
teamName,
|
||||
teamDataService,
|
||||
|
|
@ -1663,36 +1640,21 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
|
|||
invalidateTeamRosterSnapshotCaches(teamName);
|
||||
}
|
||||
|
||||
const detachNames = Array.from(
|
||||
new Set(detachOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
);
|
||||
for (const memberName of detachNames) {
|
||||
try {
|
||||
await provisioning.detachOpenCodeOwnedMemberLane(teamName, memberName);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to clean up OpenCode lane for ${teamName}/${memberName} during rollback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataRestored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreNames = Array.from(
|
||||
new Set(restoreOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
new Set(restoreLiveMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
);
|
||||
for (const memberName of restoreNames) {
|
||||
try {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(teamName, memberName, {
|
||||
await provisioning.attachLiveRosterMember(teamName, memberName, {
|
||||
reason: 'member_updated',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to restore OpenCode lane for ${teamName}/${memberName} during rollback: ${
|
||||
`Failed to restore live roster member for ${teamName}/${memberName} during rollback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
|
|
@ -4234,14 +4196,26 @@ async function handleAddMember(
|
|||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: 'Invalid payload' };
|
||||
}
|
||||
const { name, role, workflow, isolation, providerId, model, mcpPolicy } = payload as {
|
||||
const {
|
||||
name,
|
||||
role,
|
||||
workflow,
|
||||
isolation,
|
||||
providerId,
|
||||
providerBackendId,
|
||||
model,
|
||||
fastMode,
|
||||
mcpPolicy,
|
||||
} = payload as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
isolation?: unknown;
|
||||
providerId?: unknown;
|
||||
providerBackendId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
fastMode?: unknown;
|
||||
mcpPolicy?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(name);
|
||||
|
|
@ -4259,6 +4233,13 @@ async function handleAddMember(
|
|||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
providerBackendId,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
}
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
|
|
@ -4269,6 +4250,10 @@ async function handleAddMember(
|
|||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(fastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
|
|
@ -4289,68 +4274,31 @@ async function handleAddMember(
|
|||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
...(providerBackendValidation.value
|
||||
? { providerBackendId: providerBackendValidation.value }
|
||||
: {}),
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
...(fastModeValidation.value ? { fastMode: fastModeValidation.value } : {}),
|
||||
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
|
||||
});
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
// If team is alive, notify the lead to spawn the new teammate
|
||||
if (isTeamAlive) {
|
||||
if (providerValidation.value === 'opencode') {
|
||||
try {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, memberName, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
detachOpenCodeMemberNames: [memberName],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
|
||||
teamDataService.getLeadMemberName(tn),
|
||||
teamDataService.getTeamDisplayName(tn),
|
||||
]);
|
||||
leadName = resolvedLeadName || 'team-lead';
|
||||
displayName = resolvedDisplayName || tn;
|
||||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
try {
|
||||
await sendLiveAddMemberSpawnPrompt({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
displayName,
|
||||
leadName,
|
||||
projectPath: previousTeamData.config?.projectPath,
|
||||
member: {
|
||||
name: memberName,
|
||||
...(typeof role === 'string' ? { role } : {}),
|
||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
|
||||
},
|
||||
await provisioning.attachLiveRosterMember(tn, memberName, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
} catch (error) {
|
||||
// Best-effort: lead process may not be responsive
|
||||
logger.warn(
|
||||
`Failed to notify lead about new member "${memberName}" in ${tn}: ${getErrorMessage(error)}`
|
||||
);
|
||||
await rollbackLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
detachLiveMemberNames: [memberName],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -4531,69 +4479,63 @@ async function handleReplaceMembers(
|
|||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
|
||||
teamDataService.getLeadMemberName(tn),
|
||||
teamDataService.getTeamDisplayName(tn),
|
||||
]);
|
||||
leadName = resolvedLeadName || 'team-lead';
|
||||
displayName = resolvedDisplayName || tn;
|
||||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
|
||||
try {
|
||||
for (const removedMember of removedOpenCodeMembers) {
|
||||
await provisioning.detachOpenCodeOwnedMemberLane(tn, removedMember.name);
|
||||
await provisioning.detachLiveRosterMember(tn, removedMember.name);
|
||||
}
|
||||
|
||||
for (const addedMember of addedOpenCodeMembers) {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, addedMember.name, {
|
||||
await provisioning.attachLiveRosterMember(tn, addedMember.name, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
}
|
||||
|
||||
for (const updatedMember of updatedOpenCodeMembers) {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, updatedMember.name, {
|
||||
await provisioning.attachLiveRosterMember(tn, updatedMember.name, {
|
||||
reason: 'member_updated',
|
||||
});
|
||||
}
|
||||
|
||||
for (const removedMemberName of primaryDiff.removed) {
|
||||
await provisioning.detachLiveRosterMember(tn, removedMemberName);
|
||||
}
|
||||
|
||||
for (const addedMember of primaryDiff.added) {
|
||||
await provisioning.attachLiveRosterMember(tn, addedMember.name, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
}
|
||||
|
||||
for (const updatedMember of primaryDiff.updated) {
|
||||
await provisioning.attachLiveRosterMember(tn, updatedMember.name, {
|
||||
reason: 'member_updated',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
await rollbackLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreOpenCodeMemberNames: [
|
||||
restoreLiveMemberNames: [
|
||||
...removedOpenCodeMembers.map((member) => member.name),
|
||||
...primaryDiff.removed,
|
||||
...updatedOpenCodeMembers.map((member) => member.name),
|
||||
...primaryDiff.updated.map((member) => member.name),
|
||||
],
|
||||
detachLiveMemberNames: [
|
||||
...addedOpenCodeMembers.map((member) => member.name),
|
||||
...primaryDiff.added.map((member) => member.name),
|
||||
],
|
||||
detachOpenCodeMemberNames: addedOpenCodeMembers.map((member) => member.name),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const addedMember of primaryDiff.added) {
|
||||
try {
|
||||
await sendLiveAddMemberSpawnPrompt({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
displayName,
|
||||
leadName,
|
||||
projectPath: previousTeamData.config?.projectPath,
|
||||
member: addedMember,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to notify lead about new member "${addedMember.name}" in ${tn}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const summaryMessage = buildReplaceMembersSummaryMessage(primaryDiff);
|
||||
const summaryMessage = buildReplaceMembersSummaryMessage({
|
||||
...primaryDiff,
|
||||
updated: [],
|
||||
});
|
||||
if (!summaryMessage) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -4627,29 +4569,22 @@ async function handleRemoveMember(
|
|||
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
|
||||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
const removedMember = previousMembers.find(
|
||||
(member) => member.name.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
);
|
||||
await teamDataService.removeMember(tn, name);
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
// Notify the lead about removed member
|
||||
if (isTeamAlive) {
|
||||
if (isOpenCodeRosterMutationMember(removedMember)) {
|
||||
try {
|
||||
await provisioning.detachOpenCodeOwnedMemberLane(tn, name);
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreOpenCodeMemberNames: [name],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
try {
|
||||
await provisioning.detachLiveRosterMember(tn, name);
|
||||
} catch (error) {
|
||||
await rollbackLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
restoreLiveMemberNames: [name],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message =
|
||||
|
|
@ -4687,58 +4622,27 @@ async function handleRestoreMember(
|
|||
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
|
||||
}
|
||||
|
||||
const restoredMember = await teamDataService.restoreMember(tn, name);
|
||||
await teamDataService.restoreMember(tn, name);
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
if (!isTeamAlive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpenCodeRosterMutationMember(restoredMember)) {
|
||||
try {
|
||||
await provisioning.reattachOpenCodeOwnedMemberLane(tn, name, {
|
||||
reason: 'member_added',
|
||||
});
|
||||
} catch (error) {
|
||||
await rollbackOpenCodeLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
detachOpenCodeMemberNames: [name],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
|
||||
teamDataService.getLeadMemberName(tn),
|
||||
teamDataService.getTeamDisplayName(tn),
|
||||
]);
|
||||
leadName = resolvedLeadName || 'team-lead';
|
||||
displayName = resolvedDisplayName || tn;
|
||||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLiveAddMemberSpawnPrompt({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
displayName,
|
||||
leadName,
|
||||
projectPath: previousTeamData.config?.projectPath,
|
||||
member: restoredMember,
|
||||
await provisioning.attachLiveRosterMember(tn, name, {
|
||||
reason: 'member_restored',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to notify lead about restore of "${name}" in ${tn}: ${getErrorMessage(error)}`
|
||||
);
|
||||
await rollbackLiveRosterMutation({
|
||||
teamName: tn,
|
||||
teamDataService,
|
||||
provisioning,
|
||||
previousMembers,
|
||||
previousMembersMeta,
|
||||
detachLiveMemberNames: [name],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1868,14 +1868,22 @@ export class TeamDataService {
|
|||
throw new Error(`Member "${name}" already exists`);
|
||||
}
|
||||
|
||||
const memberProviderId = normalizeOptionalTeamProviderId(request.providerId);
|
||||
const memberProviderBackendId = memberProviderId
|
||||
? migrateProviderBackendId(memberProviderId, request.providerBackendId)
|
||||
: request.providerBackendId;
|
||||
const newMember: TeamMember = {
|
||||
name,
|
||||
role: request.role?.trim() || undefined,
|
||||
workflow: request.workflow?.trim() || undefined,
|
||||
isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(request.providerId),
|
||||
providerId: memberProviderId,
|
||||
...(memberProviderBackendId ? { providerBackendId: memberProviderBackendId } : {}),
|
||||
model: request.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
|
||||
...(request.fastMode === 'inherit' || request.fastMode === 'on' || request.fastMode === 'off'
|
||||
? { fastMode: request.fastMode }
|
||||
: {}),
|
||||
mcpPolicy: normalizeTeamMemberMcpPolicy(request.mcpPolicy),
|
||||
agentType: 'general-purpose',
|
||||
joinedAt: Date.now(),
|
||||
|
|
@ -1940,13 +1948,17 @@ export class TeamDataService {
|
|||
nextByName.add(name.toLowerCase());
|
||||
const prev = existingByName.get(name.toLowerCase());
|
||||
const isSameActiveMember = Boolean(prev && prev.removedAt == null);
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const providerBackendId = providerId
|
||||
? migrateProviderBackendId(providerId, member.providerBackendId)
|
||||
: member.providerBackendId;
|
||||
return {
|
||||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||||
providerId,
|
||||
providerBackendId,
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
fastMode:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,12 @@
|
|||
import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamFastMode,
|
||||
TeamMemberMcpPolicy,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface MemberDiffInput {
|
||||
name: string;
|
||||
|
|
@ -6,8 +14,10 @@ export interface MemberDiffInput {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
mcpPolicy?: TeamMemberMcpPolicy;
|
||||
removedAt?: number | string | null;
|
||||
}
|
||||
|
|
@ -19,8 +29,10 @@ export interface ReplaceMembersDiff {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
mcpPolicy?: TeamMemberMcpPolicy;
|
||||
}[];
|
||||
removed: string[];
|
||||
|
|
@ -77,6 +89,21 @@ function describeProviderChange(
|
|||
return 'provider changed - restart required';
|
||||
}
|
||||
|
||||
function describeProviderBackendChange(
|
||||
previousProviderId: TeamProviderId | undefined,
|
||||
previousProviderBackendId: TeamProviderBackendId | undefined,
|
||||
nextProviderId: TeamProviderId | undefined,
|
||||
nextProviderBackendId: TeamProviderBackendId | undefined
|
||||
): string | null {
|
||||
if (
|
||||
migrateProviderBackendId(previousProviderId, previousProviderBackendId) ===
|
||||
migrateProviderBackendId(nextProviderId, nextProviderBackendId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return 'provider backend changed - restart required';
|
||||
}
|
||||
|
||||
function describeModelChange(
|
||||
previousModel: string | undefined,
|
||||
nextModel: string | undefined
|
||||
|
|
@ -97,6 +124,16 @@ function describeEffortChange(
|
|||
return 'reasoning effort changed - restart required';
|
||||
}
|
||||
|
||||
function describeFastModeChange(
|
||||
previousFastMode: TeamFastMode | undefined,
|
||||
nextFastMode: TeamFastMode | undefined
|
||||
): string | null {
|
||||
if (previousFastMode === nextFastMode) {
|
||||
return null;
|
||||
}
|
||||
return 'fast mode changed - restart required';
|
||||
}
|
||||
|
||||
function describeMcpPolicyChange(
|
||||
previousMcpPolicy: TeamMemberMcpPolicy | undefined,
|
||||
nextMcpPolicy: TeamMemberMcpPolicy | undefined
|
||||
|
|
@ -115,8 +152,10 @@ export function buildReplaceMembersDiff(
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
mcpPolicy?: TeamMemberMcpPolicy;
|
||||
}[]
|
||||
): ReplaceMembersDiff {
|
||||
|
|
@ -131,8 +170,10 @@ export function buildReplaceMembersDiff(
|
|||
workflow: normalizeOptionalText(member.workflow),
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||||
model: normalizeOptionalText(member.model),
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
mcpPolicy: member.mcpPolicy,
|
||||
},
|
||||
])
|
||||
|
|
@ -148,8 +189,10 @@ export function buildReplaceMembersDiff(
|
|||
workflow: normalizeOptionalText(member.workflow),
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||||
model: normalizeOptionalText(member.model),
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
mcpPolicy: member.mcpPolicy,
|
||||
},
|
||||
])
|
||||
|
|
@ -179,8 +222,15 @@ export function buildReplaceMembersDiff(
|
|||
: 'worktree isolation disabled'
|
||||
: null,
|
||||
describeProviderChange(previousMember.providerId, nextMember.providerId),
|
||||
describeProviderBackendChange(
|
||||
previousMember.providerId,
|
||||
previousMember.providerBackendId,
|
||||
nextMember.providerId,
|
||||
nextMember.providerBackendId
|
||||
),
|
||||
describeModelChange(previousMember.model, nextMember.model),
|
||||
describeEffortChange(previousMember.effort, nextMember.effort),
|
||||
describeFastModeChange(previousMember.fastMode, nextMember.fastMode),
|
||||
describeMcpPolicyChange(previousMember.mcpPolicy, nextMember.mcpPolicy),
|
||||
].filter((value): value is string => value !== null);
|
||||
if (changes.length === 0) {
|
||||
|
|
|
|||
|
|
@ -3762,8 +3762,10 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
workflow: entry.workflow,
|
||||
isolation: entry.isolation,
|
||||
providerId: entry.providerId,
|
||||
providerBackendId: entry.providerBackendId,
|
||||
model: entry.model,
|
||||
effort: entry.effort,
|
||||
fastMode: entry.fastMode,
|
||||
mcpPolicy: entry.mcpPolicy,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
|
|||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types';
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamFastMode,
|
||||
TeamMemberMcpPolicy,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface AddMemberEntry {
|
||||
name: string;
|
||||
|
|
@ -29,8 +35,10 @@ export interface AddMemberEntry {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
mcpPolicy?: TeamMemberMcpPolicy;
|
||||
}
|
||||
|
||||
|
|
@ -96,12 +104,6 @@ export const AddMemberDialog = ({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const wasOpenRef = useRef(open);
|
||||
|
||||
// Combine existing names + names already in the draft list for duplicate validation
|
||||
const allNames = useMemo(() => {
|
||||
const draftNames = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||
return [...existingNames.map((n) => n.toLowerCase()), ...draftNames];
|
||||
}, [existingNames, members]);
|
||||
|
||||
const validateName = useCallback(
|
||||
(name: string): string | null => {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
|
|
@ -154,8 +156,10 @@ export const AddMemberDialog = ({
|
|||
workflow: m.workflow,
|
||||
isolation: m.isolation,
|
||||
providerId: m.providerId,
|
||||
providerBackendId: m.providerBackendId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
fastMode: m.fastMode,
|
||||
mcpPolicy: m.mcpPolicy,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1689,8 +1689,10 @@ export interface AddMemberRequest {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
mcpPolicy?: TeamMemberMcpPolicy;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -331,6 +331,8 @@ describe('ipc teams handlers', () => {
|
|||
repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)),
|
||||
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
||||
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
||||
attachLiveRosterMember: vi.fn(async () => undefined),
|
||||
detachLiveRosterMember: vi.fn(async () => undefined),
|
||||
};
|
||||
const boardTaskActivityService = {
|
||||
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
|
||||
|
|
@ -409,6 +411,10 @@ describe('ipc teams handlers', () => {
|
|||
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValue(null);
|
||||
provisioningService.discardLiveMemberMcpLaunchConfig.mockReset();
|
||||
provisioningService.discardLiveMemberMcpLaunchConfig.mockResolvedValue(undefined);
|
||||
provisioningService.attachLiveRosterMember.mockReset();
|
||||
provisioningService.attachLiveRosterMember.mockResolvedValue(undefined);
|
||||
provisioningService.detachLiveRosterMember.mockReset();
|
||||
provisioningService.detachLiveRosterMember.mockResolvedValue(undefined);
|
||||
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
|
||||
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
|
||||
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||
|
|
@ -2779,7 +2785,7 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('notifies a live lead to use member_briefing bootstrap for the new teammate', async () => {
|
||||
it('attaches a live teammate through the lifecycle service', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'alice',
|
||||
|
|
@ -2788,35 +2794,45 @@ describe('ipc teams handlers', () => {
|
|||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('and the exact prompt below:')
|
||||
'alice',
|
||||
{ reason: 'member_added' }
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves runtime backend and fast mode when adding a live teammate', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'on',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.addMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('Your FIRST action: call MCP tool member_briefing')
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'on',
|
||||
})
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining(
|
||||
'Do NOT start work, claim tasks, or improvise workflow/task/process rules'
|
||||
)
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('You are alice, a developer on team "My Team" (my-team).')
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('Their workflow: Focus on frontend polish')
|
||||
'alice',
|
||||
{ reason: 'member_added' }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes Agent Teams MCP only launch overrides into live add-member Agent prompt', async () => {
|
||||
const projectPath = path.join(os.tmpdir(), 'codex live add project with spaces');
|
||||
it('lets lifecycle own MCP launch config for live add-member', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', projectPath },
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
|
|
@ -2830,11 +2846,6 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
|
||||
mcpConfigPath: '/tmp/codex live add/alice-app-only.json',
|
||||
mcpSettingSources: 'user,project,local',
|
||||
strictMcpConfig: true,
|
||||
} as never);
|
||||
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
|
|
@ -2845,29 +2856,24 @@ describe('ipc teams handlers', () => {
|
|||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
|
||||
teamName: 'my-team',
|
||||
cwd: projectPath,
|
||||
mcpPolicy: { mode: 'appOnly' },
|
||||
});
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining(
|
||||
'mcp_config="/tmp/codex live add/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
|
||||
)
|
||||
'alice',
|
||||
{ reason: 'member_added' }
|
||||
);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards live add-member MCP config if lead notification fails after config creation', async () => {
|
||||
const mcpLaunchConfig = {
|
||||
mcpConfigPath: '/tmp/codex live add/alice-orphan-risk.json',
|
||||
mcpSettingSources: 'user,project,local',
|
||||
strictMcpConfig: true,
|
||||
};
|
||||
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce(
|
||||
mcpLaunchConfig as never
|
||||
it('rolls back live addMember metadata when lifecycle attach fails', async () => {
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
providerBackendId: 'codex-native',
|
||||
members: [],
|
||||
});
|
||||
provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
|
||||
new Error('attach failed')
|
||||
);
|
||||
provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('lead offline'));
|
||||
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
|
|
@ -2875,14 +2881,20 @@ describe('ipc teams handlers', () => {
|
|||
role: 'developer',
|
||||
providerId: 'codex',
|
||||
mcpPolicy: { mode: 'appOnly' },
|
||||
})) as { success: boolean };
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.discardLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
|
||||
teamName: 'my-team',
|
||||
mcpLaunchConfig,
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('attach failed');
|
||||
expect(mockWriteMembersMeta).toHaveBeenCalledWith('my-team', [], {
|
||||
providerBackendId: 'codex-native',
|
||||
});
|
||||
vi.mocked(console.warn).mockClear();
|
||||
expect(provisioningService.detachLiveRosterMember).toHaveBeenCalledWith('my-team', 'alice');
|
||||
const detachOrder = provisioningService.detachLiveRosterMember.mock.invocationCallOrder[0];
|
||||
const metadataRestoreOrder = mockWriteMembersMeta.mock.invocationCallOrder[0];
|
||||
expect(detachOrder).toBeDefined();
|
||||
expect(metadataRestoreOrder).toBeDefined();
|
||||
expect(detachOrder!).toBeLessThan(metadataRestoreOrder!);
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rejects invalid team name', async () => {
|
||||
|
|
@ -2935,11 +2947,11 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.addMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rolls back live OpenCode addMember metadata when controlled reattach fails', async () => {
|
||||
it('rolls back live OpenCode addMember metadata when lifecycle attach fails', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
|
|
@ -2985,7 +2997,7 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
||||
provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
|
||||
new Error('reattach failed')
|
||||
);
|
||||
|
||||
|
|
@ -3030,7 +3042,7 @@ describe('ipc teams handlers', () => {
|
|||
],
|
||||
{ providerBackendId: 'codex-native' }
|
||||
);
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
||||
expect(provisioningService.detachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'alice'
|
||||
);
|
||||
|
|
@ -3250,11 +3262,11 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.removeMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rolls back live OpenCode removeMember metadata when lane detach fails', async () => {
|
||||
it('rolls back live removeMember metadata when lifecycle detach fails', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
|
|
@ -3300,7 +3312,7 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.detachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
||||
provisioningService.detachLiveRosterMember.mockRejectedValueOnce(
|
||||
new Error('detach failed')
|
||||
);
|
||||
|
||||
|
|
@ -3333,7 +3345,7 @@ describe('ipc teams handlers', () => {
|
|||
],
|
||||
{ providerBackendId: undefined }
|
||||
);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'alice',
|
||||
{ reason: 'member_updated' }
|
||||
|
|
@ -3362,11 +3374,10 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('passes Agent Teams MCP only launch overrides into live restore-member Agent prompt', async () => {
|
||||
const projectPath = path.join(os.tmpdir(), 'codex live restore project with spaces');
|
||||
it('lets lifecycle own MCP launch config for live restore-member', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', projectPath },
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
|
|
@ -3394,30 +3405,21 @@ describe('ipc teams handlers', () => {
|
|||
providerId: 'codex',
|
||||
mcpPolicy: { mode: 'appOnly' },
|
||||
} as never);
|
||||
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
|
||||
mcpConfigPath: '/tmp/codex live restore/alice-app-only.json',
|
||||
mcpSettingSources: 'user,project,local',
|
||||
strictMcpConfig: true,
|
||||
} as never);
|
||||
|
||||
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
|
||||
teamName: 'my-team',
|
||||
cwd: projectPath,
|
||||
mcpPolicy: { mode: 'appOnly' },
|
||||
});
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining(
|
||||
'mcp_config="/tmp/codex live restore/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
|
||||
)
|
||||
'alice',
|
||||
{ reason: 'member_restored' }
|
||||
);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reattaches a restored OpenCode teammate on a live mixed team', async () => {
|
||||
it('attaches a restored OpenCode teammate through the lifecycle service', async () => {
|
||||
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
|
||||
service.restoreMember.mockResolvedValueOnce({
|
||||
name: 'alice',
|
||||
|
|
@ -3452,10 +3454,10 @@ describe('ipc teams handlers', () => {
|
|||
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'alice',
|
||||
{ reason: 'member_added' }
|
||||
{ reason: 'member_restored' }
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -3495,17 +3497,16 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.restoreMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceMembers', () => {
|
||||
it('passes Agent Teams MCP only launch overrides into live replace-members added teammate prompt', async () => {
|
||||
const projectPath = path.join(os.tmpdir(), 'codex live replace project with spaces');
|
||||
it('attaches added teammates through lifecycle during live replaceMembers', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', projectPath },
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{
|
||||
|
|
@ -3519,11 +3520,6 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
|
||||
mcpConfigPath: '/tmp/codex live replace/alice-app-only.json',
|
||||
mcpSettingSources: 'user,project,local',
|
||||
strictMcpConfig: true,
|
||||
} as never);
|
||||
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
|
|
@ -3538,20 +3534,16 @@ describe('ipc teams handlers', () => {
|
|||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
|
||||
teamName: 'my-team',
|
||||
cwd: projectPath,
|
||||
mcpPolicy: { mode: 'appOnly' },
|
||||
});
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining(
|
||||
'mcp_config="/tmp/codex live replace/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
|
||||
)
|
||||
'alice',
|
||||
{ reason: 'member_added' }
|
||||
);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports existing teammate MCP policy changes in live replace-members summary', async () => {
|
||||
it('reattaches updated primary-owned teammates through lifecycle during live replaceMembers', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
|
|
@ -3589,11 +3581,13 @@ describe('ipc teams handlers', () => {
|
|||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('MCP access policy changed - restart required')
|
||||
'alice',
|
||||
{ reason: 'member_updated' }
|
||||
);
|
||||
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks live replaceMembers for a running OpenCode-led team before metadata is changed', async () => {
|
||||
|
|
@ -3629,12 +3623,12 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('running OpenCode-led team');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
it('rolls back live OpenCode replaceMembers metadata when lane reattach fails', async () => {
|
||||
it('rolls back live OpenCode replaceMembers metadata when lifecycle attach fails', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
mockGetMembersMetaFile.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
|
|
@ -3696,7 +3690,7 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
|
||||
provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
|
||||
new Error('reattach failed')
|
||||
);
|
||||
|
||||
|
|
@ -3774,13 +3768,13 @@ describe('ipc teams handlers', () => {
|
|||
],
|
||||
{ providerBackendId: 'codex-native' }
|
||||
);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'my-team',
|
||||
'alice',
|
||||
{ reason: 'member_updated' }
|
||||
);
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
|
||||
expect(provisioningService.attachLiveRosterMember).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'my-team',
|
||||
'alice',
|
||||
|
|
@ -3832,8 +3826,8 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
expect(result.error).toContain('alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
|
||||
|
|
@ -3880,8 +3874,8 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
expect(result.error).toContain('alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
|
||||
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -938,6 +938,51 @@ describe('TeamDataService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('persists member-level provider backend and fast mode during addMember', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => []),
|
||||
writeMembers,
|
||||
} as never;
|
||||
|
||||
const service = new TeamDataService(
|
||||
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
|
||||
{ getTasks: vi.fn(async () => []) } as never,
|
||||
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
(() => ({ processes: { listProcesses: vi.fn(async () => []) } }) as never) as never,
|
||||
{} as never,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
|
||||
);
|
||||
|
||||
await service.addMember('runtime-team', {
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'on',
|
||||
});
|
||||
|
||||
expect(writeMembers).toHaveBeenCalledWith(
|
||||
'runtime-team',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'on',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('allows multiple OpenCode teammates in replaceMembers drafts before they are persisted', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
|
|
|
|||
|
|
@ -15989,6 +15989,195 @@ describe('TeamProvisioningService', () => {
|
|||
expect(run.pendingMemberRestarts.has('forge')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['anthropic', undefined, 'anthropic'],
|
||||
['codex', undefined, 'codex'],
|
||||
['gemini', undefined, 'gemini'],
|
||||
['codex', 'anthropic', 'anthropic'],
|
||||
['anthropic', 'codex', 'codex'],
|
||||
['codex', 'gemini', 'gemini'],
|
||||
] as const)(
|
||||
'attaches live primary-owned %s/%s teammate through direct process lifecycle',
|
||||
async (leadProviderId, memberProviderId, expectedProviderId) => {
|
||||
const teamName = `direct-live-${leadProviderId}-${memberProviderId ?? 'inherit'}`;
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: [],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.request = { providerId: leadProviderId };
|
||||
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
const directProcessLaunch = vi.fn(async (input) => {
|
||||
const memberSpec = (svc as any).buildPrimaryOwnedMemberSpecForRuntime({
|
||||
configuredMember: input.configuredMember,
|
||||
run: input.run,
|
||||
});
|
||||
expect(memberSpec.providerId).toBe(expectedProviderId);
|
||||
});
|
||||
const opencodeReattach = vi.fn(async () => {});
|
||||
(svc as any).launchDirectProcessMemberRestart = directProcessLaunch;
|
||||
(svc as any).reattachOpenCodeOwnedMemberLaneUnlocked = opencodeReattach;
|
||||
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
|
||||
name: 'Direct Live Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: leadProviderId },
|
||||
{
|
||||
name: 'forge',
|
||||
role: 'Developer',
|
||||
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
],
|
||||
}));
|
||||
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
|
||||
await svc.attachLiveRosterMember(teamName, 'forge', { reason: 'member_added' });
|
||||
|
||||
expect(directProcessLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName,
|
||||
memberName: 'forge',
|
||||
operation: 'member_added',
|
||||
configuredMember: expect.objectContaining({
|
||||
name: 'forge',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(opencodeReattach).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('routes live OpenCode teammates through the OpenCode lane lifecycle', async () => {
|
||||
const teamName = 'direct-live-opencode-member';
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: [],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.request = { providerId: 'codex' };
|
||||
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
const directProcessLaunch = vi.fn(async () => {});
|
||||
const opencodeReattach = vi.fn(async () => {});
|
||||
(svc as any).launchDirectProcessMemberRestart = directProcessLaunch;
|
||||
(svc as any).reattachOpenCodeOwnedMemberLaneUnlocked = opencodeReattach;
|
||||
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
|
||||
name: 'Direct Live OpenCode Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
|
||||
{
|
||||
name: 'forge',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
],
|
||||
}));
|
||||
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||
|
||||
await svc.attachLiveRosterMember(teamName, 'forge', { reason: 'member_added' });
|
||||
|
||||
expect(opencodeReattach).toHaveBeenCalledWith(teamName, 'forge', {
|
||||
reason: 'member_added',
|
||||
});
|
||||
expect(directProcessLaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks live primary-owned teammate attach for OpenCode-led teams', async () => {
|
||||
const teamName = 'opencode-led-live-primary-member';
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: [],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.request = { providerId: 'opencode' };
|
||||
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
const directProcessLaunch = vi.fn(async () => {});
|
||||
const opencodeReattach = vi.fn(async () => {});
|
||||
(svc as any).launchDirectProcessMemberRestart = directProcessLaunch;
|
||||
(svc as any).reattachOpenCodeOwnedMemberLaneUnlocked = opencodeReattach;
|
||||
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
|
||||
name: 'OpenCode Led Live Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: 'opencode' },
|
||||
{
|
||||
name: 'forge',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
],
|
||||
}));
|
||||
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||
|
||||
await expect(
|
||||
svc.attachLiveRosterMember(teamName, 'forge', { reason: 'member_added' })
|
||||
).rejects.toThrow('OpenCode-led mixed teams are not supported');
|
||||
|
||||
expect(directProcessLaunch).not.toHaveBeenCalled();
|
||||
expect(opencodeReattach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks live primary-owned teammate detach for OpenCode-led teams', async () => {
|
||||
const teamName = 'opencode-led-live-primary-detach';
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: ['forge'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.request = { providerId: 'opencode' };
|
||||
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
const opencodeDetach = vi.fn(async () => {});
|
||||
const stopPrimaryRuntime = vi.fn(async () => {});
|
||||
(svc as any).detachOpenCodeOwnedMemberLaneUnlocked = opencodeDetach;
|
||||
(svc as any).stopPrimaryOwnedRosterRuntime = stopPrimaryRuntime;
|
||||
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
|
||||
name: 'OpenCode Led Live Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: 'opencode' },
|
||||
{
|
||||
name: 'forge',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
],
|
||||
}));
|
||||
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
|
||||
|
||||
await expect(svc.detachLiveRosterMember(teamName, 'forge')).rejects.toThrow(
|
||||
'OpenCode-led mixed teams are not supported'
|
||||
);
|
||||
|
||||
expect(opencodeDetach).not.toHaveBeenCalled();
|
||||
expect(stopPrimaryRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches direct process teammate restarts with normal MCP settings inheritance', async () => {
|
||||
const teamName = 'process-flags-team';
|
||||
const projectPath = path.join(tempProjectsBase, 'process-flags-project');
|
||||
|
|
@ -16004,6 +16193,130 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
|
||||
} as any);
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: ['atlas'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.request = { providerId: 'codex', skipPermissions: true, fastMode: 'on' };
|
||||
run.detectedSessionId = 'lead-session-1';
|
||||
const configuredMember = {
|
||||
name: 'forge',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
};
|
||||
const config = {
|
||||
name: 'Process Flags Team',
|
||||
projectPath,
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
|
||||
};
|
||||
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { CODEX_API_KEY: 'test-openai-key' },
|
||||
authSource: 'openai_api_key',
|
||||
providerArgs: [],
|
||||
}));
|
||||
const launchIdentity = {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'native',
|
||||
selectedFastMode: 'on',
|
||||
resolvedFastMode: true,
|
||||
};
|
||||
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async (input) => {
|
||||
expect(input.memberSpec.fastMode).toBe('on');
|
||||
return launchIdentity;
|
||||
});
|
||||
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async (input) => ({
|
||||
fastModeArgs:
|
||||
input.launchIdentity === launchIdentity ? ['--test-codex-fast-mode'] : [],
|
||||
runtimeTurnSettledHookArgs: [],
|
||||
providerArgs: [],
|
||||
settingsArgs: [],
|
||||
extraArgs: [],
|
||||
inheritedProviderArgs: [],
|
||||
appManagedSettingsPath: null,
|
||||
}));
|
||||
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
|
||||
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
|
||||
(svc as any).enqueueDirectRestartPrompt = vi.fn();
|
||||
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
|
||||
|
||||
await (svc as any).launchDirectProcessMemberRestart({
|
||||
run,
|
||||
teamName,
|
||||
displayName: 'Process Flags Team',
|
||||
leadName: 'team-lead',
|
||||
memberName: 'forge',
|
||||
config,
|
||||
configuredMember,
|
||||
persistedRuntimeMembers: [],
|
||||
operation: 'member_added',
|
||||
});
|
||||
|
||||
child.emit('close', 0, null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
expect((svc as any).resolveDirectMemberLaunchIdentity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
memberSpec: expect.objectContaining({ fastMode: 'on' }),
|
||||
})
|
||||
);
|
||||
expect((svc as any).buildTeamRuntimeLaunchArgsPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ launchIdentity })
|
||||
);
|
||||
expect(run.expectedMembers).toEqual(['atlas', 'forge']);
|
||||
expect(run.effectiveMembers).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'forge',
|
||||
providerId: 'codex',
|
||||
fastMode: 'on',
|
||||
}),
|
||||
]);
|
||||
expect(run.allEffectiveMembers).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'forge',
|
||||
providerId: 'codex',
|
||||
fastMode: 'on',
|
||||
}),
|
||||
]);
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--teammate-runtime',
|
||||
'headless',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--mcp-config',
|
||||
'/mock/mcp-config.json',
|
||||
'--test-codex-fast-mode',
|
||||
])
|
||||
);
|
||||
expect(launchArgs).not.toContain('--strict-mcp-config');
|
||||
});
|
||||
|
||||
it('stops a direct process teammate when post-spawn runtime event persistence fails', async () => {
|
||||
const teamName = 'process-event-failure-team';
|
||||
const projectPath = path.join(tempProjectsBase, 'process-event-failure-project');
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 5678,
|
||||
stdin: { on: vi.fn(), unref: vi.fn() },
|
||||
stdout: { pipe: vi.fn(), unref: vi.fn() },
|
||||
stderr: { pipe: vi.fn(), unref: vi.fn() },
|
||||
unref: vi.fn(),
|
||||
});
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
|
||||
} as any);
|
||||
|
|
@ -16024,7 +16337,7 @@ describe('TeamProvisioningService', () => {
|
|||
agentType: 'general-purpose',
|
||||
};
|
||||
const config = {
|
||||
name: 'Process Flags Team',
|
||||
name: 'Process Event Failure Team',
|
||||
projectPath,
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
|
||||
|
|
@ -16035,6 +16348,7 @@ describe('TeamProvisioningService', () => {
|
|||
authSource: 'openai_api_key',
|
||||
providerArgs: [],
|
||||
}));
|
||||
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async () => ({ providerId: 'codex' }));
|
||||
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
|
||||
fastModeArgs: [],
|
||||
runtimeTurnSettledHookArgs: [],
|
||||
|
|
@ -16047,34 +16361,28 @@ describe('TeamProvisioningService', () => {
|
|||
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
|
||||
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
|
||||
(svc as any).enqueueDirectRestartPrompt = vi.fn();
|
||||
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
|
||||
(svc as any).appendDirectProcessRuntimeEvent = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('event write failed'));
|
||||
|
||||
await (svc as any).launchDirectProcessMemberRestart({
|
||||
run,
|
||||
teamName,
|
||||
displayName: 'Process Flags Team',
|
||||
leadName: 'team-lead',
|
||||
memberName: 'forge',
|
||||
config,
|
||||
configuredMember,
|
||||
persistedRuntimeMembers: [],
|
||||
});
|
||||
await expect(
|
||||
(svc as any).launchDirectProcessMemberRestart({
|
||||
run,
|
||||
teamName,
|
||||
displayName: 'Process Event Failure Team',
|
||||
leadName: 'team-lead',
|
||||
memberName: 'forge',
|
||||
config,
|
||||
configuredMember,
|
||||
persistedRuntimeMembers: [],
|
||||
operation: 'member_added',
|
||||
})
|
||||
).rejects.toThrow('event write failed');
|
||||
|
||||
child.emit('close', 0, null);
|
||||
expect(killProcessByPid).toHaveBeenCalledWith(5678);
|
||||
expect((svc as any).updateDirectTmuxRestartMemberConfig).not.toHaveBeenCalled();
|
||||
expect(run.allEffectiveMembers ?? []).toEqual([]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--teammate-runtime',
|
||||
'headless',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--mcp-config',
|
||||
'/mock/mcp-config.json',
|
||||
])
|
||||
);
|
||||
expect(launchArgs).not.toContain('--strict-mcp-config');
|
||||
});
|
||||
|
||||
it('launches direct process teammate restarts with strict per-member MCP policy', async () => {
|
||||
|
|
@ -16135,6 +16443,8 @@ describe('TeamProvisioningService', () => {
|
|||
authSource: 'openai_api_key',
|
||||
providerArgs: [],
|
||||
}));
|
||||
const launchIdentity = { providerId: 'codex' };
|
||||
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async () => launchIdentity);
|
||||
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
|
||||
fastModeArgs: [],
|
||||
runtimeTurnSettledHookArgs: [],
|
||||
|
|
|
|||
|
|
@ -34,4 +34,37 @@ describe('member update notifications', () => {
|
|||
'MCP access policy changed - restart required'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports provider backend and fast mode changes as restart-required updates', () => {
|
||||
const diff = buildReplaceMembersDiff(
|
||||
[
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
providerId: 'gemini',
|
||||
providerBackendId: 'api',
|
||||
fastMode: 'off',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
providerId: 'gemini',
|
||||
providerBackendId: 'cli-sdk',
|
||||
fastMode: 'on',
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(diff.updated).toEqual([
|
||||
{
|
||||
name: 'alice',
|
||||
changes: [
|
||||
'provider backend changed - restart required',
|
||||
'fast mode changed - restart required',
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue