fix(team): launch live roster members directly

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

View file

@ -147,10 +147,6 @@ import { TeamConfigReader } from '../services/team/TeamConfigReader';
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import {
buildAddMemberSpawnMessage,
type RuntimeBootstrapMemberMcpLaunchConfig,
} from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
@ -1474,40 +1470,6 @@ function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
}
async function sendLiveAddMemberSpawnPrompt(input: {
provisioning: TeamProvisioningService;
teamName: string;
displayName: string;
leadName: string;
projectPath?: string;
member: RuntimeRosterMutationMember;
}): Promise<void> {
let mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null = null;
try {
mcpLaunchConfig = await input.provisioning.prepareLiveMemberMcpLaunchConfig({
teamName: input.teamName,
cwd: input.member.cwd?.trim() || input.projectPath,
mcpPolicy: input.member.mcpPolicy,
});
const spawnMessage = buildAddMemberSpawnMessage(
input.teamName,
input.displayName,
input.leadName,
input.member,
mcpLaunchConfig
);
await input.provisioning.sendMessageToTeam(input.teamName, spawnMessage);
} catch (error) {
await input.provisioning
.discardLiveMemberMcpLaunchConfig({
teamName: input.teamName,
mcpLaunchConfig,
})
.catch(() => {});
throw error;
}
}
function didOpenCodeRosterMemberChange(
previous: RuntimeRosterMutationMember | undefined,
next: RuntimeRosterMutationMember | undefined
@ -1611,7 +1573,7 @@ async function restorePreviousMembersMetaSnapshot(options: {
return true;
} catch (error) {
logger.error(
`Failed to restore exact live OpenCode roster metadata for ${teamName}: ${
`Failed to restore exact live roster metadata for ${teamName}: ${
error instanceof Error ? error.message : String(error)
}`
);
@ -1626,7 +1588,7 @@ async function restorePreviousMembersMetaSnapshot(options: {
return true;
} catch (error) {
logger.error(
`Failed to roll back fallback live OpenCode roster metadata for ${teamName}: ${
`Failed to roll back fallback live roster metadata for ${teamName}: ${
error instanceof Error ? error.message : String(error)
}`
);
@ -1634,14 +1596,14 @@ async function restorePreviousMembersMetaSnapshot(options: {
}
}
async function rollbackOpenCodeLiveRosterMutation(options: {
async function rollbackLiveRosterMutation(options: {
teamName: string;
teamDataService: TeamDataService;
provisioning: TeamProvisioningService;
previousMembers: RuntimeRosterMutationMember[];
previousMembersMeta: TeamMembersMetaFile | null;
restoreOpenCodeMemberNames?: string[];
detachOpenCodeMemberNames?: string[];
restoreLiveMemberNames?: string[];
detachLiveMemberNames?: string[];
}): Promise<void> {
const {
teamName,
@ -1649,10 +1611,25 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
provisioning,
previousMembers,
previousMembersMeta,
restoreOpenCodeMemberNames = [],
detachOpenCodeMemberNames = [],
restoreLiveMemberNames = [],
detachLiveMemberNames = [],
} = options;
const detachNames = Array.from(
new Set(detachLiveMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
);
for (const memberName of detachNames) {
try {
await provisioning.detachLiveRosterMember(teamName, memberName);
} catch (error) {
logger.warn(
`Failed to clean up live roster member for ${teamName}/${memberName} during rollback: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const metadataRestored = await restorePreviousMembersMetaSnapshot({
teamName,
teamDataService,
@ -1663,36 +1640,21 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
invalidateTeamRosterSnapshotCaches(teamName);
}
const detachNames = Array.from(
new Set(detachOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
);
for (const memberName of detachNames) {
try {
await provisioning.detachOpenCodeOwnedMemberLane(teamName, memberName);
} catch (error) {
logger.warn(
`Failed to clean up OpenCode lane for ${teamName}/${memberName} during rollback: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
if (!metadataRestored) {
return;
}
const restoreNames = Array.from(
new Set(restoreOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
new Set(restoreLiveMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
);
for (const memberName of restoreNames) {
try {
await provisioning.reattachOpenCodeOwnedMemberLane(teamName, memberName, {
await provisioning.attachLiveRosterMember(teamName, memberName, {
reason: 'member_updated',
});
} catch (error) {
logger.warn(
`Failed to restore OpenCode lane for ${teamName}/${memberName} during rollback: ${
`Failed to restore live roster member for ${teamName}/${memberName} during rollback: ${
error instanceof Error ? error.message : String(error)
}`
);
@ -4234,14 +4196,26 @@ async function handleAddMember(
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' };
}
const { name, role, workflow, isolation, providerId, model, mcpPolicy } = payload as {
const {
name,
role,
workflow,
isolation,
providerId,
providerBackendId,
model,
fastMode,
mcpPolicy,
} = payload as {
name?: unknown;
role?: unknown;
workflow?: unknown;
isolation?: unknown;
providerId?: unknown;
providerBackendId?: unknown;
model?: unknown;
effort?: unknown;
fastMode?: unknown;
mcpPolicy?: unknown;
};
const vName = validateTeammateName(name);
@ -4259,6 +4233,13 @@ async function handleAddMember(
if (!providerValidation.valid) {
return { success: false, error: providerValidation.error };
}
const providerBackendValidation = parseOptionalProviderBackendId(
providerBackendId,
providerValidation.value
);
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'model must be a string' };
}
@ -4269,6 +4250,10 @@ async function handleAddMember(
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
return wrapTeamHandler('addMember', async () => {
const tn = vTeam.value!;
@ -4289,69 +4274,32 @@ async function handleAddMember(
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
...(providerBackendValidation.value
? { providerBackendId: providerBackendValidation.value }
: {}),
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
...(fastModeValidation.value ? { fastMode: fastModeValidation.value } : {}),
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
});
invalidateTeamRosterSnapshotCaches(tn);
// If team is alive, notify the lead to spawn the new teammate
if (isTeamAlive) {
if (providerValidation.value === 'opencode') {
try {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, memberName, {
await provisioning.attachLiveRosterMember(tn, memberName, {
reason: 'member_added',
});
} catch (error) {
await rollbackOpenCodeLiveRosterMutation({
await rollbackLiveRosterMutation({
teamName: tn,
teamDataService,
provisioning,
previousMembers,
previousMembersMeta,
detachOpenCodeMemberNames: [memberName],
detachLiveMemberNames: [memberName],
});
throw error;
}
return;
}
let leadName = 'team-lead';
let displayName = tn;
try {
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
teamDataService.getLeadMemberName(tn),
teamDataService.getTeamDisplayName(tn),
]);
leadName = resolvedLeadName || 'team-lead';
displayName = resolvedDisplayName || tn;
} catch {
// Best-effort: fall back to default lead and team names
}
try {
await sendLiveAddMemberSpawnPrompt({
provisioning,
teamName: tn,
displayName,
leadName,
projectPath: previousTeamData.config?.projectPath,
member: {
name: memberName,
...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}),
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
...(effortValidation.value ? { effort: effortValidation.value } : {}),
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
},
});
} 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;
}
let leadName = 'team-lead';
let displayName = tn;
try {
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
teamDataService.getLeadMemberName(tn),
teamDataService.getTeamDisplayName(tn),
]);
leadName = resolvedLeadName || 'team-lead';
displayName = resolvedDisplayName || tn;
} catch {
// Best-effort: fall back to default lead and team names
}
try {
for (const removedMember of removedOpenCodeMembers) {
await provisioning.detachOpenCodeOwnedMemberLane(tn, removedMember.name);
await provisioning.detachLiveRosterMember(tn, removedMember.name);
}
for (const addedMember of addedOpenCodeMembers) {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, addedMember.name, {
await provisioning.attachLiveRosterMember(tn, addedMember.name, {
reason: 'member_added',
});
}
for (const updatedMember of updatedOpenCodeMembers) {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, updatedMember.name, {
await provisioning.attachLiveRosterMember(tn, updatedMember.name, {
reason: 'member_updated',
});
}
for (const removedMemberName of primaryDiff.removed) {
await provisioning.detachLiveRosterMember(tn, removedMemberName);
}
for (const addedMember of primaryDiff.added) {
await provisioning.attachLiveRosterMember(tn, addedMember.name, {
reason: 'member_added',
});
}
for (const updatedMember of primaryDiff.updated) {
await provisioning.attachLiveRosterMember(tn, updatedMember.name, {
reason: 'member_updated',
});
}
} catch (error) {
await rollbackOpenCodeLiveRosterMutation({
await rollbackLiveRosterMutation({
teamName: tn,
teamDataService,
provisioning,
previousMembers,
previousMembersMeta,
restoreOpenCodeMemberNames: [
restoreLiveMemberNames: [
...removedOpenCodeMembers.map((member) => member.name),
...primaryDiff.removed,
...updatedOpenCodeMembers.map((member) => member.name),
...primaryDiff.updated.map((member) => member.name),
],
detachLiveMemberNames: [
...addedOpenCodeMembers.map((member) => member.name),
...primaryDiff.added.map((member) => member.name),
],
detachOpenCodeMemberNames: addedOpenCodeMembers.map((member) => member.name),
});
throw error;
}
for (const addedMember of primaryDiff.added) {
try {
await sendLiveAddMemberSpawnPrompt({
provisioning,
teamName: tn,
displayName,
leadName,
projectPath: previousTeamData.config?.projectPath,
member: addedMember,
const summaryMessage = buildReplaceMembersSummaryMessage({
...primaryDiff,
updated: [],
});
} catch (error) {
logger.warn(
`Failed to notify lead about new member "${addedMember.name}" in ${tn}: ${getErrorMessage(error)}`
);
}
}
const summaryMessage = buildReplaceMembersSummaryMessage(primaryDiff);
if (!summaryMessage) {
return;
}
@ -4627,30 +4569,23 @@ async function handleRemoveMember(
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
}
const removedMember = previousMembers.find(
(member) => member.name.trim().toLowerCase() === name.trim().toLowerCase()
);
await teamDataService.removeMember(tn, name);
invalidateTeamRosterSnapshotCaches(tn);
// Notify the lead about removed member
if (isTeamAlive) {
if (isOpenCodeRosterMutationMember(removedMember)) {
try {
await provisioning.detachOpenCodeOwnedMemberLane(tn, name);
await provisioning.detachLiveRosterMember(tn, name);
} catch (error) {
await rollbackOpenCodeLiveRosterMutation({
await rollbackLiveRosterMutation({
teamName: tn,
teamDataService,
provisioning,
previousMembers,
previousMembersMeta,
restoreOpenCodeMemberNames: [name],
restoreLiveMemberNames: [name],
});
throw error;
}
return;
}
const message =
`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);
}
const restoredMember = await teamDataService.restoreMember(tn, name);
await teamDataService.restoreMember(tn, name);
invalidateTeamRosterSnapshotCaches(tn);
if (!isTeamAlive) {
return;
}
if (isOpenCodeRosterMutationMember(restoredMember)) {
try {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, name, {
reason: 'member_added',
await provisioning.attachLiveRosterMember(tn, name, {
reason: 'member_restored',
});
} catch (error) {
await rollbackOpenCodeLiveRosterMutation({
await rollbackLiveRosterMutation({
teamName: tn,
teamDataService,
provisioning,
previousMembers,
previousMembersMeta,
detachOpenCodeMemberNames: [name],
detachLiveMemberNames: [name],
});
throw error;
}
return;
}
let leadName = 'team-lead';
let displayName = tn;
try {
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
teamDataService.getLeadMemberName(tn),
teamDataService.getTeamDisplayName(tn),
]);
leadName = resolvedLeadName || 'team-lead';
displayName = resolvedDisplayName || tn;
} catch {
// Best-effort: fall back to default lead and team names
}
try {
await sendLiveAddMemberSpawnPrompt({
provisioning,
teamName: tn,
displayName,
leadName,
projectPath: previousTeamData.config?.projectPath,
member: restoredMember,
});
} 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`);
}
const memberProviderId = normalizeOptionalTeamProviderId(request.providerId);
const memberProviderBackendId = memberProviderId
? migrateProviderBackendId(memberProviderId, request.providerBackendId)
: request.providerBackendId;
const newMember: TeamMember = {
name,
role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined,
isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(request.providerId),
providerId: memberProviderId,
...(memberProviderBackendId ? { providerBackendId: memberProviderBackendId } : {}),
model: request.model?.trim() || undefined,
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
...(request.fastMode === 'inherit' || request.fastMode === 'on' || request.fastMode === 'off'
? { fastMode: request.fastMode }
: {}),
mcpPolicy: normalizeTeamMemberMcpPolicy(request.mcpPolicy),
agentType: 'general-purpose',
joinedAt: Date.now(),
@ -1940,13 +1948,17 @@ export class TeamDataService {
nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase());
const isSameActiveMember = Boolean(prev && prev.removedAt == null);
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const providerBackendId = providerId
? migrateProviderBackendId(providerId, member.providerBackendId)
: member.providerBackendId;
return {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
providerId,
providerBackendId,
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
fastMode:

View file

@ -2305,7 +2305,14 @@ type MemberLifecycleOperationKind =
| 'opencode_retry'
| 'opencode_member_added'
| 'opencode_member_updated'
| 'opencode_member_removed';
| 'opencode_member_removed'
| 'primary_member_added'
| 'primary_member_restored'
| 'primary_member_updated'
| 'primary_member_removed';
type LiveRosterAttachReason = 'member_added' | 'member_restored' | 'member_updated';
type DirectProcessMemberLaunchReason = 'manual_restart' | LiveRosterAttachReason;
interface MemberLifecycleOperation {
kind: MemberLifecycleOperationKind;
@ -4987,6 +4994,49 @@ export class TeamProvisioningService {
});
}
private async resolveDirectMemberLaunchIdentity(input: {
claudePath: string;
cwd: string;
providerId: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
provisioningEnv: ProvisioningEnvResolution;
memberSpec: TeamCreateRequest['members'][number];
run: ProvisioningRun;
}): Promise<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(
teamName: string,
query?: { offset?: number; limit?: number }
@ -9636,21 +9686,60 @@ export class TeamProvisioningService {
member: TeamCreateRequest['members'][number]
): void {
const normalizedName = member.name.trim().toLowerCase();
const nextMembers = run.allEffectiveMembers.filter(
const currentMembers = Array.isArray(run.allEffectiveMembers) ? run.allEffectiveMembers : [];
const nextMembers = currentMembers.filter(
(candidate) => candidate.name.trim().toLowerCase() !== normalizedName
);
nextMembers.push(member);
run.allEffectiveMembers = nextMembers;
run.request.members = nextMembers;
run.request = {
...run.request,
members: nextMembers,
};
const laneIdentity = buildPlannedMemberLaneIdentity({
leadProviderId: resolveTeamProviderId(run.request.providerId),
member: {
name: member.name,
providerId: normalizeOptionalTeamProviderId(member.providerId),
},
});
const currentPrimaryMembers = Array.isArray(run.effectiveMembers) ? run.effectiveMembers : [];
const nextPrimaryMembers = currentPrimaryMembers.filter(
(candidate) => candidate.name.trim().toLowerCase() !== normalizedName
);
const currentExpectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : [];
const nextExpectedMembers = currentExpectedMembers.filter(
(candidate) => candidate.trim().toLowerCase() !== normalizedName
);
if (laneIdentity.laneKind === 'primary') {
run.effectiveMembers = [...nextPrimaryMembers, member];
run.expectedMembers = [...nextExpectedMembers, member.name.trim()].filter(Boolean);
} else {
run.effectiveMembers = nextPrimaryMembers;
run.expectedMembers = nextExpectedMembers;
}
}
private removeRunAllEffectiveMember(run: ProvisioningRun, memberName: string): void {
const normalizedName = memberName.trim().toLowerCase();
const nextMembers = run.allEffectiveMembers.filter(
const currentMembers = Array.isArray(run.allEffectiveMembers) ? run.allEffectiveMembers : [];
const nextMembers = currentMembers.filter(
(candidate) => candidate.name.trim().toLowerCase() !== normalizedName
);
run.allEffectiveMembers = nextMembers;
run.request.members = nextMembers;
run.request = {
...run.request,
members: nextMembers,
};
const currentPrimaryMembers = Array.isArray(run.effectiveMembers) ? run.effectiveMembers : [];
run.effectiveMembers = currentPrimaryMembers.filter(
(candidate) => candidate.name.trim().toLowerCase() !== normalizedName
);
const currentExpectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : [];
run.expectedMembers = currentExpectedMembers.filter(
(candidate) => candidate.trim().toLowerCase() !== normalizedName
);
}
private hasSecondaryRuntimeRuns(teamName: string): boolean {
@ -10214,6 +10303,39 @@ export class TeamProvisioningService {
};
}
private buildPrimaryOwnedMemberSpecForRuntime(input: {
configuredMember: NonNullable<
ReturnType<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: {
cwd: string;
members: TeamCreateRequest['members'];
@ -14866,7 +14988,7 @@ export class TeamProvisioningService {
private async updateDirectTmuxRestartMemberConfig(input: {
teamName: string;
memberName: string;
member: NonNullable<ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>>;
member: TeamCreateRequest['members'][number] & { agentType?: string };
agentId: string;
color: string;
prompt: string;
@ -14954,8 +15076,11 @@ export class TeamProvisioningService {
leadName: string;
leadSessionId: string | null;
prompt: string;
operation?: DirectProcessMemberLaunchReason;
}): void {
const timestamp = nowIso();
const operation = input.operation ?? 'manual_restart';
const isRestart = operation === 'manual_restart';
this.persistInboxMessage(input.teamName, input.memberName, {
from: input.leadName,
to: input.memberName,
@ -14964,8 +15089,10 @@ export class TeamProvisioningService {
read: false,
source: 'system_notification',
leadSessionId: input.leadSessionId ?? undefined,
messageId: `direct-restart-${input.memberName}-${randomUUID()}`,
summary: `Restart bootstrap instructions for ${input.memberName}`,
messageId: `direct-${operation}-${input.memberName}-${randomUUID()}`,
summary: isRestart
? `Restart bootstrap instructions for ${input.memberName}`
: `Bootstrap instructions for ${input.memberName}`,
});
}
@ -15027,7 +15154,6 @@ export class TeamProvisioningService {
);
}
const providerId = resolveTeamProviderId(input.configuredMember.providerId);
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw buildMissingCliError();
@ -15041,22 +15167,55 @@ export class TeamProvisioningService {
});
await ensureCwdExists(cwd);
const provisioningEnv = await this.buildProvisioningEnv(
const operation: DirectProcessMemberLaunchReason = 'manual_restart';
const preliminaryMemberSpec = this.buildPrimaryOwnedMemberSpecForRuntime({
configuredMember: input.configuredMember,
run: input.run,
});
const providerId = resolveTeamProviderId(preliminaryMemberSpec.providerId);
const providerBackendId = migrateProviderBackendId(
providerId,
input.configuredMember.providerBackendId,
{
preliminaryMemberSpec.providerBackendId
);
const provisioningEnv = await this.buildProvisioningEnv(providerId, providerBackendId, {
teamRuntimeAuth: {
teamName: input.teamName,
authMaterialId: `${input.run.runId}-direct-${input.configuredMember.name}-${randomUUID()}`,
allowAnthropicApiKeyHelper: true,
},
}
);
});
if (provisioningEnv.warning) {
throw new Error(provisioningEnv.warning);
}
const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy);
const [materializedMemberSpec] = await this.materializeEffectiveTeamMemberSpecs({
claudePath,
cwd,
members: [preliminaryMemberSpec],
defaults: {
providerId: resolveTeamProviderId(input.run.request.providerId),
model: input.run.request.model,
effort: input.run.request.effort,
},
primaryProviderId: providerId,
primaryEnv: provisioningEnv,
teamRuntimeAuth: {
teamName: input.teamName,
authMaterialId: `${input.run.runId}-direct-${operation}-${input.configuredMember.name}-defaults-${randomUUID()}`,
allowAnthropicApiKeyHelper: true,
},
});
const memberSpec = materializedMemberSpec ?? preliminaryMemberSpec;
const launchIdentity = await this.resolveDirectMemberLaunchIdentity({
claudePath,
cwd,
providerId,
...(providerBackendId ? { providerBackendId } : {}),
provisioningEnv,
memberSpec,
run: input.run,
});
const memberMcpPolicy = normalizeTeamMemberMcpPolicy(memberSpec.mcpPolicy);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, {
mcpPolicy: memberMcpPolicy,
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
@ -15074,16 +15233,7 @@ export class TeamProvisioningService {
const parentSessionId =
input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId;
const prompt = buildMemberSpawnPrompt(
{
name: input.configuredMember.name,
...(input.configuredMember.role ? { role: input.configuredMember.role } : {}),
...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}),
...(input.configuredMember.providerId
? { providerId: input.configuredMember.providerId }
: {}),
...(input.configuredMember.model ? { model: input.configuredMember.model } : {}),
...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}),
},
memberSpec,
input.displayName,
input.teamName,
input.leadName,
@ -15093,7 +15243,7 @@ export class TeamProvisioningService {
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
teamName: input.teamName,
providerId,
launchIdentity: null,
launchIdentity,
envResolution: provisioningEnv,
extraArgs: [],
includeAnthropicHelper: providerId === 'anthropic',
@ -15128,8 +15278,8 @@ export class TeamProvisioningService {
...(input.run.request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
...(memberSpec.model ? ['--model', memberSpec.model] : []),
...(memberSpec.effort ? ['--effort', memberSpec.effort] : []),
...runtimeArgsPlan.fastModeArgs,
...runtimeArgsPlan.runtimeTurnSettledHookArgs,
...runtimeArgsPlan.providerArgs,
@ -15146,7 +15296,7 @@ export class TeamProvisioningService {
await this.updateDirectTmuxRestartMemberConfig({
teamName: input.teamName,
memberName: input.memberName,
member: input.configuredMember,
member: memberSpec,
agentId,
color,
prompt,
@ -15162,6 +15312,7 @@ export class TeamProvisioningService {
leadName: input.leadName,
leadSessionId: parentSessionId,
prompt,
operation,
});
await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command);
this.appendMemberBootstrapDiagnostic(
@ -15183,8 +15334,9 @@ export class TeamProvisioningService {
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
>;
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[];
operation?: DirectProcessMemberLaunchReason;
}): Promise<void> {
const providerId = resolveTeamProviderId(input.configuredMember.providerId);
const operation = input.operation ?? 'manual_restart';
const claudePath = input.run.spawnContext?.claudePath ?? (await ClaudeBinaryResolver.resolve());
if (!claudePath) {
throw buildMissingCliError();
@ -15198,22 +15350,54 @@ export class TeamProvisioningService {
});
await ensureCwdExists(cwd);
const provisioningEnv = await this.buildProvisioningEnv(
const preliminaryMemberSpec = this.buildPrimaryOwnedMemberSpecForRuntime({
configuredMember: input.configuredMember,
run: input.run,
});
const providerId = resolveTeamProviderId(preliminaryMemberSpec.providerId);
const providerBackendId = migrateProviderBackendId(
providerId,
input.configuredMember.providerBackendId,
{
preliminaryMemberSpec.providerBackendId
);
const provisioningEnv = await this.buildProvisioningEnv(providerId, providerBackendId, {
teamRuntimeAuth: {
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,
},
}
);
});
if (provisioningEnv.warning) {
throw new Error(provisioningEnv.warning);
}
const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy);
const [materializedMemberSpec] = await this.materializeEffectiveTeamMemberSpecs({
claudePath,
cwd,
members: [preliminaryMemberSpec],
defaults: {
providerId: resolveTeamProviderId(input.run.request.providerId),
model: input.run.request.model,
effort: input.run.request.effort,
},
primaryProviderId: providerId,
primaryEnv: provisioningEnv,
teamRuntimeAuth: {
teamName: input.teamName,
authMaterialId: `${input.run.runId}-process-${operation}-${input.configuredMember.name}-defaults-${randomUUID()}`,
allowAnthropicApiKeyHelper: true,
},
});
const memberSpec = materializedMemberSpec ?? preliminaryMemberSpec;
const launchIdentity = await this.resolveDirectMemberLaunchIdentity({
claudePath,
cwd,
providerId,
...(providerBackendId ? { providerBackendId } : {}),
provisioningEnv,
memberSpec,
run: input.run,
});
const memberMcpPolicy = normalizeTeamMemberMcpPolicy(memberSpec.mcpPolicy);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, {
mcpPolicy: memberMcpPolicy,
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
@ -15230,33 +15414,12 @@ export class TeamProvisioningService {
?.color?.trim() || getMemberColorByName(input.configuredMember.name);
const parentSessionId =
input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId;
const memberSpec: TeamCreateRequest['members'][number] = {
name: input.configuredMember.name,
...(input.configuredMember.role ? { role: input.configuredMember.role } : {}),
...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}),
...(input.configuredMember.providerId
? { providerId: input.configuredMember.providerId }
: {}),
...(input.configuredMember.providerBackendId
? { providerBackendId: input.configuredMember.providerBackendId }
: {}),
...(input.configuredMember.model ? { model: input.configuredMember.model } : {}),
...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}),
...(input.configuredMember.agentType ? { agentType: input.configuredMember.agentType } : {}),
...(memberMcpPolicy ? { mcpPolicy: memberMcpPolicy } : {}),
...(input.configuredMember.isolation === 'worktree'
? { isolation: 'worktree' as const }
: {}),
...(input.configuredMember.cwd ? { cwd: input.configuredMember.cwd } : {}),
};
const prompt = buildMemberSpawnPrompt(
memberSpec,
input.displayName,
input.teamName,
input.leadName,
{
restart: true,
}
operation === 'manual_restart' ? { restart: true } : undefined
);
const bootstrapExpectedAfter = nowIso();
const bootstrapProofToken = randomUUID();
@ -15288,11 +15451,11 @@ export class TeamProvisioningService {
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
teamName: input.teamName,
providerId,
launchIdentity: null,
launchIdentity,
envResolution: provisioningEnv,
extraArgs: [],
includeAnthropicHelper: providerId === 'anthropic',
contextLabel: `Direct process teammate restart (${input.configuredMember.name})`,
contextLabel: `Direct process teammate ${operation} (${input.configuredMember.name})`,
});
applyAppManagedRuntimeSettingsPathEnv(
provisioningEnv.env,
@ -15325,8 +15488,8 @@ export class TeamProvisioningService {
...(input.run.request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
...(memberSpec.model ? ['--model', memberSpec.model] : []),
...(memberSpec.effort ? ['--effort', memberSpec.effort] : []),
...runtimeArgsPlan.fastModeArgs,
...runtimeArgsPlan.runtimeTurnSettledHookArgs,
...runtimeArgsPlan.providerArgs,
@ -15355,11 +15518,12 @@ export class TeamProvisioningService {
const runtimePid = child.pid;
const processPaneId = `process:${runtimePid}`;
const runtimeEventSource = `TeamProvisioningService.direct_process_${operation}`;
child.stdout?.pipe(stdoutLog);
child.stderr?.pipe(stderrLog);
child.stdin?.on('error', (error) => {
logger.debug(
`[${input.teamName}] Direct process restart stdin failed for ${agentId}: ${error.message}`
`[${input.teamName}] Direct process ${operation} stdin failed for ${agentId}: ${error.message}`
);
});
child.once('close', (code, signal) => {
@ -15372,7 +15536,7 @@ export class TeamProvisioningService {
agentId,
runId: parentSessionId,
bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart',
source: runtimeEventSource,
detail:
code !== null
? `process exited with code ${code}`
@ -15393,7 +15557,7 @@ export class TeamProvisioningService {
agentId,
runId: parentSessionId,
bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart',
source: runtimeEventSource,
detail: `process error: ${error.message}`,
});
});
@ -15402,6 +15566,7 @@ export class TeamProvisioningService {
(child.stderr as { unref?: () => void } | null)?.unref?.();
child.unref();
try {
await this.appendDirectProcessRuntimeEvent({
type: 'process_spawned',
eventsPath: runtimePaths.eventsPath,
@ -15411,7 +15576,7 @@ export class TeamProvisioningService {
agentId,
runId: parentSessionId,
bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart',
source: runtimeEventSource,
detail: 'process spawned',
});
await this.appendDirectProcessRuntimeEvent({
@ -15423,14 +15588,13 @@ export class TeamProvisioningService {
agentId,
runId: parentSessionId,
bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart',
source: runtimeEventSource,
detail: 'stdout and stderr attached',
});
await this.updateDirectTmuxRestartMemberConfig({
teamName: input.teamName,
memberName: input.memberName,
member: input.configuredMember,
member: memberSpec,
agentId,
color,
prompt,
@ -15457,6 +15621,7 @@ export class TeamProvisioningService {
leadName: input.leadName,
leadSessionId: parentSessionId,
prompt,
operation,
});
await this.appendDirectProcessRuntimeEvent({
type: 'mailbox_bootstrap_written',
@ -15467,14 +15632,31 @@ export class TeamProvisioningService {
agentId,
runId: parentSessionId,
bootstrapRunId: input.run.runId,
source: 'TeamProvisioningService.direct_process_restart',
source: runtimeEventSource,
});
this.upsertRunAllEffectiveMember(input.run, memberSpec);
this.appendMemberBootstrapDiagnostic(
input.run,
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');
} catch (error) {
try {
killProcessByPid(runtimePid);
} catch (killError) {
logger.warn(
`[${input.teamName}] Failed to stop orphaned direct process ${agentId} pid=${runtimePid}: ${
killError instanceof Error ? killError.message : String(killError)
}`
);
}
stdoutLog.end();
stderrLog.end();
throw error;
}
}
private getDirectProcessRestartRuntimePaths(
@ -15626,6 +15808,362 @@ export class TeamProvisioningService {
return 'manual_restart';
}
private getLiveRosterAttachLifecycleKind(
reason?: LiveRosterAttachReason
): MemberLifecycleOperationKind {
if (reason === 'member_restored') return 'primary_member_restored';
if (reason === 'member_updated') return 'primary_member_updated';
return 'primary_member_added';
}
async attachLiveRosterMember(
teamName: string,
memberName: string,
options?: { reason?: LiveRosterAttachReason }
): Promise<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> {
return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () =>
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 {
name: string;
@ -6,8 +14,10 @@ export interface MemberDiffInput {
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
removedAt?: number | string | null;
}
@ -19,8 +29,10 @@ export interface ReplaceMembersDiff {
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
}[];
removed: string[];
@ -77,6 +89,21 @@ function describeProviderChange(
return 'provider changed - restart required';
}
function describeProviderBackendChange(
previousProviderId: TeamProviderId | undefined,
previousProviderBackendId: TeamProviderBackendId | undefined,
nextProviderId: TeamProviderId | undefined,
nextProviderBackendId: TeamProviderBackendId | undefined
): string | null {
if (
migrateProviderBackendId(previousProviderId, previousProviderBackendId) ===
migrateProviderBackendId(nextProviderId, nextProviderBackendId)
) {
return null;
}
return 'provider backend changed - restart required';
}
function describeModelChange(
previousModel: string | undefined,
nextModel: string | undefined
@ -97,6 +124,16 @@ function describeEffortChange(
return 'reasoning effort changed - restart required';
}
function describeFastModeChange(
previousFastMode: TeamFastMode | undefined,
nextFastMode: TeamFastMode | undefined
): string | null {
if (previousFastMode === nextFastMode) {
return null;
}
return 'fast mode changed - restart required';
}
function describeMcpPolicyChange(
previousMcpPolicy: TeamMemberMcpPolicy | undefined,
nextMcpPolicy: TeamMemberMcpPolicy | undefined
@ -115,8 +152,10 @@ export function buildReplaceMembersDiff(
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
}[]
): ReplaceMembersDiff {
@ -131,8 +170,10 @@ export function buildReplaceMembersDiff(
workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
model: normalizeOptionalText(member.model),
effort: member.effort,
fastMode: member.fastMode,
mcpPolicy: member.mcpPolicy,
},
])
@ -148,8 +189,10 @@ export function buildReplaceMembersDiff(
workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
model: normalizeOptionalText(member.model),
effort: member.effort,
fastMode: member.fastMode,
mcpPolicy: member.mcpPolicy,
},
])
@ -179,8 +222,15 @@ export function buildReplaceMembersDiff(
: 'worktree isolation disabled'
: null,
describeProviderChange(previousMember.providerId, nextMember.providerId),
describeProviderBackendChange(
previousMember.providerId,
previousMember.providerBackendId,
nextMember.providerId,
nextMember.providerBackendId
),
describeModelChange(previousMember.model, nextMember.model),
describeEffortChange(previousMember.effort, nextMember.effort),
describeFastModeChange(previousMember.fastMode, nextMember.fastMode),
describeMcpPolicyChange(previousMember.mcpPolicy, nextMember.mcpPolicy),
].filter((value): value is string => value !== null);
if (changes.length === 0) {

View file

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

View file

@ -21,7 +21,13 @@ import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { Loader2 } from 'lucide-react';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types';
import type {
EffortLevel,
TeamFastMode,
TeamMemberMcpPolicy,
TeamProviderBackendId,
TeamProviderId,
} from '@shared/types';
export interface AddMemberEntry {
name: string;
@ -29,8 +35,10 @@ export interface AddMemberEntry {
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
mcpPolicy?: TeamMemberMcpPolicy;
}
@ -96,12 +104,6 @@ export const AddMemberDialog = ({
const [error, setError] = useState<string | null>(null);
const wasOpenRef = useRef(open);
// Combine existing names + names already in the draft list for duplicate validation
const allNames = useMemo(() => {
const draftNames = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
return [...existingNames.map((n) => n.toLowerCase()), ...draftNames];
}, [existingNames, members]);
const validateName = useCallback(
(name: string): string | null => {
const trimmed = name.trim().toLowerCase();
@ -154,8 +156,10 @@ export const AddMemberDialog = ({
workflow: m.workflow,
isolation: m.isolation,
providerId: m.providerId,
providerBackendId: m.providerBackendId,
model: m.model,
effort: m.effort,
fastMode: m.fastMode,
mcpPolicy: m.mcpPolicy,
}))
);

View file

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

View file

@ -331,6 +331,8 @@ describe('ipc teams handlers', () => {
repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)),
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
attachLiveRosterMember: vi.fn(async () => undefined),
detachLiveRosterMember: vi.fn(async () => undefined),
};
const boardTaskActivityService = {
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
@ -409,6 +411,10 @@ describe('ipc teams handlers', () => {
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValue(null);
provisioningService.discardLiveMemberMcpLaunchConfig.mockReset();
provisioningService.discardLiveMemberMcpLaunchConfig.mockResolvedValue(undefined);
provisioningService.attachLiveRosterMember.mockReset();
provisioningService.attachLiveRosterMember.mockResolvedValue(undefined);
provisioningService.detachLiveRosterMember.mockReset();
provisioningService.detachLiveRosterMember.mockResolvedValue(undefined);
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
@ -2779,7 +2785,7 @@ describe('ipc teams handlers', () => {
);
});
it('notifies a live lead to use member_briefing bootstrap for the new teammate', async () => {
it('attaches a live teammate through the lifecycle service', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
@ -2788,35 +2794,45 @@ describe('ipc teams handlers', () => {
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('and the exact prompt below:')
'alice',
{ reason: 'member_added' }
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('preserves runtime backend and fast mode when adding a live teammate', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
providerId: 'codex',
providerBackendId: 'codex-native',
fastMode: 'on',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.addMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Your FIRST action: call MCP tool member_briefing')
expect.objectContaining({
name: 'alice',
providerId: 'codex',
providerBackendId: 'codex-native',
fastMode: 'on',
})
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'Do NOT start work, claim tasks, or improvise workflow/task/process rules'
)
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('You are alice, a developer on team "My Team" (my-team).')
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Their workflow: Focus on frontend polish')
'alice',
{ reason: 'member_added' }
);
});
it('passes Agent Teams MCP only launch overrides into live add-member Agent prompt', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live add project with spaces');
it('lets lifecycle own MCP launch config for live add-member', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team', projectPath },
config: { name: 'My Team' },
tasks: [],
members: [
{
@ -2830,11 +2846,6 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live add/alice-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
} as never);
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
@ -2845,29 +2856,24 @@ describe('ipc teams handlers', () => {
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'mcp_config="/tmp/codex live add/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
)
'alice',
{ reason: 'member_added' }
);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('discards live add-member MCP config if lead notification fails after config creation', async () => {
const mcpLaunchConfig = {
mcpConfigPath: '/tmp/codex live add/alice-orphan-risk.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
};
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce(
mcpLaunchConfig as never
it('rolls back live addMember metadata when lifecycle attach fails', async () => {
mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1,
providerBackendId: 'codex-native',
members: [],
});
provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
new Error('attach failed')
);
provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('lead offline'));
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
@ -2875,14 +2881,20 @@ describe('ipc teams handlers', () => {
role: 'developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
})) as { success: boolean };
})) as { success: boolean; error?: string };
expect(result.success).toBe(true);
expect(provisioningService.discardLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
mcpLaunchConfig,
expect(result.success).toBe(false);
expect(result.error).toContain('attach failed');
expect(mockWriteMembersMeta).toHaveBeenCalledWith('my-team', [], {
providerBackendId: 'codex-native',
});
vi.mocked(console.warn).mockClear();
expect(provisioningService.detachLiveRosterMember).toHaveBeenCalledWith('my-team', 'alice');
const detachOrder = provisioningService.detachLiveRosterMember.mock.invocationCallOrder[0];
const metadataRestoreOrder = mockWriteMembersMeta.mock.invocationCallOrder[0];
expect(detachOrder).toBeDefined();
expect(metadataRestoreOrder).toBeDefined();
expect(detachOrder!).toBeLessThan(metadataRestoreOrder!);
vi.mocked(console.error).mockClear();
});
it('rejects invalid team name', async () => {
@ -2935,11 +2947,11 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team');
expect(service.addMember).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
it('rolls back live OpenCode addMember metadata when controlled reattach fails', async () => {
it('rolls back live OpenCode addMember metadata when lifecycle attach fails', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1,
@ -2985,7 +2997,7 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
new Error('reattach failed')
);
@ -3030,7 +3042,7 @@ describe('ipc teams handlers', () => {
],
{ providerBackendId: 'codex-native' }
);
expect(provisioningService.detachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
expect(provisioningService.detachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
'alice'
);
@ -3250,11 +3262,11 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team');
expect(service.removeMember).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
it('rolls back live OpenCode removeMember metadata when lane detach fails', async () => {
it('rolls back live removeMember metadata when lifecycle detach fails', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1,
@ -3300,7 +3312,7 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.detachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
provisioningService.detachLiveRosterMember.mockRejectedValueOnce(
new Error('detach failed')
);
@ -3333,7 +3345,7 @@ describe('ipc teams handlers', () => {
],
{ providerBackendId: undefined }
);
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
'alice',
{ reason: 'member_updated' }
@ -3362,11 +3374,10 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false);
});
it('passes Agent Teams MCP only launch overrides into live restore-member Agent prompt', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live restore project with spaces');
it('lets lifecycle own MCP launch config for live restore-member', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team', projectPath },
config: { name: 'My Team' },
tasks: [],
members: [
{
@ -3394,30 +3405,21 @@ describe('ipc teams handlers', () => {
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
} as never);
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live restore/alice-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
} as never);
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'mcp_config="/tmp/codex live restore/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
)
'alice',
{ reason: 'member_restored' }
);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('reattaches a restored OpenCode teammate on a live mixed team', async () => {
it('attaches a restored OpenCode teammate through the lifecycle service', async () => {
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
service.restoreMember.mockResolvedValueOnce({
name: 'alice',
@ -3452,10 +3454,10 @@ describe('ipc teams handlers', () => {
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
'alice',
{ reason: 'member_added' }
{ reason: 'member_restored' }
);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
@ -3495,17 +3497,16 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team');
expect(service.restoreMember).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
});
describe('replaceMembers', () => {
it('passes Agent Teams MCP only launch overrides into live replace-members added teammate prompt', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live replace project with spaces');
it('attaches added teammates through lifecycle during live replaceMembers', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team', projectPath },
config: { name: 'My Team' },
tasks: [],
members: [
{
@ -3519,11 +3520,6 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live replace/alice-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
} as never);
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const result = (await handler({} as never, 'my-team', {
@ -3538,20 +3534,16 @@ describe('ipc teams handlers', () => {
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'mcp_config="/tmp/codex live replace/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
)
'alice',
{ reason: 'member_added' }
);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('reports existing teammate MCP policy changes in live replace-members summary', async () => {
it('reattaches updated primary-owned teammates through lifecycle during live replaceMembers', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
@ -3589,11 +3581,13 @@ describe('ipc teams handlers', () => {
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('MCP access policy changed - restart required')
'alice',
{ reason: 'member_updated' }
);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('blocks live replaceMembers for a running OpenCode-led team before metadata is changed', async () => {
@ -3629,12 +3623,12 @@ describe('ipc teams handlers', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team');
expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
it('rolls back live OpenCode replaceMembers metadata when lane reattach fails', async () => {
it('rolls back live OpenCode replaceMembers metadata when lifecycle attach fails', async () => {
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
mockGetMembersMetaFile.mockResolvedValueOnce({
version: 1,
@ -3696,7 +3690,7 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.reattachOpenCodeOwnedMemberLane.mockRejectedValueOnce(
provisioningService.attachLiveRosterMember.mockRejectedValueOnce(
new Error('reattach failed')
);
@ -3774,13 +3768,13 @@ describe('ipc teams handlers', () => {
],
{ providerBackendId: 'codex-native' }
);
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenNthCalledWith(
1,
'my-team',
'alice',
{ reason: 'member_updated' }
);
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenNthCalledWith(
expect(provisioningService.attachLiveRosterMember).toHaveBeenNthCalledWith(
2,
'my-team',
'alice',
@ -3832,8 +3826,8 @@ describe('ipc teams handlers', () => {
);
expect(result.error).toContain('alice');
expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
@ -3880,8 +3874,8 @@ describe('ipc teams handlers', () => {
);
expect(result.error).toContain('alice');
expect(service.replaceMembers).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.detachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
expect(provisioningService.attachLiveRosterMember).not.toHaveBeenCalled();
expect(provisioningService.detachLiveRosterMember).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
});

View file

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

View file

@ -15989,6 +15989,195 @@ describe('TeamProvisioningService', () => {
expect(run.pendingMemberRestarts.has('forge')).toBe(true);
});
it.each([
['anthropic', undefined, 'anthropic'],
['codex', undefined, 'codex'],
['gemini', undefined, 'gemini'],
['codex', 'anthropic', 'anthropic'],
['anthropic', 'codex', 'codex'],
['codex', 'gemini', 'gemini'],
] as const)(
'attaches live primary-owned %s/%s teammate through direct process lifecycle',
async (leadProviderId, memberProviderId, expectedProviderId) => {
const teamName = `direct-live-${leadProviderId}-${memberProviderId ?? 'inherit'}`;
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName,
expectedMembers: [],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
run.request = { providerId: leadProviderId };
(svc as any).aliveRunByTeam.set(teamName, run.runId);
(svc as any).runs.set(run.runId, run);
const directProcessLaunch = vi.fn(async (input) => {
const memberSpec = (svc as any).buildPrimaryOwnedMemberSpecForRuntime({
configuredMember: input.configuredMember,
run: input.run,
});
expect(memberSpec.providerId).toBe(expectedProviderId);
});
const opencodeReattach = vi.fn(async () => {});
(svc as any).launchDirectProcessMemberRestart = directProcessLaunch;
(svc as any).reattachOpenCodeOwnedMemberLaneUnlocked = opencodeReattach;
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
name: 'Direct Live Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: leadProviderId },
{
name: 'forge',
role: 'Developer',
...(memberProviderId ? { providerId: memberProviderId } : {}),
agentType: 'general-purpose',
},
],
}));
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
await svc.attachLiveRosterMember(teamName, 'forge', { reason: 'member_added' });
expect(directProcessLaunch).toHaveBeenCalledWith(
expect.objectContaining({
teamName,
memberName: 'forge',
operation: 'member_added',
configuredMember: expect.objectContaining({
name: 'forge',
}),
})
);
expect(opencodeReattach).not.toHaveBeenCalled();
}
);
it('routes live OpenCode teammates through the OpenCode lane lifecycle', async () => {
const teamName = 'direct-live-opencode-member';
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName,
expectedMembers: [],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
run.request = { providerId: 'codex' };
(svc as any).aliveRunByTeam.set(teamName, run.runId);
(svc as any).runs.set(run.runId, run);
const directProcessLaunch = vi.fn(async () => {});
const opencodeReattach = vi.fn(async () => {});
(svc as any).launchDirectProcessMemberRestart = directProcessLaunch;
(svc as any).reattachOpenCodeOwnedMemberLaneUnlocked = opencodeReattach;
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
name: 'Direct Live OpenCode Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{
name: 'forge',
role: 'Developer',
providerId: 'opencode',
agentType: 'general-purpose',
},
],
}));
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
await svc.attachLiveRosterMember(teamName, 'forge', { reason: 'member_added' });
expect(opencodeReattach).toHaveBeenCalledWith(teamName, 'forge', {
reason: 'member_added',
});
expect(directProcessLaunch).not.toHaveBeenCalled();
});
it('blocks live primary-owned teammate attach for OpenCode-led teams', async () => {
const teamName = 'opencode-led-live-primary-member';
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName,
expectedMembers: [],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
run.request = { providerId: 'opencode' };
(svc as any).aliveRunByTeam.set(teamName, run.runId);
(svc as any).runs.set(run.runId, run);
const directProcessLaunch = vi.fn(async () => {});
const opencodeReattach = vi.fn(async () => {});
(svc as any).launchDirectProcessMemberRestart = directProcessLaunch;
(svc as any).reattachOpenCodeOwnedMemberLaneUnlocked = opencodeReattach;
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
name: 'OpenCode Led Live Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'opencode' },
{
name: 'forge',
role: 'Developer',
providerId: 'codex',
agentType: 'general-purpose',
},
],
}));
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
await expect(
svc.attachLiveRosterMember(teamName, 'forge', { reason: 'member_added' })
).rejects.toThrow('OpenCode-led mixed teams are not supported');
expect(directProcessLaunch).not.toHaveBeenCalled();
expect(opencodeReattach).not.toHaveBeenCalled();
});
it('blocks live primary-owned teammate detach for OpenCode-led teams', async () => {
const teamName = 'opencode-led-live-primary-detach';
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['forge'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
run.request = { providerId: 'opencode' };
(svc as any).aliveRunByTeam.set(teamName, run.runId);
(svc as any).runs.set(run.runId, run);
const opencodeDetach = vi.fn(async () => {});
const stopPrimaryRuntime = vi.fn(async () => {});
(svc as any).detachOpenCodeOwnedMemberLaneUnlocked = opencodeDetach;
(svc as any).stopPrimaryOwnedRosterRuntime = stopPrimaryRuntime;
(svc as any).readConfigForStrictDecision = vi.fn(async () => ({
name: 'OpenCode Led Live Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'opencode' },
{
name: 'forge',
role: 'Developer',
providerId: 'codex',
agentType: 'general-purpose',
},
],
}));
(svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) };
await expect(svc.detachLiveRosterMember(teamName, 'forge')).rejects.toThrow(
'OpenCode-led mixed teams are not supported'
);
expect(opencodeDetach).not.toHaveBeenCalled();
expect(stopPrimaryRuntime).not.toHaveBeenCalled();
});
it('launches direct process teammate restarts with normal MCP settings inheritance', async () => {
const teamName = 'process-flags-team';
const projectPath = path.join(tempProjectsBase, 'process-flags-project');
@ -16004,6 +16193,130 @@ describe('TeamProvisioningService', () => {
});
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
} as any);
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['atlas'],
memberSpawnStatuses: new Map(),
});
run.child = { pid: 111 };
run.request = { providerId: 'codex', skipPermissions: true, fastMode: 'on' };
run.detectedSessionId = 'lead-session-1';
const configuredMember = {
name: 'forge',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
agentType: 'general-purpose',
};
const config = {
name: 'Process Flags Team',
projectPath,
leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
};
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test-openai-key' },
authSource: 'openai_api_key',
providerArgs: [],
}));
const launchIdentity = {
providerId: 'codex',
providerBackendId: 'native',
selectedFastMode: 'on',
resolvedFastMode: true,
};
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async (input) => {
expect(input.memberSpec.fastMode).toBe('on');
return launchIdentity;
});
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async (input) => ({
fastModeArgs:
input.launchIdentity === launchIdentity ? ['--test-codex-fast-mode'] : [],
runtimeTurnSettledHookArgs: [],
providerArgs: [],
settingsArgs: [],
extraArgs: [],
inheritedProviderArgs: [],
appManagedSettingsPath: null,
}));
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
(svc as any).enqueueDirectRestartPrompt = vi.fn();
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
await (svc as any).launchDirectProcessMemberRestart({
run,
teamName,
displayName: 'Process Flags Team',
leadName: 'team-lead',
memberName: 'forge',
config,
configuredMember,
persistedRuntimeMembers: [],
operation: 'member_added',
});
child.emit('close', 0, null);
await new Promise((resolve) => setTimeout(resolve, 25));
expect((svc as any).resolveDirectMemberLaunchIdentity).toHaveBeenCalledWith(
expect.objectContaining({
memberSpec: expect.objectContaining({ fastMode: 'on' }),
})
);
expect((svc as any).buildTeamRuntimeLaunchArgsPlan).toHaveBeenCalledWith(
expect.objectContaining({ launchIdentity })
);
expect(run.expectedMembers).toEqual(['atlas', 'forge']);
expect(run.effectiveMembers).toEqual([
expect.objectContaining({
name: 'forge',
providerId: 'codex',
fastMode: 'on',
}),
]);
expect(run.allEffectiveMembers).toEqual([
expect.objectContaining({
name: 'forge',
providerId: 'codex',
fastMode: 'on',
}),
]);
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toEqual(
expect.arrayContaining([
'--teammate-runtime',
'headless',
'--setting-sources',
'user,project,local',
'--mcp-config',
'/mock/mcp-config.json',
'--test-codex-fast-mode',
])
);
expect(launchArgs).not.toContain('--strict-mcp-config');
});
it('stops a direct process teammate when post-spawn runtime event persistence fails', async () => {
const teamName = 'process-event-failure-team';
const projectPath = path.join(tempProjectsBase, 'process-event-failure-project');
fs.mkdirSync(projectPath, { recursive: true });
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
const child = Object.assign(new EventEmitter(), {
pid: 5678,
stdin: { on: vi.fn(), unref: vi.fn() },
stdout: { pipe: vi.fn(), unref: vi.fn() },
stderr: { pipe: vi.fn(), unref: vi.fn() },
unref: vi.fn(),
});
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
} as any);
@ -16024,7 +16337,7 @@ describe('TeamProvisioningService', () => {
agentType: 'general-purpose',
};
const config = {
name: 'Process Flags Team',
name: 'Process Event Failure Team',
projectPath,
leadSessionId: 'lead-session-1',
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
@ -16035,6 +16348,7 @@ describe('TeamProvisioningService', () => {
authSource: 'openai_api_key',
providerArgs: [],
}));
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async () => ({ providerId: 'codex' }));
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [],
runtimeTurnSettledHookArgs: [],
@ -16047,34 +16361,28 @@ describe('TeamProvisioningService', () => {
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
(svc as any).enqueueDirectRestartPrompt = vi.fn();
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
(svc as any).appendDirectProcessRuntimeEvent = vi
.fn()
.mockRejectedValueOnce(new Error('event write failed'));
await (svc as any).launchDirectProcessMemberRestart({
await expect(
(svc as any).launchDirectProcessMemberRestart({
run,
teamName,
displayName: 'Process Flags Team',
displayName: 'Process Event Failure Team',
leadName: 'team-lead',
memberName: 'forge',
config,
configuredMember,
persistedRuntimeMembers: [],
});
operation: 'member_added',
})
).rejects.toThrow('event write failed');
child.emit('close', 0, null);
expect(killProcessByPid).toHaveBeenCalledWith(5678);
expect((svc as any).updateDirectTmuxRestartMemberConfig).not.toHaveBeenCalled();
expect(run.allEffectiveMembers ?? []).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 25));
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toEqual(
expect.arrayContaining([
'--teammate-runtime',
'headless',
'--setting-sources',
'user,project,local',
'--mcp-config',
'/mock/mcp-config.json',
])
);
expect(launchArgs).not.toContain('--strict-mcp-config');
});
it('launches direct process teammate restarts with strict per-member MCP policy', async () => {
@ -16135,6 +16443,8 @@ describe('TeamProvisioningService', () => {
authSource: 'openai_api_key',
providerArgs: [],
}));
const launchIdentity = { providerId: 'codex' };
(svc as any).resolveDirectMemberLaunchIdentity = vi.fn(async () => launchIdentity);
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [],
runtimeTurnSettledHookArgs: [],

View file

@ -34,4 +34,37 @@ describe('member update notifications', () => {
'MCP access policy changed - restart required'
);
});
it('reports provider backend and fast mode changes as restart-required updates', () => {
const diff = buildReplaceMembersDiff(
[
{
name: 'alice',
role: 'Developer',
providerId: 'gemini',
providerBackendId: 'api',
fastMode: 'off',
},
],
[
{
name: 'alice',
role: 'Developer',
providerId: 'gemini',
providerBackendId: 'cli-sdk',
fastMode: 'on',
},
]
);
expect(diff.updated).toEqual([
{
name: 'alice',
changes: [
'provider backend changed - restart required',
'fast mode changed - restart required',
],
},
]);
});
});