fix(team): launch live roster members directly

This commit is contained in:
777genius 2026-06-01 21:07:40 +03:00
parent ab3be12b94
commit 0d46aac5c0
11 changed files with 1401 additions and 507 deletions

View file

@ -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;
}
});
}

View file

@ -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

View file

@ -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) {

View file

@ -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,
});
}

View file

@ -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,
}))
);

View file

@ -1689,8 +1689,10 @@ export interface AddMemberRequest {
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
}

View file

@ -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();
});
});

View file

@ -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 = {

View file

@ -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: [],

View file

@ -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',
],
},
]);
});
});