From 0d46aac5c037e8bf074c2cf53d01c1ef2c77aad7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 1 Jun 2026 21:07:40 +0300 Subject: [PATCH] fix(team): launch live roster members directly --- src/main/ipc/teams.ts | 334 +++---- src/main/services/team/TeamDataService.ts | 18 +- .../services/team/TeamProvisioningService.ts | 830 +++++++++++++++--- .../team/memberUpdateNotifications.ts | 52 +- .../components/team/TeamDetailView.tsx | 2 + .../team/dialogs/AddMemberDialog.tsx | 18 +- src/shared/types/team.ts | 2 + test/main/ipc/teams.test.ts | 212 +++-- .../services/team/TeamDataService.test.ts | 45 + .../team/TeamProvisioningService.test.ts | 362 +++++++- .../team/memberUpdateNotifications.test.ts | 33 + 11 files changed, 1401 insertions(+), 507 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 9f0d44df..6e404075 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 { - 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 { 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; } }); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index d14ebd74..acce6b15 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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: diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dc07d9e6..f93b2f4d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2305,7 +2305,14 @@ type MemberLifecycleOperationKind = | 'opencode_retry' | 'opencode_member_added' | 'opencode_member_updated' - | 'opencode_member_removed'; + | 'opencode_member_removed' + | 'primary_member_added' + | 'primary_member_restored' + | 'primary_member_updated' + | 'primary_member_removed'; + +type LiveRosterAttachReason = 'member_added' | 'member_restored' | 'member_updated'; +type DirectProcessMemberLaunchReason = 'manual_restart' | LiveRosterAttachReason; interface MemberLifecycleOperation { kind: MemberLifecycleOperationKind; @@ -4987,6 +4994,49 @@ export class TeamProvisioningService { }); } + private async resolveDirectMemberLaunchIdentity(input: { + claudePath: string; + cwd: string; + providerId: TeamProviderId; + providerBackendId?: TeamProviderBackendId; + provisioningEnv: ProvisioningEnvResolution; + memberSpec: TeamCreateRequest['members'][number]; + run: ProvisioningRun; + }): Promise { + const request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' + > = { + providerId: input.providerId, + ...(input.providerBackendId ? { providerBackendId: input.providerBackendId } : {}), + ...(input.memberSpec.model ? { model: input.memberSpec.model } : {}), + ...(input.memberSpec.effort ? { effort: input.memberSpec.effort } : {}), + ...(input.memberSpec.fastMode ? { fastMode: input.memberSpec.fastMode } : {}), + ...(input.run.request.limitContext ? { limitContext: input.run.request.limitContext } : {}), + }; + const facts = await this.readRuntimeProviderLaunchFacts({ + claudePath: input.claudePath, + cwd: input.cwd, + providerId: input.providerId, + env: input.provisioningEnv.env, + providerArgs: input.provisioningEnv.providerArgs, + limitContext: input.run.request.limitContext, + }); + this.validateRuntimeLaunchSelection({ + actorLabel: `Member ${input.memberSpec.name}`, + providerId: input.providerId, + model: input.memberSpec.model, + effort: input.memberSpec.effort, + fastMode: input.memberSpec.fastMode, + limitContext: input.run.request.limitContext, + facts, + }); + return this.buildProviderModelLaunchIdentity({ + request, + facts, + }); + } + async getClaudeLogs( teamName: string, query?: { offset?: number; limit?: number } @@ -9636,21 +9686,60 @@ export class TeamProvisioningService { member: TeamCreateRequest['members'][number] ): void { const normalizedName = member.name.trim().toLowerCase(); - const nextMembers = run.allEffectiveMembers.filter( + const currentMembers = Array.isArray(run.allEffectiveMembers) ? run.allEffectiveMembers : []; + const nextMembers = currentMembers.filter( (candidate) => candidate.name.trim().toLowerCase() !== normalizedName ); nextMembers.push(member); run.allEffectiveMembers = nextMembers; - run.request.members = nextMembers; + run.request = { + ...run.request, + members: nextMembers, + }; + + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: resolveTeamProviderId(run.request.providerId), + member: { + name: member.name, + providerId: normalizeOptionalTeamProviderId(member.providerId), + }, + }); + const currentPrimaryMembers = Array.isArray(run.effectiveMembers) ? run.effectiveMembers : []; + const nextPrimaryMembers = currentPrimaryMembers.filter( + (candidate) => candidate.name.trim().toLowerCase() !== normalizedName + ); + const currentExpectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : []; + const nextExpectedMembers = currentExpectedMembers.filter( + (candidate) => candidate.trim().toLowerCase() !== normalizedName + ); + if (laneIdentity.laneKind === 'primary') { + run.effectiveMembers = [...nextPrimaryMembers, member]; + run.expectedMembers = [...nextExpectedMembers, member.name.trim()].filter(Boolean); + } else { + run.effectiveMembers = nextPrimaryMembers; + run.expectedMembers = nextExpectedMembers; + } } private removeRunAllEffectiveMember(run: ProvisioningRun, memberName: string): void { const normalizedName = memberName.trim().toLowerCase(); - const nextMembers = run.allEffectiveMembers.filter( + const currentMembers = Array.isArray(run.allEffectiveMembers) ? run.allEffectiveMembers : []; + const nextMembers = currentMembers.filter( (candidate) => candidate.name.trim().toLowerCase() !== normalizedName ); run.allEffectiveMembers = nextMembers; - run.request.members = nextMembers; + run.request = { + ...run.request, + members: nextMembers, + }; + const currentPrimaryMembers = Array.isArray(run.effectiveMembers) ? run.effectiveMembers : []; + run.effectiveMembers = currentPrimaryMembers.filter( + (candidate) => candidate.name.trim().toLowerCase() !== normalizedName + ); + const currentExpectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : []; + run.expectedMembers = currentExpectedMembers.filter( + (candidate) => candidate.trim().toLowerCase() !== normalizedName + ); } private hasSecondaryRuntimeRuns(teamName: string): boolean { @@ -10214,6 +10303,39 @@ export class TeamProvisioningService { }; } + private buildPrimaryOwnedMemberSpecForRuntime(input: { + configuredMember: NonNullable< + ReturnType + >; + run: ProvisioningRun; + }): TeamCreateRequest['members'][number] { + const configuredSpec = this.buildConfiguredProvisioningMember(input.configuredMember); + const defaultProviderId = resolveTeamProviderId(input.run.request.providerId); + const memberProviderId = normalizeTeamMemberProviderId(configuredSpec.providerId); + const inheritsDefaultRuntime = + memberProviderId == null || memberProviderId === defaultProviderId; + const effectiveSpec = buildEffectiveTeamMemberSpec(configuredSpec, { + providerId: defaultProviderId, + model: input.run.request.model, + effort: input.run.request.effort, + }); + const effectiveProviderId = resolveTeamProviderId(effectiveSpec.providerId); + const providerBackendId = + migrateProviderBackendId(effectiveProviderId, configuredSpec.providerBackendId) ?? + (inheritsDefaultRuntime + ? migrateProviderBackendId(effectiveProviderId, input.run.request.providerBackendId) + : undefined); + const fastMode = + configuredSpec.fastMode ?? (inheritsDefaultRuntime ? input.run.request.fastMode : undefined); + + return { + ...effectiveSpec, + ...(providerBackendId ? { providerBackendId } : {}), + ...(fastMode ? { fastMode } : {}), + ...(input.configuredMember.agentType ? { agentType: input.configuredMember.agentType } : {}), + }; + } + private async buildRuntimeBootstrapMemberMcpLaunchConfigs(input: { cwd: string; members: TeamCreateRequest['members']; @@ -14866,7 +14988,7 @@ export class TeamProvisioningService { private async updateDirectTmuxRestartMemberConfig(input: { teamName: string; memberName: string; - member: NonNullable>; + member: TeamCreateRequest['members'][number] & { agentType?: string }; agentId: string; color: string; prompt: string; @@ -14954,8 +15076,11 @@ export class TeamProvisioningService { leadName: string; leadSessionId: string | null; prompt: string; + operation?: DirectProcessMemberLaunchReason; }): void { const timestamp = nowIso(); + const operation = input.operation ?? 'manual_restart'; + const isRestart = operation === 'manual_restart'; this.persistInboxMessage(input.teamName, input.memberName, { from: input.leadName, to: input.memberName, @@ -14964,8 +15089,10 @@ export class TeamProvisioningService { read: false, source: 'system_notification', leadSessionId: input.leadSessionId ?? undefined, - messageId: `direct-restart-${input.memberName}-${randomUUID()}`, - summary: `Restart bootstrap instructions for ${input.memberName}`, + messageId: `direct-${operation}-${input.memberName}-${randomUUID()}`, + summary: isRestart + ? `Restart bootstrap instructions for ${input.memberName}` + : `Bootstrap instructions for ${input.memberName}`, }); } @@ -15027,7 +15154,6 @@ export class TeamProvisioningService { ); } - const providerId = resolveTeamProviderId(input.configuredMember.providerId); const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) { throw buildMissingCliError(); @@ -15041,22 +15167,55 @@ export class TeamProvisioningService { }); await ensureCwdExists(cwd); - const provisioningEnv = await this.buildProvisioningEnv( + const operation: DirectProcessMemberLaunchReason = 'manual_restart'; + const preliminaryMemberSpec = this.buildPrimaryOwnedMemberSpecForRuntime({ + configuredMember: input.configuredMember, + run: input.run, + }); + const providerId = resolveTeamProviderId(preliminaryMemberSpec.providerId); + const providerBackendId = migrateProviderBackendId( providerId, - input.configuredMember.providerBackendId, - { - teamRuntimeAuth: { - teamName: input.teamName, - authMaterialId: `${input.run.runId}-direct-${input.configuredMember.name}-${randomUUID()}`, - allowAnthropicApiKeyHelper: true, - }, - } + preliminaryMemberSpec.providerBackendId ); + const provisioningEnv = await this.buildProvisioningEnv(providerId, providerBackendId, { + teamRuntimeAuth: { + teamName: input.teamName, + authMaterialId: `${input.run.runId}-direct-${input.configuredMember.name}-${randomUUID()}`, + allowAnthropicApiKeyHelper: true, + }, + }); if (provisioningEnv.warning) { throw new Error(provisioningEnv.warning); } - const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy); + const [materializedMemberSpec] = await this.materializeEffectiveTeamMemberSpecs({ + claudePath, + cwd, + members: [preliminaryMemberSpec], + defaults: { + providerId: resolveTeamProviderId(input.run.request.providerId), + model: input.run.request.model, + effort: input.run.request.effort, + }, + primaryProviderId: providerId, + primaryEnv: provisioningEnv, + teamRuntimeAuth: { + teamName: input.teamName, + authMaterialId: `${input.run.runId}-direct-${operation}-${input.configuredMember.name}-defaults-${randomUUID()}`, + allowAnthropicApiKeyHelper: true, + }, + }); + const memberSpec = materializedMemberSpec ?? preliminaryMemberSpec; + const launchIdentity = await this.resolveDirectMemberLaunchIdentity({ + claudePath, + cwd, + providerId, + ...(providerBackendId ? { providerBackendId } : {}), + provisioningEnv, + memberSpec, + run: input.run, + }); + const memberMcpPolicy = normalizeTeamMemberMcpPolicy(memberSpec.mcpPolicy); const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, { mcpPolicy: memberMcpPolicy, controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL, @@ -15074,16 +15233,7 @@ export class TeamProvisioningService { const parentSessionId = input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId; const prompt = buildMemberSpawnPrompt( - { - name: input.configuredMember.name, - ...(input.configuredMember.role ? { role: input.configuredMember.role } : {}), - ...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}), - ...(input.configuredMember.providerId - ? { providerId: input.configuredMember.providerId } - : {}), - ...(input.configuredMember.model ? { model: input.configuredMember.model } : {}), - ...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}), - }, + memberSpec, input.displayName, input.teamName, input.leadName, @@ -15093,7 +15243,7 @@ export class TeamProvisioningService { const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ teamName: input.teamName, providerId, - launchIdentity: null, + launchIdentity, envResolution: provisioningEnv, extraArgs: [], includeAnthropicHelper: providerId === 'anthropic', @@ -15128,8 +15278,8 @@ export class TeamProvisioningService { ...(input.run.request.skipPermissions !== false ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), - ...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), - ...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), + ...(memberSpec.model ? ['--model', memberSpec.model] : []), + ...(memberSpec.effort ? ['--effort', memberSpec.effort] : []), ...runtimeArgsPlan.fastModeArgs, ...runtimeArgsPlan.runtimeTurnSettledHookArgs, ...runtimeArgsPlan.providerArgs, @@ -15146,7 +15296,7 @@ export class TeamProvisioningService { await this.updateDirectTmuxRestartMemberConfig({ teamName: input.teamName, memberName: input.memberName, - member: input.configuredMember, + member: memberSpec, agentId, color, prompt, @@ -15162,6 +15312,7 @@ export class TeamProvisioningService { leadName: input.leadName, leadSessionId: parentSessionId, prompt, + operation, }); await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command); this.appendMemberBootstrapDiagnostic( @@ -15183,8 +15334,9 @@ export class TeamProvisioningService { ReturnType >; persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[]; + operation?: DirectProcessMemberLaunchReason; }): Promise { - const providerId = resolveTeamProviderId(input.configuredMember.providerId); + const operation = input.operation ?? 'manual_restart'; const claudePath = input.run.spawnContext?.claudePath ?? (await ClaudeBinaryResolver.resolve()); if (!claudePath) { throw buildMissingCliError(); @@ -15198,22 +15350,54 @@ export class TeamProvisioningService { }); await ensureCwdExists(cwd); - const provisioningEnv = await this.buildProvisioningEnv( + const preliminaryMemberSpec = this.buildPrimaryOwnedMemberSpecForRuntime({ + configuredMember: input.configuredMember, + run: input.run, + }); + const providerId = resolveTeamProviderId(preliminaryMemberSpec.providerId); + const providerBackendId = migrateProviderBackendId( providerId, - input.configuredMember.providerBackendId, - { - teamRuntimeAuth: { - teamName: input.teamName, - authMaterialId: `${input.run.runId}-process-restart-${input.configuredMember.name}-${randomUUID()}`, - allowAnthropicApiKeyHelper: true, - }, - } + preliminaryMemberSpec.providerBackendId ); + const provisioningEnv = await this.buildProvisioningEnv(providerId, providerBackendId, { + teamRuntimeAuth: { + teamName: input.teamName, + authMaterialId: `${input.run.runId}-process-${operation}-${input.configuredMember.name}-${randomUUID()}`, + allowAnthropicApiKeyHelper: true, + }, + }); if (provisioningEnv.warning) { throw new Error(provisioningEnv.warning); } - const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy); + const [materializedMemberSpec] = await this.materializeEffectiveTeamMemberSpecs({ + claudePath, + cwd, + members: [preliminaryMemberSpec], + defaults: { + providerId: resolveTeamProviderId(input.run.request.providerId), + model: input.run.request.model, + effort: input.run.request.effort, + }, + primaryProviderId: providerId, + primaryEnv: provisioningEnv, + teamRuntimeAuth: { + teamName: input.teamName, + authMaterialId: `${input.run.runId}-process-${operation}-${input.configuredMember.name}-defaults-${randomUUID()}`, + allowAnthropicApiKeyHelper: true, + }, + }); + const memberSpec = materializedMemberSpec ?? preliminaryMemberSpec; + const launchIdentity = await this.resolveDirectMemberLaunchIdentity({ + claudePath, + cwd, + providerId, + ...(providerBackendId ? { providerBackendId } : {}), + provisioningEnv, + memberSpec, + run: input.run, + }); + const memberMcpPolicy = normalizeTeamMemberMcpPolicy(memberSpec.mcpPolicy); const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, { mcpPolicy: memberMcpPolicy, controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL, @@ -15230,33 +15414,12 @@ export class TeamProvisioningService { ?.color?.trim() || getMemberColorByName(input.configuredMember.name); const parentSessionId = input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId; - const memberSpec: TeamCreateRequest['members'][number] = { - name: input.configuredMember.name, - ...(input.configuredMember.role ? { role: input.configuredMember.role } : {}), - ...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}), - ...(input.configuredMember.providerId - ? { providerId: input.configuredMember.providerId } - : {}), - ...(input.configuredMember.providerBackendId - ? { providerBackendId: input.configuredMember.providerBackendId } - : {}), - ...(input.configuredMember.model ? { model: input.configuredMember.model } : {}), - ...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}), - ...(input.configuredMember.agentType ? { agentType: input.configuredMember.agentType } : {}), - ...(memberMcpPolicy ? { mcpPolicy: memberMcpPolicy } : {}), - ...(input.configuredMember.isolation === 'worktree' - ? { isolation: 'worktree' as const } - : {}), - ...(input.configuredMember.cwd ? { cwd: input.configuredMember.cwd } : {}), - }; const prompt = buildMemberSpawnPrompt( memberSpec, input.displayName, input.teamName, input.leadName, - { - restart: true, - } + operation === 'manual_restart' ? { restart: true } : undefined ); const bootstrapExpectedAfter = nowIso(); const bootstrapProofToken = randomUUID(); @@ -15288,11 +15451,11 @@ export class TeamProvisioningService { const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ teamName: input.teamName, providerId, - launchIdentity: null, + launchIdentity, envResolution: provisioningEnv, extraArgs: [], includeAnthropicHelper: providerId === 'anthropic', - contextLabel: `Direct process teammate restart (${input.configuredMember.name})`, + contextLabel: `Direct process teammate ${operation} (${input.configuredMember.name})`, }); applyAppManagedRuntimeSettingsPathEnv( provisioningEnv.env, @@ -15325,8 +15488,8 @@ export class TeamProvisioningService { ...(input.run.request.skipPermissions !== false ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), - ...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), - ...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), + ...(memberSpec.model ? ['--model', memberSpec.model] : []), + ...(memberSpec.effort ? ['--effort', memberSpec.effort] : []), ...runtimeArgsPlan.fastModeArgs, ...runtimeArgsPlan.runtimeTurnSettledHookArgs, ...runtimeArgsPlan.providerArgs, @@ -15355,11 +15518,12 @@ export class TeamProvisioningService { const runtimePid = child.pid; const processPaneId = `process:${runtimePid}`; + const runtimeEventSource = `TeamProvisioningService.direct_process_${operation}`; child.stdout?.pipe(stdoutLog); child.stderr?.pipe(stderrLog); child.stdin?.on('error', (error) => { logger.debug( - `[${input.teamName}] Direct process restart stdin failed for ${agentId}: ${error.message}` + `[${input.teamName}] Direct process ${operation} stdin failed for ${agentId}: ${error.message}` ); }); child.once('close', (code, signal) => { @@ -15372,7 +15536,7 @@ export class TeamProvisioningService { agentId, runId: parentSessionId, bootstrapRunId: input.run.runId, - source: 'TeamProvisioningService.direct_process_restart', + source: runtimeEventSource, detail: code !== null ? `process exited with code ${code}` @@ -15393,7 +15557,7 @@ export class TeamProvisioningService { agentId, runId: parentSessionId, bootstrapRunId: input.run.runId, - source: 'TeamProvisioningService.direct_process_restart', + source: runtimeEventSource, detail: `process error: ${error.message}`, }); }); @@ -15402,79 +15566,97 @@ export class TeamProvisioningService { (child.stderr as { unref?: () => void } | null)?.unref?.(); child.unref(); - await this.appendDirectProcessRuntimeEvent({ - type: 'process_spawned', - eventsPath: runtimePaths.eventsPath, - pid: runtimePid, - teamName: input.teamName, - agentName: input.configuredMember.name, - agentId, - runId: parentSessionId, - bootstrapRunId: input.run.runId, - source: 'TeamProvisioningService.direct_process_restart', - detail: 'process spawned', - }); - await this.appendDirectProcessRuntimeEvent({ - type: 'stdout_attached', - eventsPath: runtimePaths.eventsPath, - pid: runtimePid, - teamName: input.teamName, - agentName: input.configuredMember.name, - agentId, - runId: parentSessionId, - bootstrapRunId: input.run.runId, - source: 'TeamProvisioningService.direct_process_restart', - detail: 'stdout and stderr attached', - }); - - await this.updateDirectTmuxRestartMemberConfig({ - teamName: input.teamName, - memberName: input.memberName, - member: input.configuredMember, - agentId, - color, - prompt, - paneId: processPaneId, - cwd, - providerId, - joinedAt: Date.now(), - bootstrapExpectedAfter, - backendType: 'process', - runtimePid, - bootstrapRuntimeEventsPath: runtimePaths.eventsPath, - bootstrapProofToken, - bootstrapRunId: input.run.runId, - ...(nativeBootstrapSpec - ? { - bootstrapContextHash: nativeBootstrapSpec.contextHash, - bootstrapBriefingHash: nativeBootstrapSpec.briefingHash, - } - : {}), - }); - this.enqueueDirectRestartPrompt({ - teamName: input.teamName, - memberName: input.configuredMember.name, - leadName: input.leadName, - leadSessionId: parentSessionId, - prompt, - }); - await this.appendDirectProcessRuntimeEvent({ - type: 'mailbox_bootstrap_written', - eventsPath: runtimePaths.eventsPath, - pid: runtimePid, - teamName: input.teamName, - agentName: input.configuredMember.name, - agentId, - runId: parentSessionId, - bootstrapRunId: input.run.runId, - source: 'TeamProvisioningService.direct_process_restart', - }); - this.appendMemberBootstrapDiagnostic( - input.run, - input.memberName, - `restart process spawned with pid ${runtimePid}` - ); - this.setMemberSpawnStatus(input.run, input.memberName, 'waiting'); + try { + await this.appendDirectProcessRuntimeEvent({ + type: 'process_spawned', + eventsPath: runtimePaths.eventsPath, + pid: runtimePid, + teamName: input.teamName, + agentName: input.configuredMember.name, + agentId, + runId: parentSessionId, + bootstrapRunId: input.run.runId, + source: runtimeEventSource, + detail: 'process spawned', + }); + await this.appendDirectProcessRuntimeEvent({ + type: 'stdout_attached', + eventsPath: runtimePaths.eventsPath, + pid: runtimePid, + teamName: input.teamName, + agentName: input.configuredMember.name, + agentId, + runId: parentSessionId, + bootstrapRunId: input.run.runId, + source: runtimeEventSource, + detail: 'stdout and stderr attached', + }); + await this.updateDirectTmuxRestartMemberConfig({ + teamName: input.teamName, + memberName: input.memberName, + member: memberSpec, + agentId, + color, + prompt, + paneId: processPaneId, + cwd, + providerId, + joinedAt: Date.now(), + bootstrapExpectedAfter, + backendType: 'process', + runtimePid, + bootstrapRuntimeEventsPath: runtimePaths.eventsPath, + bootstrapProofToken, + bootstrapRunId: input.run.runId, + ...(nativeBootstrapSpec + ? { + bootstrapContextHash: nativeBootstrapSpec.contextHash, + bootstrapBriefingHash: nativeBootstrapSpec.briefingHash, + } + : {}), + }); + this.enqueueDirectRestartPrompt({ + teamName: input.teamName, + memberName: input.configuredMember.name, + leadName: input.leadName, + leadSessionId: parentSessionId, + prompt, + operation, + }); + await this.appendDirectProcessRuntimeEvent({ + type: 'mailbox_bootstrap_written', + eventsPath: runtimePaths.eventsPath, + pid: runtimePid, + teamName: input.teamName, + agentName: input.configuredMember.name, + agentId, + runId: parentSessionId, + bootstrapRunId: input.run.runId, + source: runtimeEventSource, + }); + this.upsertRunAllEffectiveMember(input.run, memberSpec); + this.appendMemberBootstrapDiagnostic( + input.run, + input.memberName, + operation === 'manual_restart' + ? `restart process spawned with pid ${runtimePid}` + : `runtime process spawned with pid ${runtimePid}` + ); + this.setMemberSpawnStatus(input.run, input.memberName, 'waiting'); + } catch (error) { + try { + killProcessByPid(runtimePid); + } catch (killError) { + logger.warn( + `[${input.teamName}] Failed to stop orphaned direct process ${agentId} pid=${runtimePid}: ${ + killError instanceof Error ? killError.message : String(killError) + }` + ); + } + stdoutLog.end(); + stderrLog.end(); + throw error; + } } private getDirectProcessRestartRuntimePaths( @@ -15626,6 +15808,362 @@ export class TeamProvisioningService { return 'manual_restart'; } + private getLiveRosterAttachLifecycleKind( + reason?: LiveRosterAttachReason + ): MemberLifecycleOperationKind { + if (reason === 'member_restored') return 'primary_member_restored'; + if (reason === 'member_updated') return 'primary_member_updated'; + return 'primary_member_added'; + } + + async attachLiveRosterMember( + teamName: string, + memberName: string, + options?: { reason?: LiveRosterAttachReason } + ): Promise { + return this.runMemberLifecycleOperation( + teamName, + memberName, + this.getLiveRosterAttachLifecycleKind(options?.reason), + () => this.attachLiveRosterMemberUnlocked(teamName, memberName, options) + ); + } + + private async stopPrimaryOwnedRosterRuntime(input: { + teamName: string; + memberName: string; + persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[]; + liveRuntimeByMember: Map; + actionLabel: string; + }): Promise { + const pidsToStop = new Set(); + const tmuxPaneIdsToStop = new Set(); + let hasAliveRuntimeWithoutStopHandle = false; + + for (const runtimeMember of input.persistedRuntimeMembers) { + const backendType = runtimeMember.backendType?.trim().toLowerCase(); + if (backendType === 'in-process') { + throw new Error( + `Member "${input.memberName}" uses an in-process runtime and cannot be detached here` + ); + } + if ( + backendType === 'process' && + typeof runtimeMember.runtimePid === 'number' && + Number.isFinite(runtimeMember.runtimePid) && + runtimeMember.runtimePid > 0 + ) { + pidsToStop.add(runtimeMember.runtimePid); + } + const paneId = + typeof runtimeMember.tmuxPaneId === 'string' ? runtimeMember.tmuxPaneId.trim() : ''; + if (backendType === 'tmux' && paneId) { + tmuxPaneIdsToStop.add(paneId); + } + } + + for (const [candidateName, metadata] of input.liveRuntimeByMember.entries()) { + if (!matchesObservedMemberNameForExpected(candidateName, input.memberName)) { + continue; + } + if (metadata.backendType === 'in-process') { + throw new Error( + `Member "${input.memberName}" uses an in-process runtime and cannot be detached here` + ); + } + + let hasStopHandle = false; + if (metadata.backendType === 'tmux') { + const paneId = metadata.tmuxPaneId?.trim(); + if (paneId) { + tmuxPaneIdsToStop.add(paneId); + hasStopHandle = true; + } + } + if (typeof metadata.pid === 'number' && Number.isFinite(metadata.pid) && metadata.pid > 0) { + pidsToStop.add(metadata.pid); + hasStopHandle = true; + } + if ( + typeof metadata.metricsPid === 'number' && + Number.isFinite(metadata.metricsPid) && + metadata.metricsPid > 0 + ) { + pidsToStop.add(metadata.metricsPid); + hasStopHandle = true; + } + if (metadata.alive && !hasStopHandle) { + hasAliveRuntimeWithoutStopHandle = true; + } + } + + if (hasAliveRuntimeWithoutStopHandle) { + throw new Error( + `${input.actionLabel} cannot stop the existing runtime because it does not expose a pid or tmux pane.` + ); + } + + for (const paneId of tmuxPaneIdsToStop) { + try { + killTmuxPaneForCurrentPlatformSync(paneId); + } catch (error) { + logger.debug( + `[${input.teamName}] Failed to stop teammate pane ${input.memberName} ${paneId} for live roster lifecycle: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + for (const pid of pidsToStop) { + try { + killProcessByPid(pid); + } catch (error) { + logger.debug( + `[${input.teamName}] Failed to stop teammate process ${input.memberName} pid=${pid} for live roster lifecycle: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + if (pidsToStop.size > 0) { + const lingeringPids = await waitForPidsToExit([...pidsToStop], { + timeoutMs: 1_500, + pollMs: 100, + }); + if (lingeringPids.length > 0) { + throw new Error( + `${input.actionLabel} is still waiting for process exit (${lingeringPids.join(', ')}).` + ); + } + } + if (tmuxPaneIdsToStop.size > 0) { + const lingeringPaneIds = await waitForTmuxPanesToExit([...tmuxPaneIdsToStop], { + timeoutMs: 1_500, + pollMs: 100, + }); + if (lingeringPaneIds.length > 0) { + throw new Error( + `${input.actionLabel} is still waiting for tmux pane exit (${lingeringPaneIds.join(', ')}).` + ); + } + } + } + + private async attachLiveRosterMemberUnlocked( + teamName: string, + memberName: string, + options?: { reason?: LiveRosterAttachReason } + ): Promise { + const run = this.getMutableAliveRunOrThrow(teamName); + const config = await this.readConfigForStrictDecision(teamName); + if (!config) { + throw new Error(`Team "${teamName}" configuration is no longer available`); + } + const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); + const configuredMember = this.resolveEffectiveConfiguredMember( + config.members ?? [], + metaMembers, + memberName + ); + if (!configuredMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" has been removed`); + } + if (isLeadMember({ name: configuredMember.name, agentType: configuredMember.agentType })) { + throw new Error('Lead attach is not supported from member controls'); + } + + const leadProviderId = resolveTeamProviderId(run.request.providerId); + const desiredProviderId = + normalizeOptionalTeamProviderId(configuredMember.providerId) ?? leadProviderId; + if (desiredProviderId === 'opencode') { + await this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, { + reason: options?.reason === 'member_updated' ? 'member_updated' : 'member_added', + }); + return; + } + if (leadProviderId === 'opencode') { + throw new Error( + 'OpenCode-led mixed teams are not supported in this phase. Stop the team and relaunch with a non-OpenCode lead.' + ); + } + + const currentStatus = run.memberSpawnStatuses.get(memberName); + const currentUpdatedAtMs = parseOptionalIsoMs(currentStatus?.updatedAt); + const currentStatusAgeMs = + currentUpdatedAtMs > 0 ? Date.now() - currentUpdatedAtMs : Number.POSITIVE_INFINITY; + const currentSpawnLooksFresh = + currentStatus?.status === 'spawning' && currentStatusAgeMs < MEMBER_BOOTSTRAP_STALL_MS; + if (currentSpawnLooksFresh || currentStatus?.launchState === 'runtime_pending_permission') { + throw new Error(`Launch for teammate "${memberName}" is already in progress`); + } + + const replaceExistingRuntime = options?.reason === 'member_updated'; + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName).catch( + () => new Map() + ); + const liveRuntimeMember = + liveRuntimeByMember.get(memberName) ?? + [...liveRuntimeByMember.entries()].find(([candidateName]) => + matchesObservedMemberNameForExpected(candidateName, memberName) + )?.[1]; + if ( + !replaceExistingRuntime && + liveRuntimeMember?.alive && + liveRuntimeMember.livenessKind === 'runtime_process' + ) { + this.upsertRunAllEffectiveMember( + run, + this.buildPrimaryOwnedMemberSpecForRuntime({ + configuredMember, + run, + }) + ); + this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); + return; + } + if ( + !replaceExistingRuntime && + liveRuntimeMember?.alive && + (liveRuntimeMember.livenessKind === 'runtime_process_candidate' || + liveRuntimeMember.livenessKind === 'permission_blocked') && + currentStatus?.launchState === 'runtime_pending_bootstrap' + ) { + throw new Error(`Launch for teammate "${memberName}" is already in progress`); + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + const backendTypes = new Set( + persistedRuntimeMembers + .map((member) => member.backendType?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + ); + if (backendTypes.has('in-process')) { + throw new Error( + `Member "${memberName}" uses an in-process runtime and cannot be attached here` + ); + } + if (replaceExistingRuntime) { + await this.stopPrimaryOwnedRosterRuntime({ + teamName, + memberName, + persistedRuntimeMembers, + liveRuntimeByMember, + actionLabel: `Update for teammate "${memberName}"`, + }); + this.setMemberSpawnStatus(run, memberName, 'offline'); + } + + this.invalidateRuntimeSnapshotCaches(teamName); + this.resetRuntimeToolActivity(run, memberName); + this.clearMemberSpawnToolTracking(run, memberName); + run.pendingMemberRestarts.delete(memberName); + this.setMemberSpawnStatus(run, memberName, 'spawning'); + if (currentStatus?.launchState === 'runtime_pending_bootstrap') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'stale runtime_pending_bootstrap without live runtime process; retrying launch' + ); + } + this.appendMemberBootstrapDiagnostic( + run, + memberName, + `live roster ${options?.reason ?? 'member_added'} requested app-managed runtime process` + ); + + try { + await this.launchDirectProcessMemberRestart({ + run, + teamName, + displayName: config.name?.trim() || teamName, + leadName: this.resolveLeadMemberName(config.members ?? [], metaMembers), + memberName, + config, + configuredMember, + persistedRuntimeMembers, + operation: options?.reason ?? 'member_added', + }); + } catch (error) { + this.setMemberSpawnStatus( + run, + memberName, + 'error', + error instanceof Error ? error.message : String(error) + ); + if (run.isLaunch) { + await this.persistLaunchStateSnapshot( + run, + run.provisioningComplete ? 'finished' : 'active' + ); + } + throw error; + } + } + + async detachLiveRosterMember(teamName: string, memberName: string): Promise { + return this.runMemberLifecycleOperation(teamName, memberName, 'primary_member_removed', () => + this.detachLiveRosterMemberUnlocked(teamName, memberName) + ); + } + + private async detachLiveRosterMemberUnlocked( + teamName: string, + memberName: string + ): Promise { + const run = this.getMutableAliveRunOrThrow(teamName); + const leadProviderId = resolveTeamProviderId(run.request.providerId); + const config = await this.readConfigForStrictDecision(teamName); + const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); + const configuredMember = this.resolveEffectiveConfiguredMember( + config?.members ?? [], + metaMembers, + memberName + ); + const desiredProviderId = + normalizeOptionalTeamProviderId(configuredMember?.providerId) ?? leadProviderId; + if (desiredProviderId === 'opencode') { + await this.detachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName); + return; + } + if (leadProviderId === 'opencode') { + throw new Error( + 'OpenCode-led mixed teams are not supported in this phase. Stop the team and relaunch with a non-OpenCode lead.' + ); + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName).catch( + () => new Map() + ); + await this.stopPrimaryOwnedRosterRuntime({ + teamName, + memberName, + persistedRuntimeMembers, + liveRuntimeByMember, + actionLabel: `Detach for teammate "${memberName}"`, + }); + + this.removeRunAllEffectiveMember(run, memberName); + this.invalidateRuntimeSnapshotCaches(teamName); + this.resetRuntimeToolActivity(run, memberName); + this.clearMemberSpawnToolTracking(run, memberName); + run.pendingMemberRestarts.delete(memberName); + this.setMemberSpawnStatus(run, memberName, 'offline'); + if (run.isLaunch) { + await this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); + } + } + async restartMember(teamName: string, memberName: string): Promise { return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () => this.restartMemberUnlocked(teamName, memberName) diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index 325ff9d2..849f8b2d 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -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) { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 04cb378d..6be81208 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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, }); } diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index bee9fce4..9bce65d2 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -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(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, })) ); diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 7d690285..fad432e8 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1689,8 +1689,10 @@ export interface AddMemberRequest { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; mcpPolicy?: TeamMemberMcpPolicy; } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 851c3bca..c810dcb3 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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>(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(); }); }); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index f91d3aba..9f1f1a34 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -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 = { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 1ee1fbf6..3cbf8dcf 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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: [], diff --git a/test/main/services/team/memberUpdateNotifications.test.ts b/test/main/services/team/memberUpdateNotifications.test.ts index 12a78424..08fdfb27 100644 --- a/test/main/services/team/memberUpdateNotifications.test.ts +++ b/test/main/services/team/memberUpdateNotifications.test.ts @@ -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', + ], + }, + ]); + }); });