fix(team): launch live roster members directly
This commit is contained in:
parent
ab3be12b94
commit
0d46aac5c0
11 changed files with 1401 additions and 507 deletions
|
|
@ -147,10 +147,6 @@ import { TeamConfigReader } from '../services/team/TeamConfigReader';
|
||||||
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
|
import { 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)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue