fix(team): launch live roster members directly

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

View file

@ -147,10 +147,6 @@ import { TeamConfigReader } from '../services/team/TeamConfigReader';
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack'; import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore';
import {
buildAddMemberSpawnMessage,
type RuntimeBootstrapMemberMcpLaunchConfig,
} from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService'; import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
@ -1474,40 +1470,6 @@ function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode'; return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
} }
async function sendLiveAddMemberSpawnPrompt(input: {
provisioning: TeamProvisioningService;
teamName: string;
displayName: string;
leadName: string;
projectPath?: string;
member: RuntimeRosterMutationMember;
}): Promise<void> {
let mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null = null;
try {
mcpLaunchConfig = await input.provisioning.prepareLiveMemberMcpLaunchConfig({
teamName: input.teamName,
cwd: input.member.cwd?.trim() || input.projectPath,
mcpPolicy: input.member.mcpPolicy,
});
const spawnMessage = buildAddMemberSpawnMessage(
input.teamName,
input.displayName,
input.leadName,
input.member,
mcpLaunchConfig
);
await input.provisioning.sendMessageToTeam(input.teamName, spawnMessage);
} catch (error) {
await input.provisioning
.discardLiveMemberMcpLaunchConfig({
teamName: input.teamName,
mcpLaunchConfig,
})
.catch(() => {});
throw error;
}
}
function didOpenCodeRosterMemberChange( function didOpenCodeRosterMemberChange(
previous: RuntimeRosterMutationMember | undefined, previous: RuntimeRosterMutationMember | undefined,
next: RuntimeRosterMutationMember | undefined next: RuntimeRosterMutationMember | undefined
@ -1611,7 +1573,7 @@ async function restorePreviousMembersMetaSnapshot(options: {
return true; return true;
} catch (error) { } catch (error) {
logger.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) error instanceof Error ? error.message : String(error)
}` }`
); );
@ -1626,7 +1588,7 @@ async function restorePreviousMembersMetaSnapshot(options: {
return true; return true;
} catch (error) { } catch (error) {
logger.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) 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; teamName: string;
teamDataService: TeamDataService; teamDataService: TeamDataService;
provisioning: TeamProvisioningService; provisioning: TeamProvisioningService;
previousMembers: RuntimeRosterMutationMember[]; previousMembers: RuntimeRosterMutationMember[];
previousMembersMeta: TeamMembersMetaFile | null; previousMembersMeta: TeamMembersMetaFile | null;
restoreOpenCodeMemberNames?: string[]; restoreLiveMemberNames?: string[];
detachOpenCodeMemberNames?: string[]; detachLiveMemberNames?: string[];
}): Promise<void> { }): Promise<void> {
const { const {
teamName, teamName,
@ -1649,10 +1611,25 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
provisioning, provisioning,
previousMembers, previousMembers,
previousMembersMeta, previousMembersMeta,
restoreOpenCodeMemberNames = [], restoreLiveMemberNames = [],
detachOpenCodeMemberNames = [], detachLiveMemberNames = [],
} = options; } = 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({ const metadataRestored = await restorePreviousMembersMetaSnapshot({
teamName, teamName,
teamDataService, teamDataService,
@ -1663,36 +1640,21 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
invalidateTeamRosterSnapshotCaches(teamName); 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) { if (!metadataRestored) {
return; return;
} }
const restoreNames = Array.from( 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) { for (const memberName of restoreNames) {
try { try {
await provisioning.reattachOpenCodeOwnedMemberLane(teamName, memberName, { await provisioning.attachLiveRosterMember(teamName, memberName, {
reason: 'member_updated', reason: 'member_updated',
}); });
} catch (error) { } catch (error) {
logger.warn( 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) error instanceof Error ? error.message : String(error)
}` }`
); );
@ -4234,14 +4196,26 @@ async function handleAddMember(
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' }; 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; name?: unknown;
role?: unknown; role?: unknown;
workflow?: unknown; workflow?: unknown;
isolation?: unknown; isolation?: unknown;
providerId?: unknown; providerId?: unknown;
providerBackendId?: unknown;
model?: unknown; model?: unknown;
effort?: unknown; effort?: unknown;
fastMode?: unknown;
mcpPolicy?: unknown; mcpPolicy?: unknown;
}; };
const vName = validateTeammateName(name); const vName = validateTeammateName(name);
@ -4259,6 +4233,13 @@ async function handleAddMember(
if (!providerValidation.valid) { if (!providerValidation.valid) {
return { success: false, error: providerValidation.error }; 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') { if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'model must be a string' }; return { success: false, error: 'model must be a string' };
} }
@ -4269,6 +4250,10 @@ async function handleAddMember(
if (!effortValidation.valid) { if (!effortValidation.valid) {
return { success: false, error: effortValidation.error }; return { success: false, error: effortValidation.error };
} }
const fastModeValidation = parseOptionalTeamFastMode(fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
return wrapTeamHandler('addMember', async () => { return wrapTeamHandler('addMember', async () => {
const tn = vTeam.value!; const tn = vTeam.value!;
@ -4289,69 +4274,32 @@ async function handleAddMember(
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined, workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value, providerId: providerValidation.value,
...(providerBackendValidation.value
? { providerBackendId: providerBackendValidation.value }
: {}),
model: typeof model === 'string' ? model.trim() || undefined : undefined, model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value, effort: effortValidation.value,
...(fastModeValidation.value ? { fastMode: fastModeValidation.value } : {}),
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy), mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
}); });
invalidateTeamRosterSnapshotCaches(tn); invalidateTeamRosterSnapshotCaches(tn);
// If team is alive, notify the lead to spawn the new teammate
if (isTeamAlive) { if (isTeamAlive) {
if (providerValidation.value === 'opencode') {
try { try {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, memberName, { await provisioning.attachLiveRosterMember(tn, memberName, {
reason: 'member_added', reason: 'member_added',
}); });
} catch (error) { } catch (error) {
await rollbackOpenCodeLiveRosterMutation({ await rollbackLiveRosterMutation({
teamName: tn, teamName: tn,
teamDataService, teamDataService,
provisioning, provisioning,
previousMembers, previousMembers,
previousMembersMeta, previousMembersMeta,
detachOpenCodeMemberNames: [memberName], detachLiveMemberNames: [memberName],
}); });
throw error; 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),
},
});
} catch (error) {
// Best-effort: lead process may not be responsive
logger.warn(
`Failed to notify lead about new member "${memberName}" in ${tn}: ${getErrorMessage(error)}`
);
}
} }
}); });
} }
@ -4531,69 +4479,63 @@ async function handleReplaceMembers(
return; 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 { try {
for (const removedMember of removedOpenCodeMembers) { for (const removedMember of removedOpenCodeMembers) {
await provisioning.detachOpenCodeOwnedMemberLane(tn, removedMember.name); await provisioning.detachLiveRosterMember(tn, removedMember.name);
} }
for (const addedMember of addedOpenCodeMembers) { for (const addedMember of addedOpenCodeMembers) {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, addedMember.name, { await provisioning.attachLiveRosterMember(tn, addedMember.name, {
reason: 'member_added', reason: 'member_added',
}); });
} }
for (const updatedMember of updatedOpenCodeMembers) { 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', reason: 'member_updated',
}); });
} }
} catch (error) { } catch (error) {
await rollbackOpenCodeLiveRosterMutation({ await rollbackLiveRosterMutation({
teamName: tn, teamName: tn,
teamDataService, teamDataService,
provisioning, provisioning,
previousMembers, previousMembers,
previousMembersMeta, previousMembersMeta,
restoreOpenCodeMemberNames: [ restoreLiveMemberNames: [
...removedOpenCodeMembers.map((member) => member.name), ...removedOpenCodeMembers.map((member) => member.name),
...primaryDiff.removed,
...updatedOpenCodeMembers.map((member) => member.name), ...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; throw error;
} }
for (const addedMember of primaryDiff.added) { const summaryMessage = buildReplaceMembersSummaryMessage({
try { ...primaryDiff,
await sendLiveAddMemberSpawnPrompt({ updated: [],
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);
if (!summaryMessage) { if (!summaryMessage) {
return; return;
} }
@ -4627,30 +4569,23 @@ async function handleRemoveMember(
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) { if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE); 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); await teamDataService.removeMember(tn, name);
invalidateTeamRosterSnapshotCaches(tn); invalidateTeamRosterSnapshotCaches(tn);
// Notify the lead about removed member
if (isTeamAlive) { if (isTeamAlive) {
if (isOpenCodeRosterMutationMember(removedMember)) {
try { try {
await provisioning.detachOpenCodeOwnedMemberLane(tn, name); await provisioning.detachLiveRosterMember(tn, name);
} catch (error) { } catch (error) {
await rollbackOpenCodeLiveRosterMutation({ await rollbackLiveRosterMutation({
teamName: tn, teamName: tn,
teamDataService, teamDataService,
provisioning, provisioning,
previousMembers, previousMembers,
previousMembersMeta, previousMembersMeta,
restoreOpenCodeMemberNames: [name], restoreLiveMemberNames: [name],
}); });
throw error; throw error;
} }
return;
}
const message = const message =
`Teammate "${name}" has been removed from the team. ` + `Teammate "${name}" has been removed from the team. ` +
@ -4687,59 +4622,28 @@ async function handleRestoreMember(
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE); throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
} }
const restoredMember = await teamDataService.restoreMember(tn, name); await teamDataService.restoreMember(tn, name);
invalidateTeamRosterSnapshotCaches(tn); invalidateTeamRosterSnapshotCaches(tn);
if (!isTeamAlive) { if (!isTeamAlive) {
return; return;
} }
if (isOpenCodeRosterMutationMember(restoredMember)) {
try { try {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, name, { await provisioning.attachLiveRosterMember(tn, name, {
reason: 'member_added', reason: 'member_restored',
}); });
} catch (error) { } catch (error) {
await rollbackOpenCodeLiveRosterMutation({ await rollbackLiveRosterMutation({
teamName: tn, teamName: tn,
teamDataService, teamDataService,
provisioning, provisioning,
previousMembers, previousMembers,
previousMembersMeta, previousMembersMeta,
detachOpenCodeMemberNames: [name], detachLiveMemberNames: [name],
}); });
throw error; 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,
});
} catch (error) {
logger.warn(
`Failed to notify lead about restore of "${name}" in ${tn}: ${getErrorMessage(error)}`
);
}
}); });
} }

View file

@ -1868,14 +1868,22 @@ export class TeamDataService {
throw new Error(`Member "${name}" already exists`); throw new Error(`Member "${name}" already exists`);
} }
const memberProviderId = normalizeOptionalTeamProviderId(request.providerId);
const memberProviderBackendId = memberProviderId
? migrateProviderBackendId(memberProviderId, request.providerBackendId)
: request.providerBackendId;
const newMember: TeamMember = { const newMember: TeamMember = {
name, name,
role: request.role?.trim() || undefined, role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined, workflow: request.workflow?.trim() || undefined,
isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(request.providerId), providerId: memberProviderId,
...(memberProviderBackendId ? { providerBackendId: memberProviderBackendId } : {}),
model: request.model?.trim() || undefined, model: request.model?.trim() || undefined,
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined, effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
...(request.fastMode === 'inherit' || request.fastMode === 'on' || request.fastMode === 'off'
? { fastMode: request.fastMode }
: {}),
mcpPolicy: normalizeTeamMemberMcpPolicy(request.mcpPolicy), mcpPolicy: normalizeTeamMemberMcpPolicy(request.mcpPolicy),
agentType: 'general-purpose', agentType: 'general-purpose',
joinedAt: Date.now(), joinedAt: Date.now(),
@ -1940,13 +1948,17 @@ export class TeamDataService {
nextByName.add(name.toLowerCase()); nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase()); const prev = existingByName.get(name.toLowerCase());
const isSameActiveMember = Boolean(prev && prev.removedAt == null); const isSameActiveMember = Boolean(prev && prev.removedAt == null);
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const providerBackendId = providerId
? migrateProviderBackendId(providerId, member.providerBackendId)
: member.providerBackendId;
return { return {
name, name,
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId,
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId), providerBackendId,
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
fastMode: fastMode:

View file

@ -2305,7 +2305,14 @@ type MemberLifecycleOperationKind =
| 'opencode_retry' | 'opencode_retry'
| 'opencode_member_added' | 'opencode_member_added'
| 'opencode_member_updated' | '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 { interface MemberLifecycleOperation {
kind: MemberLifecycleOperationKind; 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<ProviderModelLaunchIdentity> {
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( async getClaudeLogs(
teamName: string, teamName: string,
query?: { offset?: number; limit?: number } query?: { offset?: number; limit?: number }
@ -9636,21 +9686,60 @@ export class TeamProvisioningService {
member: TeamCreateRequest['members'][number] member: TeamCreateRequest['members'][number]
): void { ): void {
const normalizedName = member.name.trim().toLowerCase(); 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 (candidate) => candidate.name.trim().toLowerCase() !== normalizedName
); );
nextMembers.push(member); nextMembers.push(member);
run.allEffectiveMembers = nextMembers; 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 { private removeRunAllEffectiveMember(run: ProvisioningRun, memberName: string): void {
const normalizedName = memberName.trim().toLowerCase(); 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 (candidate) => candidate.name.trim().toLowerCase() !== normalizedName
); );
run.allEffectiveMembers = nextMembers; 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 { private hasSecondaryRuntimeRuns(teamName: string): boolean {
@ -10214,6 +10303,39 @@ export class TeamProvisioningService {
}; };
} }
private buildPrimaryOwnedMemberSpecForRuntime(input: {
configuredMember: NonNullable<
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
>;
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: { private async buildRuntimeBootstrapMemberMcpLaunchConfigs(input: {
cwd: string; cwd: string;
members: TeamCreateRequest['members']; members: TeamCreateRequest['members'];
@ -14866,7 +14988,7 @@ export class TeamProvisioningService {
private async updateDirectTmuxRestartMemberConfig(input: { private async updateDirectTmuxRestartMemberConfig(input: {
teamName: string; teamName: string;
memberName: string; memberName: string;
member: NonNullable<ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>>; member: TeamCreateRequest['members'][number] & { agentType?: string };
agentId: string; agentId: string;
color: string; color: string;
prompt: string; prompt: string;
@ -14954,8 +15076,11 @@ export class TeamProvisioningService {
leadName: string; leadName: string;
leadSessionId: string | null; leadSessionId: string | null;
prompt: string; prompt: string;
operation?: DirectProcessMemberLaunchReason;
}): void { }): void {
const timestamp = nowIso(); const timestamp = nowIso();
const operation = input.operation ?? 'manual_restart';
const isRestart = operation === 'manual_restart';
this.persistInboxMessage(input.teamName, input.memberName, { this.persistInboxMessage(input.teamName, input.memberName, {
from: input.leadName, from: input.leadName,
to: input.memberName, to: input.memberName,
@ -14964,8 +15089,10 @@ export class TeamProvisioningService {
read: false, read: false,
source: 'system_notification', source: 'system_notification',
leadSessionId: input.leadSessionId ?? undefined, leadSessionId: input.leadSessionId ?? undefined,
messageId: `direct-restart-${input.memberName}-${randomUUID()}`, messageId: `direct-${operation}-${input.memberName}-${randomUUID()}`,
summary: `Restart bootstrap instructions for ${input.memberName}`, 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(); const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) { if (!claudePath) {
throw buildMissingCliError(); throw buildMissingCliError();
@ -15041,22 +15167,55 @@ export class TeamProvisioningService {
}); });
await ensureCwdExists(cwd); 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, providerId,
input.configuredMember.providerBackendId, preliminaryMemberSpec.providerBackendId
{ );
const provisioningEnv = await this.buildProvisioningEnv(providerId, providerBackendId, {
teamRuntimeAuth: { teamRuntimeAuth: {
teamName: input.teamName, teamName: input.teamName,
authMaterialId: `${input.run.runId}-direct-${input.configuredMember.name}-${randomUUID()}`, authMaterialId: `${input.run.runId}-direct-${input.configuredMember.name}-${randomUUID()}`,
allowAnthropicApiKeyHelper: true, allowAnthropicApiKeyHelper: true,
}, },
} });
);
if (provisioningEnv.warning) { if (provisioningEnv.warning) {
throw new Error(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, { const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, {
mcpPolicy: memberMcpPolicy, mcpPolicy: memberMcpPolicy,
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL, controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
@ -15074,16 +15233,7 @@ export class TeamProvisioningService {
const parentSessionId = const parentSessionId =
input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId; input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId;
const prompt = buildMemberSpawnPrompt( const prompt = buildMemberSpawnPrompt(
{ memberSpec,
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 } : {}),
},
input.displayName, input.displayName,
input.teamName, input.teamName,
input.leadName, input.leadName,
@ -15093,7 +15243,7 @@ export class TeamProvisioningService {
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
teamName: input.teamName, teamName: input.teamName,
providerId, providerId,
launchIdentity: null, launchIdentity,
envResolution: provisioningEnv, envResolution: provisioningEnv,
extraArgs: [], extraArgs: [],
includeAnthropicHelper: providerId === 'anthropic', includeAnthropicHelper: providerId === 'anthropic',
@ -15128,8 +15278,8 @@ export class TeamProvisioningService {
...(input.run.request.skipPermissions !== false ...(input.run.request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), ...(memberSpec.model ? ['--model', memberSpec.model] : []),
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), ...(memberSpec.effort ? ['--effort', memberSpec.effort] : []),
...runtimeArgsPlan.fastModeArgs, ...runtimeArgsPlan.fastModeArgs,
...runtimeArgsPlan.runtimeTurnSettledHookArgs, ...runtimeArgsPlan.runtimeTurnSettledHookArgs,
...runtimeArgsPlan.providerArgs, ...runtimeArgsPlan.providerArgs,
@ -15146,7 +15296,7 @@ export class TeamProvisioningService {
await this.updateDirectTmuxRestartMemberConfig({ await this.updateDirectTmuxRestartMemberConfig({
teamName: input.teamName, teamName: input.teamName,
memberName: input.memberName, memberName: input.memberName,
member: input.configuredMember, member: memberSpec,
agentId, agentId,
color, color,
prompt, prompt,
@ -15162,6 +15312,7 @@ export class TeamProvisioningService {
leadName: input.leadName, leadName: input.leadName,
leadSessionId: parentSessionId, leadSessionId: parentSessionId,
prompt, prompt,
operation,
}); });
await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command); await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command);
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
@ -15183,8 +15334,9 @@ export class TeamProvisioningService {
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']> ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
>; >;
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[]; persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[];
operation?: DirectProcessMemberLaunchReason;
}): Promise<void> { }): Promise<void> {
const providerId = resolveTeamProviderId(input.configuredMember.providerId); const operation = input.operation ?? 'manual_restart';
const claudePath = input.run.spawnContext?.claudePath ?? (await ClaudeBinaryResolver.resolve()); const claudePath = input.run.spawnContext?.claudePath ?? (await ClaudeBinaryResolver.resolve());
if (!claudePath) { if (!claudePath) {
throw buildMissingCliError(); throw buildMissingCliError();
@ -15198,22 +15350,54 @@ export class TeamProvisioningService {
}); });
await ensureCwdExists(cwd); 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, providerId,
input.configuredMember.providerBackendId, preliminaryMemberSpec.providerBackendId
{ );
const provisioningEnv = await this.buildProvisioningEnv(providerId, providerBackendId, {
teamRuntimeAuth: { teamRuntimeAuth: {
teamName: input.teamName, teamName: input.teamName,
authMaterialId: `${input.run.runId}-process-restart-${input.configuredMember.name}-${randomUUID()}`, authMaterialId: `${input.run.runId}-process-${operation}-${input.configuredMember.name}-${randomUUID()}`,
allowAnthropicApiKeyHelper: true, allowAnthropicApiKeyHelper: true,
}, },
} });
);
if (provisioningEnv.warning) { if (provisioningEnv.warning) {
throw new Error(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, { const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, {
mcpPolicy: memberMcpPolicy, mcpPolicy: memberMcpPolicy,
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL, controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
@ -15230,33 +15414,12 @@ export class TeamProvisioningService {
?.color?.trim() || getMemberColorByName(input.configuredMember.name); ?.color?.trim() || getMemberColorByName(input.configuredMember.name);
const parentSessionId = const parentSessionId =
input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId; 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( const prompt = buildMemberSpawnPrompt(
memberSpec, memberSpec,
input.displayName, input.displayName,
input.teamName, input.teamName,
input.leadName, input.leadName,
{ operation === 'manual_restart' ? { restart: true } : undefined
restart: true,
}
); );
const bootstrapExpectedAfter = nowIso(); const bootstrapExpectedAfter = nowIso();
const bootstrapProofToken = randomUUID(); const bootstrapProofToken = randomUUID();
@ -15288,11 +15451,11 @@ export class TeamProvisioningService {
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
teamName: input.teamName, teamName: input.teamName,
providerId, providerId,
launchIdentity: null, launchIdentity,
envResolution: provisioningEnv, envResolution: provisioningEnv,
extraArgs: [], extraArgs: [],
includeAnthropicHelper: providerId === 'anthropic', includeAnthropicHelper: providerId === 'anthropic',
contextLabel: `Direct process teammate restart (${input.configuredMember.name})`, contextLabel: `Direct process teammate ${operation} (${input.configuredMember.name})`,
}); });
applyAppManagedRuntimeSettingsPathEnv( applyAppManagedRuntimeSettingsPathEnv(
provisioningEnv.env, provisioningEnv.env,
@ -15325,8 +15488,8 @@ export class TeamProvisioningService {
...(input.run.request.skipPermissions !== false ...(input.run.request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), ...(memberSpec.model ? ['--model', memberSpec.model] : []),
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), ...(memberSpec.effort ? ['--effort', memberSpec.effort] : []),
...runtimeArgsPlan.fastModeArgs, ...runtimeArgsPlan.fastModeArgs,
...runtimeArgsPlan.runtimeTurnSettledHookArgs, ...runtimeArgsPlan.runtimeTurnSettledHookArgs,
...runtimeArgsPlan.providerArgs, ...runtimeArgsPlan.providerArgs,
@ -15355,11 +15518,12 @@ export class TeamProvisioningService {
const runtimePid = child.pid; const runtimePid = child.pid;
const processPaneId = `process:${runtimePid}`; const processPaneId = `process:${runtimePid}`;
const runtimeEventSource = `TeamProvisioningService.direct_process_${operation}`;
child.stdout?.pipe(stdoutLog); child.stdout?.pipe(stdoutLog);
child.stderr?.pipe(stderrLog); child.stderr?.pipe(stderrLog);
child.stdin?.on('error', (error) => { child.stdin?.on('error', (error) => {
logger.debug( 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) => { child.once('close', (code, signal) => {
@ -15372,7 +15536,7 @@ export class TeamProvisioningService {
agentId, agentId,
runId: parentSessionId, runId: parentSessionId,
bootstrapRunId: input.run.runId, bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart', source: runtimeEventSource,
detail: detail:
code !== null code !== null
? `process exited with code ${code}` ? `process exited with code ${code}`
@ -15393,7 +15557,7 @@ export class TeamProvisioningService {
agentId, agentId,
runId: parentSessionId, runId: parentSessionId,
bootstrapRunId: input.run.runId, bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart', source: runtimeEventSource,
detail: `process error: ${error.message}`, detail: `process error: ${error.message}`,
}); });
}); });
@ -15402,6 +15566,7 @@ export class TeamProvisioningService {
(child.stderr as { unref?: () => void } | null)?.unref?.(); (child.stderr as { unref?: () => void } | null)?.unref?.();
child.unref(); child.unref();
try {
await this.appendDirectProcessRuntimeEvent({ await this.appendDirectProcessRuntimeEvent({
type: 'process_spawned', type: 'process_spawned',
eventsPath: runtimePaths.eventsPath, eventsPath: runtimePaths.eventsPath,
@ -15411,7 +15576,7 @@ export class TeamProvisioningService {
agentId, agentId,
runId: parentSessionId, runId: parentSessionId,
bootstrapRunId: input.run.runId, bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart', source: runtimeEventSource,
detail: 'process spawned', detail: 'process spawned',
}); });
await this.appendDirectProcessRuntimeEvent({ await this.appendDirectProcessRuntimeEvent({
@ -15423,14 +15588,13 @@ export class TeamProvisioningService {
agentId, agentId,
runId: parentSessionId, runId: parentSessionId,
bootstrapRunId: input.run.runId, bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart', source: runtimeEventSource,
detail: 'stdout and stderr attached', detail: 'stdout and stderr attached',
}); });
await this.updateDirectTmuxRestartMemberConfig({ await this.updateDirectTmuxRestartMemberConfig({
teamName: input.teamName, teamName: input.teamName,
memberName: input.memberName, memberName: input.memberName,
member: input.configuredMember, member: memberSpec,
agentId, agentId,
color, color,
prompt, prompt,
@ -15457,6 +15621,7 @@ export class TeamProvisioningService {
leadName: input.leadName, leadName: input.leadName,
leadSessionId: parentSessionId, leadSessionId: parentSessionId,
prompt, prompt,
operation,
}); });
await this.appendDirectProcessRuntimeEvent({ await this.appendDirectProcessRuntimeEvent({
type: 'mailbox_bootstrap_written', type: 'mailbox_bootstrap_written',
@ -15467,14 +15632,31 @@ export class TeamProvisioningService {
agentId, agentId,
runId: parentSessionId, runId: parentSessionId,
bootstrapRunId: input.run.runId, bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart', source: runtimeEventSource,
}); });
this.upsertRunAllEffectiveMember(input.run, memberSpec);
this.appendMemberBootstrapDiagnostic( this.appendMemberBootstrapDiagnostic(
input.run, input.run,
input.memberName, input.memberName,
`restart process spawned with pid ${runtimePid}` operation === 'manual_restart'
? `restart process spawned with pid ${runtimePid}`
: `runtime process spawned with pid ${runtimePid}`
); );
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting'); 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( private getDirectProcessRestartRuntimePaths(
@ -15626,6 +15808,362 @@ export class TeamProvisioningService {
return 'manual_restart'; 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<void> {
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<string, LiveTeamAgentRuntimeMetadata>;
actionLabel: string;
}): Promise<void> {
const pidsToStop = new Set<number>();
const tmuxPaneIdsToStop = new Set<string>();
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<void> {
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<string, LiveTeamAgentRuntimeMetadata>()
);
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<void> {
return this.runMemberLifecycleOperation(teamName, memberName, 'primary_member_removed', () =>
this.detachLiveRosterMemberUnlocked(teamName, memberName)
);
}
private async detachLiveRosterMemberUnlocked(
teamName: string,
memberName: string
): Promise<void> {
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<string, LiveTeamAgentRuntimeMetadata>()
);
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<void> { async restartMember(teamName: string, memberName: string): Promise<void> {
return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () => return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () =>
this.restartMemberUnlocked(teamName, memberName) this.restartMemberUnlocked(teamName, memberName)

View file

@ -1,4 +1,12 @@
import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types'; import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import type {
EffortLevel,
TeamFastMode,
TeamMemberMcpPolicy,
TeamProviderBackendId,
TeamProviderId,
} from '@shared/types';
export interface MemberDiffInput { export interface MemberDiffInput {
name: string; name: string;
@ -6,8 +14,10 @@ export interface MemberDiffInput {
workflow?: string; workflow?: string;
isolation?: 'worktree'; isolation?: 'worktree';
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy; mcpPolicy?: TeamMemberMcpPolicy;
removedAt?: number | string | null; removedAt?: number | string | null;
} }
@ -19,8 +29,10 @@ export interface ReplaceMembersDiff {
workflow?: string; workflow?: string;
isolation?: 'worktree'; isolation?: 'worktree';
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy; mcpPolicy?: TeamMemberMcpPolicy;
}[]; }[];
removed: string[]; removed: string[];
@ -77,6 +89,21 @@ function describeProviderChange(
return 'provider changed - restart required'; 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( function describeModelChange(
previousModel: string | undefined, previousModel: string | undefined,
nextModel: string | undefined nextModel: string | undefined
@ -97,6 +124,16 @@ function describeEffortChange(
return 'reasoning effort changed - restart required'; 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( function describeMcpPolicyChange(
previousMcpPolicy: TeamMemberMcpPolicy | undefined, previousMcpPolicy: TeamMemberMcpPolicy | undefined,
nextMcpPolicy: TeamMemberMcpPolicy | undefined nextMcpPolicy: TeamMemberMcpPolicy | undefined
@ -115,8 +152,10 @@ export function buildReplaceMembersDiff(
workflow?: string; workflow?: string;
isolation?: 'worktree'; isolation?: 'worktree';
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy; mcpPolicy?: TeamMemberMcpPolicy;
}[] }[]
): ReplaceMembersDiff { ): ReplaceMembersDiff {
@ -131,8 +170,10 @@ export function buildReplaceMembersDiff(
workflow: normalizeOptionalText(member.workflow), workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId, providerId: member.providerId,
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
model: normalizeOptionalText(member.model), model: normalizeOptionalText(member.model),
effort: member.effort, effort: member.effort,
fastMode: member.fastMode,
mcpPolicy: member.mcpPolicy, mcpPolicy: member.mcpPolicy,
}, },
]) ])
@ -148,8 +189,10 @@ export function buildReplaceMembersDiff(
workflow: normalizeOptionalText(member.workflow), workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId, providerId: member.providerId,
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
model: normalizeOptionalText(member.model), model: normalizeOptionalText(member.model),
effort: member.effort, effort: member.effort,
fastMode: member.fastMode,
mcpPolicy: member.mcpPolicy, mcpPolicy: member.mcpPolicy,
}, },
]) ])
@ -179,8 +222,15 @@ export function buildReplaceMembersDiff(
: 'worktree isolation disabled' : 'worktree isolation disabled'
: null, : null,
describeProviderChange(previousMember.providerId, nextMember.providerId), describeProviderChange(previousMember.providerId, nextMember.providerId),
describeProviderBackendChange(
previousMember.providerId,
previousMember.providerBackendId,
nextMember.providerId,
nextMember.providerBackendId
),
describeModelChange(previousMember.model, nextMember.model), describeModelChange(previousMember.model, nextMember.model),
describeEffortChange(previousMember.effort, nextMember.effort), describeEffortChange(previousMember.effort, nextMember.effort),
describeFastModeChange(previousMember.fastMode, nextMember.fastMode),
describeMcpPolicyChange(previousMember.mcpPolicy, nextMember.mcpPolicy), describeMcpPolicyChange(previousMember.mcpPolicy, nextMember.mcpPolicy),
].filter((value): value is string => value !== null); ].filter((value): value is string => value !== null);
if (changes.length === 0) { if (changes.length === 0) {

View file

@ -3762,8 +3762,10 @@ export const TeamDetailView = memo(function TeamDetailView({
workflow: entry.workflow, workflow: entry.workflow,
isolation: entry.isolation, isolation: entry.isolation,
providerId: entry.providerId, providerId: entry.providerId,
providerBackendId: entry.providerBackendId,
model: entry.model, model: entry.model,
effort: entry.effort, effort: entry.effort,
fastMode: entry.fastMode,
mcpPolicy: entry.mcpPolicy, mcpPolicy: entry.mcpPolicy,
}); });
} }

View file

@ -21,7 +21,13 @@ import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; 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 { export interface AddMemberEntry {
name: string; name: string;
@ -29,8 +35,10 @@ export interface AddMemberEntry {
workflow?: string; workflow?: string;
isolation?: 'worktree'; isolation?: 'worktree';
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy; mcpPolicy?: TeamMemberMcpPolicy;
} }
@ -96,12 +104,6 @@ export const AddMemberDialog = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const wasOpenRef = useRef(open); 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( const validateName = useCallback(
(name: string): string | null => { (name: string): string | null => {
const trimmed = name.trim().toLowerCase(); const trimmed = name.trim().toLowerCase();
@ -154,8 +156,10 @@ export const AddMemberDialog = ({
workflow: m.workflow, workflow: m.workflow,
isolation: m.isolation, isolation: m.isolation,
providerId: m.providerId, providerId: m.providerId,
providerBackendId: m.providerBackendId,
model: m.model, model: m.model,
effort: m.effort, effort: m.effort,
fastMode: m.fastMode,
mcpPolicy: m.mcpPolicy, mcpPolicy: m.mcpPolicy,
})) }))
); );

View file

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

View file

@ -331,6 +331,8 @@ describe('ipc teams handlers', () => {
repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)), repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)),
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined), reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined), detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
attachLiveRosterMember: vi.fn(async () => undefined),
detachLiveRosterMember: vi.fn(async () => undefined),
}; };
const boardTaskActivityService = { const boardTaskActivityService = {
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []), getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
@ -409,6 +411,10 @@ describe('ipc teams handlers', () => {
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValue(null); provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValue(null);
provisioningService.discardLiveMemberMcpLaunchConfig.mockReset(); provisioningService.discardLiveMemberMcpLaunchConfig.mockReset();
provisioningService.discardLiveMemberMcpLaunchConfig.mockResolvedValue(undefined); provisioningService.discardLiveMemberMcpLaunchConfig.mockResolvedValue(undefined);
provisioningService.attachLiveRosterMember.mockReset();
provisioningService.attachLiveRosterMember.mockResolvedValue(undefined);
provisioningService.detachLiveRosterMember.mockReset();
provisioningService.detachLiveRosterMember.mockResolvedValue(undefined);
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset(); provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined); provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 }); 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 handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', { const result = (await handler({} as never, 'my-team', {
name: 'alice', name: 'alice',
@ -2788,35 +2794,45 @@ describe('ipc teams handlers', () => {
})) as { success: boolean }; })) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team', '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', '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', 'my-team',
expect.stringContaining( 'alice',
'Do NOT start work, claim tasks, or improvise workflow/task/process rules' { reason: 'member_added' }
)
);
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')
); );
}); });
it('passes Agent Teams MCP only launch overrides into live add-member Agent prompt', async () => { it('lets lifecycle own MCP launch config for live add-member', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live add project with spaces');
service.getTeamData.mockResolvedValueOnce({ service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team', teamName: 'my-team',
config: { name: 'My Team', projectPath }, config: { name: 'My Team' },
tasks: [], tasks: [],
members: [ members: [
{ {
@ -2830,11 +2846,6 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], 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 handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', { const result = (await handler({} as never, 'my-team', {
@ -2845,29 +2856,24 @@ describe('ipc teams handlers', () => {
})) as { success: boolean }; })) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team', 'my-team',
expect.stringContaining( 'alice',
'mcp_config="/tmp/codex live add/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' { 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 () => { it('rolls back live addMember metadata when lifecycle attach fails', async () => {
const mcpLaunchConfig = { mockGetMembersMetaFile.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live add/alice-orphan-risk.json', version: 1,
mcpSettingSources: 'user,project,local', providerBackendId: 'codex-native',
strictMcpConfig: true, members: [],
}; });
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce( provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
mcpLaunchConfig as never new Error('attach failed')
); );
provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('lead offline'));
const handler = handlers.get(TEAM_ADD_MEMBER)!; const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', { const result = (await handler({} as never, 'my-team', {
@ -2875,14 +2881,20 @@ describe('ipc teams handlers', () => {
role: 'developer', role: 'developer',
providerId: 'codex', providerId: 'codex',
mcpPolicy: { mode: 'appOnly' }, mcpPolicy: { mode: 'appOnly' },
})) as { success: boolean }; })) as { success: boolean; error?: string };
expect(result.success).toBe(true); expect(result.success).toBe(false);
expect(provisioningService.discardLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ expect(result.error).toContain('attach failed');
teamName: 'my-team', expect(mockWriteMembersMeta).toHaveBeenCalledWith('my-team', [], {
mcpLaunchConfig, 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 () => { it('rejects invalid team name', async () => {
@ -2935,11 +2947,11 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team'); expect(result.error).toContain('running OpenCode-led team');
expect(service.addMember).not.toHaveBeenCalled(); expect(service.addMember).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear(); 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)!; const handler = handlers.get(TEAM_ADD_MEMBER)!;
mockGetMembersMetaFile.mockResolvedValueOnce({ mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1, version: 1,
@ -2985,7 +2997,7 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}); });
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce( provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
new Error('reattach failed') new Error('reattach failed')
); );
@ -3030,7 +3042,7 @@ describe('ipc teams handlers', () => {
], ],
{ providerBackendId: 'codex-native' } { providerBackendId: 'codex-native' }
); );
expect(provisioningService.detachOpenCodeOwnedMemberLane).toHaveBeenCalledWith( expect(provisioningService.detachLiveRosterMember).toHaveBeenCalledWith(
'my-team', 'my-team',
'alice' 'alice'
); );
@ -3250,11 +3262,11 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team'); expect(result.error).toContain('running OpenCode-led team');
expect(service.removeMember).not.toHaveBeenCalled(); expect(service.removeMember).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear(); 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)!; const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
mockGetMembersMetaFile.mockResolvedValueOnce({ mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1, version: 1,
@ -3300,7 +3312,7 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}); });
provisioningService.detachOpenCodeOwnedMemberLane.mockRejectedValueOnce( provisioningService.detachLiveRosterMember.mockRejectedValueOnce(
new Error('detach failed') new Error('detach failed')
); );
@ -3333,7 +3345,7 @@ describe('ipc teams handlers', () => {
], ],
{ providerBackendId: undefined } { providerBackendId: undefined }
); );
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith( expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team', 'my-team',
'alice', 'alice',
{ reason: 'member_updated' } { reason: 'member_updated' }
@ -3362,11 +3374,10 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it('passes Agent Teams MCP only launch overrides into live restore-member Agent prompt', async () => { it('lets lifecycle own MCP launch config for live restore-member', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live restore project with spaces');
service.getTeamData.mockResolvedValueOnce({ service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team', teamName: 'my-team',
config: { name: 'My Team', projectPath }, config: { name: 'My Team' },
tasks: [], tasks: [],
members: [ members: [
{ {
@ -3394,30 +3405,21 @@ describe('ipc teams handlers', () => {
providerId: 'codex', providerId: 'codex',
mcpPolicy: { mode: 'appOnly' }, mcpPolicy: { mode: 'appOnly' },
} as never); } 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 handler = handlers.get(TEAM_RESTORE_MEMBER)!;
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean }; const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team', 'my-team',
expect.stringContaining( 'alice',
'mcp_config="/tmp/codex live restore/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' { 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)!; const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
service.restoreMember.mockResolvedValueOnce({ service.restoreMember.mockResolvedValueOnce({
name: 'alice', name: 'alice',
@ -3452,10 +3454,10 @@ describe('ipc teams handlers', () => {
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean }; const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith( expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team', 'my-team',
'alice', 'alice',
{ reason: 'member_added' } { reason: 'member_restored' }
); );
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
}); });
@ -3495,17 +3497,16 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team'); expect(result.error).toContain('running OpenCode-led team');
expect(service.restoreMember).not.toHaveBeenCalled(); expect(service.restoreMember).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear(); vi.mocked(console.error).mockClear();
}); });
}); });
describe('replaceMembers', () => { describe('replaceMembers', () => {
it('passes Agent Teams MCP only launch overrides into live replace-members added teammate prompt', async () => { it('attaches added teammates through lifecycle during live replaceMembers', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live replace project with spaces');
service.getTeamData.mockResolvedValueOnce({ service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team', teamName: 'my-team',
config: { name: 'My Team', projectPath }, config: { name: 'My Team' },
tasks: [], tasks: [],
members: [ members: [
{ {
@ -3519,11 +3520,6 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], 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 handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const result = (await handler({} as never, 'my-team', { const result = (await handler({} as never, 'my-team', {
@ -3538,20 +3534,16 @@ describe('ipc teams handlers', () => {
})) as { success: boolean }; })) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({ expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team', 'my-team',
expect.stringContaining( 'alice',
'mcp_config="/tmp/codex live replace/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true' { 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({ service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team', teamName: 'my-team',
config: { name: 'My Team' }, config: { name: 'My Team' },
@ -3589,11 +3581,13 @@ describe('ipc teams handlers', () => {
})) as { success: boolean }; })) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled(); expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team', '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 () => { 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.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team'); expect(result.error).toContain('running OpenCode-led team');
expect(service.replaceMembers).not.toHaveBeenCalled(); expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear(); 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)!; const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
mockGetMembersMetaFile.mockResolvedValueOnce({ mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1, version: 1,
@ -3696,7 +3690,7 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}); });
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce( provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
new Error('reattach failed') new Error('reattach failed')
); );
@ -3774,13 +3768,13 @@ describe('ipc teams handlers', () => {
], ],
{ providerBackendId: 'codex-native' } { providerBackendId: 'codex-native' }
); );
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith( expect(provisioningService.attachLiveRosterMember).toHaveBeenNthCalledWith(
1, 1,
'my-team', 'my-team',
'alice', 'alice',
{ reason: 'member_updated' } { reason: 'member_updated' }
); );
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith( expect(provisioningService.attachLiveRosterMember).toHaveBeenNthCalledWith(
2, 2,
'my-team', 'my-team',
'alice', 'alice',
@ -3832,8 +3826,8 @@ describe('ipc teams handlers', () => {
); );
expect(result.error).toContain('alice'); expect(result.error).toContain('alice');
expect(service.replaceMembers).not.toHaveBeenCalled(); expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear(); vi.mocked(console.error).mockClear();
}); });
@ -3880,8 +3874,8 @@ describe('ipc teams handlers', () => {
); );
expect(result.error).toContain('alice'); expect(result.error).toContain('alice');
expect(service.replaceMembers).not.toHaveBeenCalled(); expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled(); expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear(); vi.mocked(console.error).mockClear();
}); });
}); });

View file

@ -938,6 +938,51 @@ describe('TeamDataService', () => {
); );
}); });
it('persists member-level provider backend and fast mode during addMember', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => []),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never,
(() => ({ processes: { listProcesses: vi.fn(async () => []) } }) as never) as never,
{} as never,
{ getMeta: vi.fn(async () => ({ providerId: 'codex' })) } as never
);
await service.addMember('runtime-team', {
name: 'alice',
providerId: 'codex',
providerBackendId: 'codex-native',
fastMode: 'on',
});
expect(writeMembers).toHaveBeenCalledWith(
'runtime-team',
expect.arrayContaining([
expect.objectContaining({
name: 'alice',
providerId: 'codex',
providerBackendId: 'codex-native',
fastMode: 'on',
}),
])
);
});
it('allows multiple OpenCode teammates in replaceMembers drafts before they are persisted', async () => { it('allows multiple OpenCode teammates in replaceMembers drafts before they are persisted', async () => {
const writeMembers = vi.fn(async () => {}); const writeMembers = vi.fn(async () => {});
const membersMetaStore = { const membersMetaStore = {

View file

@ -15989,6 +15989,195 @@ describe('TeamProvisioningService', () => {
expect(run.pendingMemberRestarts.has('forge')).toBe(true); 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 () => { it('launches direct process teammate restarts with normal MCP settings inheritance', async () => {
const teamName = 'process-flags-team'; const teamName = 'process-flags-team';
const projectPath = path.join(tempProjectsBase, 'process-flags-project'); const projectPath = path.join(tempProjectsBase, 'process-flags-project');
@ -16004,6 +16193,130 @@ describe('TeamProvisioningService', () => {
}); });
vi.mocked(spawnCli).mockReturnValue(child as any); 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, { const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'), writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
} as any); } as any);
@ -16024,7 +16337,7 @@ describe('TeamProvisioningService', () => {
agentType: 'general-purpose', agentType: 'general-purpose',
}; };
const config = { const config = {
name: 'Process Flags Team', name: 'Process Event Failure Team',
projectPath, projectPath,
leadSessionId: 'lead-session-1', leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember], members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
@ -16035,6 +16348,7 @@ describe('TeamProvisioningService', () => {
authSource: 'openai_api_key', authSource: 'openai_api_key',
providerArgs: [], providerArgs: [],
})); }));
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async () => ({ providerId: 'codex' }));
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({ (svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [], fastModeArgs: [],
runtimeTurnSettledHookArgs: [], runtimeTurnSettledHookArgs: [],
@ -16047,34 +16361,28 @@ describe('TeamProvisioningService', () => {
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({})); (svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {}); (svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
(svc as any).enqueueDirectRestartPrompt = vi.fn(); (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({ await expect(
(svc as any).launchDirectProcessMemberRestart({
run, run,
teamName, teamName,
displayName: 'Process Flags Team', displayName: 'Process Event Failure Team',
leadName: 'team-lead', leadName: 'team-lead',
memberName: 'forge', memberName: 'forge',
config, config,
configuredMember, configuredMember,
persistedRuntimeMembers: [], 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)); 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 () => { it('launches direct process teammate restarts with strict per-member MCP policy', async () => {
@ -16135,6 +16443,8 @@ describe('TeamProvisioningService', () => {
authSource: 'openai_api_key', authSource: 'openai_api_key',
providerArgs: [], providerArgs: [],
})); }));
const launchIdentity = { providerId: 'codex' };
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async () => launchIdentity);
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({ (svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [], fastModeArgs: [],
runtimeTurnSettledHookArgs: [], runtimeTurnSettledHookArgs: [],

View file

@ -34,4 +34,37 @@ describe('member update notifications', () => {
'MCP access policy changed - restart required' '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',
],
},
]);
});
}); });