chore: checkpoint workspace before relaunch flow
This commit is contained in:
parent
fbf299f276
commit
1e2241aead
33 changed files with 4078 additions and 299 deletions
312
docs/research/codex-native-runtime-integration-decision.md
Normal file
312
docs/research/codex-native-runtime-integration-decision.md
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
# Codex Native Runtime Integration Decision
|
||||
|
||||
**Status**: Decision
|
||||
**Date**: 2026-04-19
|
||||
**Owner repos**:
|
||||
|
||||
- `claude_team`
|
||||
- `agent_teams_orchestrator`
|
||||
- `plugin-kit-ai`
|
||||
|
||||
## Purpose
|
||||
|
||||
Record the chosen direction for improving Codex integration in the multimodel runtime without losing native Codex capabilities such as plugins, skills, and MCP.
|
||||
|
||||
## Chosen Plan Assessment
|
||||
|
||||
- Chosen plan: normalized internal event/log layer plus staged `Codex-native` backend lane
|
||||
- Assessment: `🎯 9 🛡️ 9 🧠 7`
|
||||
- Estimated first serious wave: `2200-4500` lines across `agent_teams_orchestrator`, `claude_team`, and `plugin-kit-ai`
|
||||
|
||||
## Current Reality
|
||||
|
||||
Today, `Codex` inside our multimodel runtime is **not** executed through the real Codex runtime.
|
||||
|
||||
Instead, the current path is:
|
||||
|
||||
- `claude_team`
|
||||
- `agent_teams_orchestrator`
|
||||
- internal Codex backend
|
||||
- OpenAI Responses API
|
||||
|
||||
In practice this means:
|
||||
|
||||
- the orchestrator keeps Anthropic-style streaming semantics
|
||||
- `Codex` is treated as a model backend, not as a native runtime
|
||||
- native Codex plugins are not honestly end-to-end supported
|
||||
- current `Codex` capability support is limited by our adapter, not by the real Codex runtime
|
||||
|
||||
## What We Learned
|
||||
|
||||
After deep code and docs analysis, the most important conclusions are:
|
||||
|
||||
1. `@openai/codex-sdk` and `codex exec --json` are the real official execution seam for embedded Codex runtime usage.
|
||||
2. `codex exec` supports API-key mode, so API-key mode itself is not the blocker.
|
||||
3. `Codex` native plugins, apps, skills, and MCP are part of the real Codex runtime flow.
|
||||
4. Our current `agent_teams_orchestrator` query loop is deeply coupled to Anthropic-style events and tool semantics.
|
||||
5. A full drop-in swap from the current Codex adapter to `@openai/codex-sdk / codex exec` would not be a safe transport-only change. It would change runtime semantics.
|
||||
6. `plugin-kit-ai` is a good fit for plugin management and native plugin placement.
|
||||
7. `codex app-server` is promising for richer control-plane features, but should not be the foundation of the first production rollout for plugin management.
|
||||
|
||||
## Chosen Direction
|
||||
|
||||
We will **not** force Codex into the current Anthropic-shaped runtime contract.
|
||||
|
||||
We will instead:
|
||||
|
||||
- add a new **internal normalized event/log layer**
|
||||
- keep execution semantics provider-native where needed
|
||||
- add a separate **Codex-native runtime lane**
|
||||
- use `plugin-kit-ai` for plugin management and native plugin placement
|
||||
|
||||
In practical terms:
|
||||
|
||||
- current Codex path stays available as the fallback/default path at first
|
||||
- real Codex runtime execution becomes a separate lane instead of a drop-in replacement
|
||||
- unified logs come from normalization, not from pretending every provider has Anthropic-native runtime semantics
|
||||
|
||||
## Decision Summary
|
||||
|
||||
### We are doing this
|
||||
|
||||
- keep the current Codex adapter path as the fallback/default path initially
|
||||
- introduce a new `Codex-native` backend lane using `@openai/codex-sdk / codex exec`
|
||||
- introduce a normalized internal event/log format for all providers
|
||||
- map Anthropic, Gemini, and future Codex-native events into that normalized format
|
||||
- keep unified logging, transcript projection, analytics, and UI-facing event handling on top of the normalized layer
|
||||
- use `plugin-kit-ai` for:
|
||||
- install
|
||||
- update
|
||||
- remove
|
||||
- repair
|
||||
- discover
|
||||
- catalog
|
||||
- native Codex plugin placement through native marketplace/filesystem layout
|
||||
|
||||
### We are not doing this
|
||||
|
||||
- not replacing the whole multimodel runtime in one shot
|
||||
- not forcing real Codex runtime execution into fake Anthropic transport semantics
|
||||
- not pretending a full `@openai/codex-sdk / codex exec` swap is a drop-in backend replacement
|
||||
- not making `app-server plugin/*` the first production seam
|
||||
|
||||
## Why We Chose This
|
||||
|
||||
### Main benefit
|
||||
|
||||
This path gives us both:
|
||||
|
||||
- unified internal logs/events
|
||||
- a real path to native Codex runtime capabilities
|
||||
|
||||
without requiring a full rewrite of the current multimodel runtime.
|
||||
|
||||
### Main reason against a direct full swap
|
||||
|
||||
The current orchestrator is deeply coupled to Anthropic-shaped runtime behavior:
|
||||
|
||||
- `tool_use`
|
||||
- `tool_result`
|
||||
- `content_block_start`
|
||||
- `input_json_delta`
|
||||
- `message_delta`
|
||||
- current permission and sandbox flow
|
||||
- current synthetic tool/result handling
|
||||
- current transcript persistence and resume logic
|
||||
|
||||
`codex exec` emits a different event model:
|
||||
|
||||
- `thread.started`
|
||||
- `turn.started`
|
||||
- `turn.completed`
|
||||
- `turn.failed`
|
||||
- `item.started`
|
||||
- `item.updated`
|
||||
- `item.completed`
|
||||
|
||||
and item types such as:
|
||||
|
||||
- `agent_message`
|
||||
- `reasoning`
|
||||
- `command_execution`
|
||||
- `file_change`
|
||||
- `mcp_tool_call`
|
||||
|
||||
That is not just a different wire format. It is a different runtime shape.
|
||||
|
||||
## What Changes Per Repo
|
||||
|
||||
### `agent_teams_orchestrator`
|
||||
|
||||
This repo takes the biggest change.
|
||||
|
||||
We want to:
|
||||
|
||||
- introduce a provider-neutral normalized event/log model
|
||||
- add adapter mappers from current Anthropic/Gemini style streams into that model
|
||||
- add a separate `Codex-native` backend lane through `@openai/codex-sdk / codex exec`
|
||||
- keep the current Codex adapter path alive as fallback during migration
|
||||
- avoid forcing `codex exec` events into fake `tool_use/tool_result` transport semantics
|
||||
|
||||
We do **not** want to:
|
||||
|
||||
- replace the current Codex backend in one shot
|
||||
- rewrite all providers around Codex-native semantics
|
||||
- make transcript/log normalization depend on Anthropic wire events
|
||||
|
||||
### `claude_team`
|
||||
|
||||
This repo should stay relatively stable compared with the orchestrator.
|
||||
|
||||
We want to:
|
||||
|
||||
- keep one multimodel runtime concept
|
||||
- stay capability-aware per provider/backend lane
|
||||
- consume normalized runtime/log DTOs rather than assuming one provider-shaped event model
|
||||
- integrate plugin management through `plugin-kit-ai`
|
||||
- keep Codex plugin support gated behind the real Codex-native lane
|
||||
|
||||
We do **not** want to:
|
||||
|
||||
- invent a fake Codex plugin support state while execution still goes through the old adapter lane
|
||||
- force UI logic to infer runtime truth from provider labels alone
|
||||
|
||||
### `plugin-kit-ai`
|
||||
|
||||
This repo remains the management engine, not the execution engine.
|
||||
|
||||
We want to:
|
||||
|
||||
- use it for catalog
|
||||
- use it for discover
|
||||
- use it for install/update/remove/repair
|
||||
- use it for native Codex plugin placement through native marketplace/filesystem layout
|
||||
|
||||
We do **not** want to:
|
||||
|
||||
- make it responsible for running Codex plugins inside sessions
|
||||
- blur installation and execution into one concern
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Runtime execution
|
||||
|
||||
- `Anthropic` can continue on the current path for now
|
||||
- `Gemini` can continue on the current path for now
|
||||
- `Codex-native` gets a dedicated backend lane through `@openai/codex-sdk / codex exec`
|
||||
|
||||
### Internal normalization
|
||||
|
||||
All runtime backends must project into a shared internal event/log model.
|
||||
|
||||
The normalized layer should represent concepts such as:
|
||||
|
||||
- turn started
|
||||
- assistant text
|
||||
- reasoning
|
||||
- command execution
|
||||
- MCP call
|
||||
- file change
|
||||
- approval request
|
||||
- turn completed
|
||||
- turn failed
|
||||
|
||||
The normalized format is the source of truth for:
|
||||
|
||||
- logs
|
||||
- transcript projection
|
||||
- analytics
|
||||
- UI-facing activity/event summaries
|
||||
|
||||
The normalized format is **not** required to preserve provider-native wire semantics.
|
||||
|
||||
## Codex Plugins Strategy
|
||||
|
||||
For Codex plugins we want:
|
||||
|
||||
- native Codex runtime execution
|
||||
- native Codex marketplace/filesystem placement
|
||||
- provider-aware plugin management in `claude_team`
|
||||
|
||||
Therefore:
|
||||
|
||||
- `plugin-kit-ai` is the management engine
|
||||
- real Codex runtime is the execution engine
|
||||
|
||||
This is important because plugin installation and plugin execution are different concerns.
|
||||
|
||||
Installing a native Codex plugin is not enough by itself if the session still runs through our current Responses API adapter path.
|
||||
|
||||
## App Server Position
|
||||
|
||||
`codex app-server` remains relevant, but not as the first critical path for this migration.
|
||||
|
||||
It is better positioned as a later control-plane enhancement for things like:
|
||||
|
||||
- auth state
|
||||
- MCP status and OAuth flows
|
||||
- skills/config inspection
|
||||
- external config import
|
||||
|
||||
For the first production rollout, it should not be the hard dependency for plugin lifecycle management.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1
|
||||
|
||||
- design and introduce the normalized internal event/log layer
|
||||
- keep current backends working
|
||||
- define the internal mapping contract clearly
|
||||
|
||||
### Phase 2
|
||||
|
||||
- add a `Codex-native` backend lane through `@openai/codex-sdk / codex exec`
|
||||
- keep the current Codex adapter as fallback
|
||||
- validate API-key mode, working directory behavior, sandbox mode, approval policy, thread resume, and streaming
|
||||
|
||||
### Phase 3
|
||||
|
||||
- integrate `plugin-kit-ai` for provider-aware plugin management
|
||||
- add native Codex plugin placement through native marketplace/filesystem model
|
||||
- keep current UI provider-aware and capability-aware
|
||||
|
||||
### Phase 4
|
||||
|
||||
- optionally add selective `codex app-server` control-plane integration where it provides clear value
|
||||
|
||||
## Main Risks And Guardrails
|
||||
|
||||
### Risk 1 - treating `codex-sdk/exec` as a transport-only swap
|
||||
|
||||
This is the most dangerous mistake.
|
||||
|
||||
Guardrail:
|
||||
|
||||
- treat `Codex-native` as a separate runtime lane
|
||||
- normalize logs/events above it
|
||||
- do not assume the current Anthropic-shaped tool loop can be preserved unchanged
|
||||
|
||||
### Risk 2 - claiming Codex plugin support too early
|
||||
|
||||
Installing native Codex plugins is not enough if execution still runs through the current adapter path.
|
||||
|
||||
Guardrail:
|
||||
|
||||
- only advertise Codex plugin support when the session actually runs through the Codex-native lane
|
||||
|
||||
### Risk 3 - overcommitting to `app-server` too early
|
||||
|
||||
`codex app-server` is useful, but it should not become a hard dependency for the first production plugin rollout.
|
||||
|
||||
Guardrail:
|
||||
|
||||
- use it later for selective control-plane features
|
||||
- do not block the first migration on `app-server plugin/*`
|
||||
|
||||
## Practical Rule
|
||||
|
||||
If we need **unified logs**, we normalize events.
|
||||
|
||||
If we need **native Codex capabilities**, we do not fake Codex into Anthropic runtime semantics.
|
||||
|
||||
That is the core architectural rule for this migration.
|
||||
|
|
@ -1074,6 +1074,9 @@ async function handleUpdateConfig(
|
|||
}
|
||||
return wrapTeamHandler('updateConfig', async () => {
|
||||
const tn = validated.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn);
|
||||
const requestedName = typeof name === 'string' ? name.trim() : '';
|
||||
const result = await getTeamDataService().updateConfig(tn, {
|
||||
name,
|
||||
description,
|
||||
|
|
@ -1084,10 +1087,10 @@ async function handleUpdateConfig(
|
|||
}
|
||||
|
||||
// Notify running lead about the rename so it stays aware of current team name
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) {
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (provisioning.isTeamAlive(tn)) {
|
||||
const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`;
|
||||
const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`;
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, msg);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
|
|
@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics {
|
|||
lastPressureLogAt: number;
|
||||
}
|
||||
|
||||
function applyDistinctRosterColors<T extends { name: string; color?: string; removedAt?: number }>(
|
||||
members: readonly T[]
|
||||
): T[] {
|
||||
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
|
|
@ -1161,7 +1172,7 @@ export class TeamDataService {
|
|||
role: configMember.role,
|
||||
workflow: configMember.workflow,
|
||||
agentType: configMember.agentType ?? 'general-purpose',
|
||||
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
|
||||
color: configMember.color,
|
||||
joinedAt: configMember.joinedAt ?? Date.now(),
|
||||
cwd: configMember.cwd,
|
||||
};
|
||||
|
|
@ -1176,13 +1187,13 @@ export class TeamDataService {
|
|||
member = {
|
||||
name: memberName,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(memberName),
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
members.push(member);
|
||||
await this.membersMetaStore.writeMembers(teamName, members);
|
||||
const nextMembers = applyDistinctRosterColors([...members, member]);
|
||||
member = nextMembers.find((m) => m.name === memberName) ?? member;
|
||||
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||
}
|
||||
|
||||
return { members, member };
|
||||
|
|
@ -1193,6 +1204,13 @@ export class TeamDataService {
|
|||
if (!name) {
|
||||
throw new Error('Member name cannot be empty');
|
||||
}
|
||||
const formatError = validateTeamMemberNameFormat(name);
|
||||
if (formatError) {
|
||||
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||
}
|
||||
if (name.toLowerCase() === 'user') {
|
||||
throw new Error('Member name "user" is reserved');
|
||||
}
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
throw new Error(
|
||||
|
|
@ -1224,12 +1242,11 @@ export class TeamDataService {
|
|||
? request.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(name),
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
||||
members.push(newMember);
|
||||
await this.membersMetaStore.writeMembers(teamName, members);
|
||||
const nextMembers = applyDistinctRosterColors([...members, newMember]);
|
||||
await this.membersMetaStore.writeMembers(teamName, nextMembers);
|
||||
}
|
||||
|
||||
async updateMemberRole(
|
||||
|
|
@ -1269,36 +1286,48 @@ export class TeamDataService {
|
|||
const joinedAt = Date.now();
|
||||
const nextByName = new Set<string>();
|
||||
|
||||
const nextActive: TeamMember[] = request.members.map((member) => {
|
||||
const name = member.name.trim();
|
||||
if (!name) throw new Error('Member name cannot be empty');
|
||||
if (name.toLowerCase() === 'team-lead') {
|
||||
throw new Error('Member name "team-lead" is reserved');
|
||||
}
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
throw new Error(
|
||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||
);
|
||||
}
|
||||
nextByName.add(name.toLowerCase());
|
||||
const prev = existingByName.get(name.toLowerCase());
|
||||
return {
|
||||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
color: prev?.color ?? getMemberColorByName(name),
|
||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||
removedAt: undefined,
|
||||
};
|
||||
});
|
||||
const nextActive = applyDistinctRosterColors(
|
||||
request.members.map((member) => {
|
||||
const name = member.name.trim();
|
||||
if (!name) throw new Error('Member name cannot be empty');
|
||||
const formatError = validateTeamMemberNameFormat(name);
|
||||
if (formatError) {
|
||||
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||
}
|
||||
if (name.toLowerCase() === 'user') {
|
||||
throw new Error('Member name "user" is reserved');
|
||||
}
|
||||
if (name.toLowerCase() === 'team-lead') {
|
||||
throw new Error('Member name "team-lead" is reserved');
|
||||
}
|
||||
if (nextByName.has(name.toLowerCase())) {
|
||||
throw new Error(`Member "${name}" already exists`);
|
||||
}
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
throw new Error(
|
||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||
);
|
||||
}
|
||||
nextByName.add(name.toLowerCase());
|
||||
const prev = existingByName.get(name.toLowerCase());
|
||||
return {
|
||||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
color: prev?.color,
|
||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||
removedAt: undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||
const nextRemoved: TeamMember[] = [];
|
||||
|
|
@ -2322,12 +2351,18 @@ export class TeamDataService {
|
|||
createdAt: joinedAt,
|
||||
});
|
||||
|
||||
await this.membersMetaStore.writeMembers(
|
||||
request.teamName,
|
||||
const membersToWrite = applyDistinctRosterColors(
|
||||
request.members.map((member) => ({
|
||||
name: (() => {
|
||||
const name = member.name.trim();
|
||||
if (!name) throw new Error('Member name cannot be empty');
|
||||
const formatError = validateTeamMemberNameFormat(name);
|
||||
if (formatError) {
|
||||
throw new Error(`Member name "${name}" is invalid: ${formatError}`);
|
||||
}
|
||||
if (name.toLowerCase() === 'user') {
|
||||
throw new Error('Member name "user" is reserved');
|
||||
}
|
||||
if (name.toLowerCase() === 'team-lead')
|
||||
throw new Error('Member name "team-lead" is reserved');
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
|
|
@ -2346,11 +2381,11 @@ export class TeamDataService {
|
|||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(member.name.trim()),
|
||||
agentType: 'general-purpose' as const,
|
||||
joinedAt,
|
||||
}))
|
||||
);
|
||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||
}
|
||||
|
||||
async reconcileTeamArtifacts(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
createCliAutoSuffixNameGuard,
|
||||
createCliProvisionerNameGuard,
|
||||
} from '@shared/utils/teamMemberName';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -262,6 +263,11 @@ export class TeamMemberResolver {
|
|||
}
|
||||
return aStableId.localeCompare(bStableId);
|
||||
});
|
||||
return members;
|
||||
|
||||
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
|||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||
import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||
import {
|
||||
|
|
@ -224,6 +225,16 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
|||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
|
||||
function applyDistinctProvisioningMemberColors<
|
||||
T extends { name: string; color?: string; removedAt?: number },
|
||||
>(members: readonly T[]): T[] {
|
||||
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
||||
}));
|
||||
}
|
||||
const FS_MONITOR_POLL_MS = 2000;
|
||||
const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||
const STALL_CHECK_INTERVAL_MS = 10_000;
|
||||
|
|
@ -727,6 +738,8 @@ interface ProvisioningRun {
|
|||
>;
|
||||
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
||||
memberSpawnToolUseIds: Map<string, string>;
|
||||
/** Explicit restart requests awaiting teammate rejoin or failure. */
|
||||
pendingMemberRestarts: Map<string, PendingMemberRestartContext>;
|
||||
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
||||
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
||||
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
||||
|
|
@ -821,6 +834,64 @@ interface LiveTeamAgentRuntimeMetadata {
|
|||
tmuxPaneId?: string;
|
||||
}
|
||||
|
||||
function escapeRegexLiteral(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function commandContainsCliArgValue(command: string, argName: string, value: string): boolean {
|
||||
const normalizedCommand = command.trim();
|
||||
const normalizedValue = value.trim();
|
||||
if (!normalizedCommand || !normalizedValue) {
|
||||
return false;
|
||||
}
|
||||
const pattern = new RegExp(
|
||||
`(?:^|\\s)${escapeRegexLiteral(argName)}(?:=|\\s+)${escapeRegexLiteral(normalizedValue)}(?:\\s|$)`
|
||||
);
|
||||
return pattern.test(normalizedCommand);
|
||||
}
|
||||
|
||||
function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
|
||||
return reason?.trim() === 'Teammate was never spawned during launch.';
|
||||
}
|
||||
|
||||
function isLaunchGraceWindowFailureReason(reason?: string): boolean {
|
||||
return reason?.trim() === 'Teammate did not join within the launch grace window.';
|
||||
}
|
||||
|
||||
function isConfigRegistrationFailureReason(reason?: string): boolean {
|
||||
return (
|
||||
reason?.trim() ===
|
||||
'Teammate was not registered in config.json during launch. Persistent spawn failed.'
|
||||
);
|
||||
}
|
||||
|
||||
function isAutoClearableLaunchFailureReason(reason?: string): boolean {
|
||||
return (
|
||||
isNeverSpawnedDuringLaunchReason(reason) ||
|
||||
isLaunchGraceWindowFailureReason(reason) ||
|
||||
isConfigRegistrationFailureReason(reason)
|
||||
);
|
||||
}
|
||||
|
||||
function buildRestartStillRunningReason(memberName: string): string {
|
||||
return (
|
||||
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
|
||||
`to be active. The requested settings may not have been applied.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildRestartGraceTimeoutReason(memberName: string): string {
|
||||
return `Teammate "${memberName}" did not rejoin within the restart grace window.`;
|
||||
}
|
||||
|
||||
interface PendingMemberRestartContext {
|
||||
requestedAt: string;
|
||||
desired: Pick<
|
||||
TeamCreateRequest['members'][number],
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||
>;
|
||||
}
|
||||
|
||||
function normalizeTeamAgentRuntimeBackendType(
|
||||
value: string | undefined,
|
||||
isLead: boolean
|
||||
|
|
@ -1628,7 +1699,9 @@ export function buildRestartMemberSpawnMessage(
|
|||
return (
|
||||
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
|
||||
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` +
|
||||
`This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` +
|
||||
`This is a restart of an existing persistent teammate, not a new teammate. ` +
|
||||
`If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` +
|
||||
`If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` +
|
||||
indentMultiline(prompt, ' ')
|
||||
);
|
||||
}
|
||||
|
|
@ -3919,6 +3992,7 @@ export class TeamProvisioningService {
|
|||
const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId);
|
||||
if (spawnedMemberName) {
|
||||
run.memberSpawnToolUseIds.delete(toolUseId);
|
||||
const pendingRestart = run.pendingMemberRestarts.get(spawnedMemberName);
|
||||
if (isError) {
|
||||
const resultPreview = extractToolResultPreview(resultContent);
|
||||
this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview);
|
||||
|
|
@ -3928,8 +4002,25 @@ export class TeamProvisioningService {
|
|||
const detail =
|
||||
parsedStatus.reason === 'already_running'
|
||||
? 'duplicate spawn skipped - already running'
|
||||
: 'duplicate spawn skipped - teammate already online';
|
||||
: 'duplicate spawn skipped - teammate bootstrap still pending';
|
||||
this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail);
|
||||
if (parsedStatus.reason === 'already_running') {
|
||||
if (pendingRestart) {
|
||||
run.pendingMemberRestarts.delete(spawnedMemberName);
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
spawnedMemberName,
|
||||
'error',
|
||||
buildRestartStillRunningReason(spawnedMemberName)
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
|
||||
} else {
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3947,11 +4038,16 @@ export class TeamProvisioningService {
|
|||
memberName: string,
|
||||
resultPreview?: string
|
||||
): void {
|
||||
const pendingRestart = run.pendingMemberRestarts.get(memberName);
|
||||
const reason =
|
||||
(typeof resultPreview === 'string' && resultPreview.trim().length > 0
|
||||
? resultPreview.trim()
|
||||
: 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.';
|
||||
const message = `Teammate "${memberName}" failed to start: ${reason}`;
|
||||
const message = pendingRestart
|
||||
? `Failed to restart teammate "${memberName}": ${reason}`
|
||||
: `Teammate "${memberName}" failed to start: ${reason}`;
|
||||
|
||||
run.pendingMemberRestarts.delete(memberName);
|
||||
|
||||
this.setMemberSpawnStatus(run, memberName, 'error', message);
|
||||
|
||||
|
|
@ -4016,6 +4112,21 @@ export class TeamProvisioningService {
|
|||
heartbeatAt?: string
|
||||
): void {
|
||||
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
|
||||
if (
|
||||
status === 'waiting' &&
|
||||
!prev.hardFailure &&
|
||||
(prev.bootstrapConfirmed || prev.runtimeAlive)
|
||||
) {
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
memberName,
|
||||
'online',
|
||||
undefined,
|
||||
prev.livenessSource,
|
||||
prev.lastHeartbeatAt
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updatedAt = nowIso();
|
||||
const next: MemberSpawnStatusEntry = {
|
||||
...prev,
|
||||
|
|
@ -4024,13 +4135,26 @@ export class TeamProvisioningService {
|
|||
};
|
||||
|
||||
if (status === 'spawning') {
|
||||
next.launchState = 'starting';
|
||||
} else if (status === 'waiting') {
|
||||
next.agentToolAccepted = true;
|
||||
next.agentToolAccepted = false;
|
||||
next.runtimeAlive = false;
|
||||
next.bootstrapConfirmed = false;
|
||||
next.hardFailure = false;
|
||||
next.error = undefined;
|
||||
next.hardFailureReason = undefined;
|
||||
next.livenessSource = undefined;
|
||||
next.firstSpawnAcceptedAt = undefined;
|
||||
next.lastHeartbeatAt = undefined;
|
||||
next.launchState = 'starting';
|
||||
} else if (status === 'waiting') {
|
||||
next.agentToolAccepted = true;
|
||||
next.runtimeAlive = false;
|
||||
next.bootstrapConfirmed = false;
|
||||
next.hardFailure = false;
|
||||
next.error = undefined;
|
||||
next.hardFailureReason = undefined;
|
||||
next.livenessSource = undefined;
|
||||
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
||||
next.lastHeartbeatAt = undefined;
|
||||
next.launchState = 'runtime_pending_bootstrap';
|
||||
} else if (status === 'online') {
|
||||
next.agentToolAccepted = true;
|
||||
|
|
@ -4078,6 +4202,9 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
run.memberSpawnStatuses.set(memberName, next);
|
||||
if ((status === 'online' && next.bootstrapConfirmed) || status === 'offline') {
|
||||
run.pendingMemberRestarts.delete(memberName);
|
||||
}
|
||||
this.syncMemberLaunchGraceCheck(run, memberName, next);
|
||||
|
||||
if (status === 'spawning') {
|
||||
|
|
@ -4316,8 +4443,17 @@ export class TeamProvisioningService {
|
|||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const configuredMembers = config?.members ?? [];
|
||||
const configuredMember = configuredMembers.find(
|
||||
(member) => member?.name?.trim() === memberName
|
||||
let metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>> = [];
|
||||
try {
|
||||
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
} catch {
|
||||
metaMembers = [];
|
||||
}
|
||||
|
||||
const configuredMember = this.resolveEffectiveConfiguredMember(
|
||||
configuredMembers,
|
||||
metaMembers,
|
||||
memberName
|
||||
);
|
||||
if (!configuredMember) {
|
||||
throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`);
|
||||
|
|
@ -4328,6 +4464,9 @@ export class TeamProvisioningService {
|
|||
if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) {
|
||||
throw new Error('Lead restart is not supported from member controls');
|
||||
}
|
||||
if (run.pendingMemberRestarts.has(memberName)) {
|
||||
throw new Error(`Restart for teammate "${memberName}" is already in progress`);
|
||||
}
|
||||
|
||||
const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => {
|
||||
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
|
|
@ -4414,15 +4553,25 @@ export class TeamProvisioningService {
|
|||
this.setMemberSpawnStatus(run, memberName, 'offline');
|
||||
this.setMemberSpawnStatus(run, memberName, 'spawning');
|
||||
this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI');
|
||||
run.pendingMemberRestarts.set(memberName, {
|
||||
requestedAt: nowIso(),
|
||||
desired: {
|
||||
name: configuredMember.name,
|
||||
role: configuredMember.role,
|
||||
workflow: configuredMember.workflow,
|
||||
providerId: configuredMember.providerId,
|
||||
model: configuredMember.model,
|
||||
effort: configuredMember.effort,
|
||||
},
|
||||
});
|
||||
|
||||
const leadName =
|
||||
configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead';
|
||||
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
|
||||
const restartMessage = buildRestartMemberSpawnMessage(
|
||||
teamName,
|
||||
config?.name?.trim() || teamName,
|
||||
leadName,
|
||||
{
|
||||
name: memberName,
|
||||
name: configuredMember.name,
|
||||
role: configuredMember.role,
|
||||
workflow: configuredMember.workflow,
|
||||
providerId: configuredMember.providerId,
|
||||
|
|
@ -4434,6 +4583,7 @@ export class TeamProvisioningService {
|
|||
try {
|
||||
await this.sendMessageToRun(run, restartMessage);
|
||||
} catch (error) {
|
||||
run.pendingMemberRestarts.delete(memberName);
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
memberName,
|
||||
|
|
@ -4510,11 +4660,17 @@ export class TeamProvisioningService {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
const restartPending = run.pendingMemberRestarts.has(memberName);
|
||||
if (restartPending) {
|
||||
run.pendingMemberRestarts.delete(memberName);
|
||||
}
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
memberName,
|
||||
'error',
|
||||
'Teammate did not join within the launch grace window.'
|
||||
restartPending
|
||||
? buildRestartGraceTimeoutReason(memberName)
|
||||
: 'Teammate did not join within the launch grace window.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -5931,6 +6087,7 @@ export class TeamProvisioningService {
|
|||
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
||||
),
|
||||
memberSpawnToolUseIds: new Map(),
|
||||
pendingMemberRestarts: new Map(),
|
||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||
lastDeterministicBootstrapSeq: 0,
|
||||
lastMemberSpawnAuditAt: 0,
|
||||
|
|
@ -6050,8 +6207,7 @@ export class TeamProvisioningService {
|
|||
limitContext: request.limitContext,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await this.membersMetaStore.writeMembers(
|
||||
request.teamName,
|
||||
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||
effectiveMemberSpecs.map((m) => ({
|
||||
name: m.name.trim(),
|
||||
role: m.role?.trim() || undefined,
|
||||
|
|
@ -6063,10 +6219,10 @@ export class TeamProvisioningService {
|
|||
? m.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose' as const,
|
||||
color: getMemberColorByName(m.name.trim()),
|
||||
joinedAt: Date.now(),
|
||||
}))
|
||||
);
|
||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite);
|
||||
if (request.skipPermissions === false) {
|
||||
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
||||
}
|
||||
|
|
@ -6512,6 +6668,7 @@ export class TeamProvisioningService {
|
|||
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
||||
),
|
||||
memberSpawnToolUseIds: new Map(),
|
||||
pendingMemberRestarts: new Map(),
|
||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||
lastDeterministicBootstrapSeq: 0,
|
||||
lastMemberSpawnAuditAt: 0,
|
||||
|
|
@ -7979,13 +8136,29 @@ export class TeamProvisioningService {
|
|||
const nextStatuses = { ...statuses };
|
||||
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
||||
const current = nextStatuses[memberName];
|
||||
if (!current || !metadata.model) {
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
nextStatuses[memberName] = {
|
||||
const nextEntry: MemberSpawnStatusEntry = {
|
||||
...current,
|
||||
runtimeModel: metadata.model,
|
||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||
};
|
||||
const failureReason = current.hardFailureReason ?? current.error;
|
||||
if (
|
||||
metadata.alive &&
|
||||
current.launchState === 'failed_to_start' &&
|
||||
isAutoClearableLaunchFailureReason(failureReason)
|
||||
) {
|
||||
nextEntry.status = 'online';
|
||||
nextEntry.agentToolAccepted = true;
|
||||
nextEntry.runtimeAlive = true;
|
||||
nextEntry.hardFailure = false;
|
||||
nextEntry.hardFailureReason = undefined;
|
||||
nextEntry.error = undefined;
|
||||
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
|
||||
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
|
||||
}
|
||||
nextStatuses[memberName] = nextEntry;
|
||||
}
|
||||
return nextStatuses;
|
||||
}
|
||||
|
|
@ -8033,6 +8206,87 @@ export class TeamProvisioningService {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
private resolveEffectiveConfiguredMember(
|
||||
configuredMembers: TeamConfig['members'] | undefined,
|
||||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>,
|
||||
memberName: string
|
||||
): {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
agentType?: string;
|
||||
removedAt?: number | string;
|
||||
} | null {
|
||||
const configuredMember = (configuredMembers ?? []).find((member) => {
|
||||
const candidateName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
|
||||
});
|
||||
const metaMember = metaMembers.find((member) => {
|
||||
const candidateName = member.name?.trim() ?? '';
|
||||
return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName);
|
||||
});
|
||||
|
||||
if (!configuredMember && !metaMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
metaMember?.name?.trim() || configuredMember?.name?.trim() || memberName.trim() || memberName;
|
||||
const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined;
|
||||
const workflow =
|
||||
metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined;
|
||||
const providerId =
|
||||
normalizeTeamMemberProviderId(metaMember?.providerId) ??
|
||||
normalizeTeamMemberProviderId(configuredMember?.providerId);
|
||||
const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined;
|
||||
const effort =
|
||||
metaMember?.effort === 'low' ||
|
||||
metaMember?.effort === 'medium' ||
|
||||
metaMember?.effort === 'high'
|
||||
? metaMember.effort
|
||||
: configuredMember?.effort === 'low' ||
|
||||
configuredMember?.effort === 'medium' ||
|
||||
configuredMember?.effort === 'high'
|
||||
? configuredMember.effort
|
||||
: undefined;
|
||||
const agentType =
|
||||
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
|
||||
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
|
||||
|
||||
return {
|
||||
name,
|
||||
...(role ? { role } : {}),
|
||||
...(workflow ? { workflow } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(agentType ? { agentType } : {}),
|
||||
...(removedAt != null ? { removedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveLeadMemberName(
|
||||
configuredMembers: TeamConfig['members'] | undefined,
|
||||
metaMembers: Awaited<ReturnType<TeamMembersMetaStore['getMembers']>>
|
||||
): string {
|
||||
const configuredLead = (configuredMembers ?? []).find((member) => isLeadMember(member));
|
||||
const configuredLeadName = configuredLead?.name?.trim();
|
||||
if (configuredLeadName) {
|
||||
return configuredLeadName;
|
||||
}
|
||||
|
||||
const metaLead = metaMembers.find((member) => isLeadMember(member));
|
||||
const metaLeadName = metaLead?.name?.trim();
|
||||
if (metaLeadName) {
|
||||
return metaLeadName;
|
||||
}
|
||||
|
||||
return 'team-lead';
|
||||
}
|
||||
|
||||
private findEffectiveRunMemberModel(
|
||||
run: ProvisioningRun | null,
|
||||
memberName: string
|
||||
|
|
@ -8174,13 +8428,28 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
const unresolvedAgentIds = [...metadataByMember.values()]
|
||||
.map((metadata) => metadata.agentId?.trim() ?? '')
|
||||
.filter((agentId) => agentId.length > 0);
|
||||
const processPidByAgentId =
|
||||
unresolvedAgentIds.length > 0
|
||||
? this.findLiveProcessPidByAgentId(teamName, unresolvedAgentIds)
|
||||
: new Map<string, number>();
|
||||
|
||||
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||
const backendType = metadata.backendType;
|
||||
const panePid = paneId ? panePidById.get(paneId) : undefined;
|
||||
const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined;
|
||||
const resolvedPid =
|
||||
typeof panePid === 'number' && panePid > 0
|
||||
? panePid
|
||||
: typeof processPid === 'number' && processPid > 0
|
||||
? processPid
|
||||
: undefined;
|
||||
const status = this.findTrackedMemberSpawnStatus(run, memberName);
|
||||
const alive =
|
||||
typeof panePid === 'number' && panePid > 0
|
||||
typeof resolvedPid === 'number' && resolvedPid > 0
|
||||
? true
|
||||
: backendType === 'tmux'
|
||||
? false
|
||||
|
|
@ -8188,7 +8457,7 @@ export class TeamProvisioningService {
|
|||
metadataByMember.set(memberName, {
|
||||
...metadata,
|
||||
alive,
|
||||
...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}),
|
||||
...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -8236,6 +8505,46 @@ export class TeamProvisioningService {
|
|||
return rows;
|
||||
}
|
||||
|
||||
private findLiveProcessPidByAgentId(
|
||||
teamName: string,
|
||||
agentIds: readonly string[]
|
||||
): Map<string, number> {
|
||||
const normalizedAgentIds = [
|
||||
...new Set(agentIds.map((agentId) => agentId.trim()).filter(Boolean)),
|
||||
];
|
||||
if (normalizedAgentIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = this.readUnixProcessTableRows();
|
||||
if (rows.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const pidByAgentId = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (
|
||||
!commandContainsCliArgValue(row.command, '--team-name', teamName) ||
|
||||
!row.command.includes('--agent-id')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const agentId of normalizedAgentIds) {
|
||||
if (!commandContainsCliArgValue(row.command, '--agent-id', agentId)) {
|
||||
continue;
|
||||
}
|
||||
const currentPid = pidByAgentId.get(agentId);
|
||||
if (!currentPid || row.pid > currentPid) {
|
||||
pidByAgentId.set(agentId, row.pid);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pidByAgentId;
|
||||
}
|
||||
|
||||
private async readProcessRssBytesByPid(pids: readonly number[]): Promise<Map<number, number>> {
|
||||
const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))];
|
||||
if (uniquePids.length === 0) {
|
||||
|
|
@ -8444,6 +8753,7 @@ export class TeamProvisioningService {
|
|||
const nextMembers = { ...persisted.members };
|
||||
const now = nowIso();
|
||||
for (const expected of persisted.expectedMembers) {
|
||||
const bootstrapMember = bootstrapSnapshot?.members[expected];
|
||||
const current = nextMembers[expected] ?? {
|
||||
name: expected,
|
||||
launchState: 'starting',
|
||||
|
|
@ -8453,6 +8763,20 @@ export class TeamProvisioningService {
|
|||
hardFailure: false,
|
||||
lastEvaluatedAt: now,
|
||||
};
|
||||
if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) {
|
||||
current.agentToolAccepted = true;
|
||||
current.firstSpawnAcceptedAt =
|
||||
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
|
||||
}
|
||||
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
|
||||
current.runtimeAlive = true;
|
||||
current.lastRuntimeAliveAt =
|
||||
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
|
||||
}
|
||||
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
|
||||
current.bootstrapConfirmed = true;
|
||||
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
|
||||
}
|
||||
const matchedRuntimeNames = [...configMembers].filter((name) => {
|
||||
if (name === expected) return true;
|
||||
const parsed = parseNumericSuffixName(name);
|
||||
|
|
@ -8499,6 +8823,21 @@ export class TeamProvisioningService {
|
|||
: current.sources?.configDrift,
|
||||
inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat,
|
||||
};
|
||||
const bootstrapProvesSpawnAcceptance =
|
||||
bootstrapMember?.agentToolAccepted === true ||
|
||||
typeof bootstrapMember?.firstSpawnAcceptedAt === 'string';
|
||||
const currentProvesSpawnAcceptance =
|
||||
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
|
||||
if (
|
||||
isNeverSpawnedDuringLaunchReason(current.hardFailureReason) &&
|
||||
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
|
||||
) {
|
||||
current.hardFailure = false;
|
||||
current.hardFailureReason = undefined;
|
||||
if (current.sources) {
|
||||
current.sources.hardFailureSignal = undefined;
|
||||
}
|
||||
}
|
||||
if (heartbeatReason) {
|
||||
current.hardFailure = true;
|
||||
current.hardFailureReason = heartbeatReason;
|
||||
|
|
@ -13044,8 +13383,7 @@ export class TeamProvisioningService {
|
|||
const joinedAt = Date.now();
|
||||
|
||||
try {
|
||||
await this.membersMetaStore.writeMembers(
|
||||
teamName,
|
||||
const membersToWrite = applyDistinctProvisioningMemberColors(
|
||||
teammateMembers.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
|
|
@ -13056,11 +13394,11 @@ export class TeamProvisioningService {
|
|||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(member.name.trim()),
|
||||
agentType: 'general-purpose' as const,
|
||||
joinedAt,
|
||||
}))
|
||||
);
|
||||
await this.membersMetaStore.writeMembers(teamName, membersToWrite);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to persist members.meta.json: ${
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads';
|
|||
|
||||
import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
|
||||
interface ListTeamsPayload {
|
||||
teamsDir: string;
|
||||
|
|
@ -593,6 +594,11 @@ async function listTeams(
|
|||
dropCliProvisionerMembers(memberMap);
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const memberColors = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||
const coloredMembers = members.map((member) => ({
|
||||
...member,
|
||||
color: memberColors.get(member.name) ?? member.color,
|
||||
}));
|
||||
const launchStateSummary =
|
||||
(await readLaunchState(payload.teamsDir, teamName)) ??
|
||||
(() => {
|
||||
|
|
@ -623,7 +629,7 @@ async function listTeams(
|
|||
memberCount: memberMap.size,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
...(members.length > 0 ? { members } : {}),
|
||||
...(coloredMembers.length > 0 ? { members: coloredMembers } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||
|
|
@ -1537,6 +1538,10 @@ export const TeamDetailView = ({
|
|||
return nextMember;
|
||||
});
|
||||
}, [leadBranch, members, trackedBranches]);
|
||||
const resolvedMemberColorMap = useMemo(
|
||||
() => buildMemberColorMap(membersWithLiveBranches),
|
||||
[membersWithLiveBranches]
|
||||
);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessionIds = useMemo(() => {
|
||||
|
|
@ -2168,12 +2173,17 @@ export const TeamDetailView = ({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
disabled={isTeamProvisioning}
|
||||
onClick={() => setEditDialogOpen(true)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Edit team</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isTeamProvisioning
|
||||
? 'Edit team is unavailable while provisioning is still in progress'
|
||||
: 'Edit team'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -2708,7 +2718,10 @@ export const TeamDetailView = ({
|
|||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
|
||||
leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null}
|
||||
resolvedMemberColorMap={resolvedMemberColorMap}
|
||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSaved={() => void selectTeam(teamName)}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
} from '@renderer/utils/teamModelAvailability';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -940,7 +941,14 @@ export const CreateTeamDialog = ({
|
|||
const mentionSuggestions = useMemo(
|
||||
() =>
|
||||
soloTeam
|
||||
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
||||
? [
|
||||
{
|
||||
id: 'team-lead',
|
||||
name: 'team-lead',
|
||||
subtitle: 'Team Lead',
|
||||
color: resolveTeamLeadColorName(),
|
||||
},
|
||||
]
|
||||
: buildMemberDraftSuggestions(members, memberColorMap),
|
||||
[memberColorMap, members, soloTeam]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraftsFromInputs,
|
||||
filterEditableMemberInputs,
|
||||
createMemberDraft,
|
||||
MembersEditorSection,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,8 +23,17 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { buildMemberColorMap, displayMemberName } from '@renderer/utils/memberHelpers';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildEditTeamSourceSnapshot,
|
||||
getMemberRuntimeContractKey,
|
||||
getLiveRosterIdentityChanges,
|
||||
getMembersRequiringRuntimeRestart,
|
||||
} from './editTeamRuntimeChanges';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
|
|
@ -43,16 +54,72 @@ interface EditTeamDialogProps {
|
|||
currentDescription: string;
|
||||
currentColor: string;
|
||||
currentMembers: ResolvedTeamMember[];
|
||||
leadMember?: ResolvedTeamMember | null;
|
||||
resolvedMemberColorMap?: ReadonlyMap<string, string>;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
projectPath?: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onSaved: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
||||
}
|
||||
|
||||
function useEditTeamErrorReset(
|
||||
setError: (value: string | null) => void,
|
||||
setSaveOutcomeError: (value: string | null) => void
|
||||
): () => void {
|
||||
return () => {
|
||||
setError(null);
|
||||
setSaveOutcomeError(null);
|
||||
};
|
||||
}
|
||||
|
||||
function getInvalidMemberNamesError(
|
||||
members: readonly {
|
||||
name: string;
|
||||
removedAt?: number | string | null;
|
||||
}[]
|
||||
): string | null {
|
||||
for (const member of members) {
|
||||
if (member.removedAt) {
|
||||
continue;
|
||||
}
|
||||
const name = member.name.trim();
|
||||
if (!name) {
|
||||
return 'Member name cannot be empty';
|
||||
}
|
||||
if (validateMemberNameInline(name) !== null) {
|
||||
return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'user' || lower === 'team-lead') {
|
||||
return `Member name "${name}" is reserved`;
|
||||
}
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRemovedMembersToSnapshot(
|
||||
members: readonly ResolvedTeamMember[],
|
||||
removedMemberNames: readonly string[]
|
||||
): ResolvedTeamMember[] {
|
||||
if (removedMemberNames.length === 0) {
|
||||
return [...members];
|
||||
}
|
||||
const removedKeys = new Set(removedMemberNames.map((name) => name.trim().toLowerCase()));
|
||||
const removedAt = Date.now();
|
||||
return members.map((member) =>
|
||||
removedKeys.has(member.name.trim().toLowerCase()) ? { ...member, removedAt } : member
|
||||
);
|
||||
}
|
||||
|
||||
export const EditTeamDialog = ({
|
||||
open,
|
||||
teamName,
|
||||
|
|
@ -60,7 +127,10 @@ export const EditTeamDialog = ({
|
|||
currentDescription,
|
||||
currentColor,
|
||||
currentMembers,
|
||||
leadMember = null,
|
||||
resolvedMemberColorMap,
|
||||
isTeamAlive = false,
|
||||
isTeamProvisioning = false,
|
||||
projectPath,
|
||||
onClose,
|
||||
onSaved,
|
||||
|
|
@ -72,39 +142,305 @@ export const EditTeamDialog = ({
|
|||
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
|
||||
const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const wasOpenRef = useRef(false);
|
||||
const initializedTeamNameRef = useRef<string | null>(null);
|
||||
const baselineSourceSnapshotRef = useRef<string | null>(null);
|
||||
const pendingCommittedSourceSnapshotRef = useRef<string | null>(null);
|
||||
|
||||
useFileListCacheWarmer(projectPath ?? null);
|
||||
const clearTransientErrors = useEditTeamErrorReset(setError, setSaveOutcomeError);
|
||||
const effectiveResolvedMemberColorMap = useMemo(
|
||||
() => resolvedMemberColorMap ?? buildMemberColorMap(currentMembers),
|
||||
[currentMembers, resolvedMemberColorMap]
|
||||
);
|
||||
const leadDraft = useMemo(() => {
|
||||
if (!leadMember) return null;
|
||||
return createMemberDraft({
|
||||
id: `lead:${leadMember.name}`,
|
||||
name: displayMemberName(leadMember.name),
|
||||
originalName: leadMember.name,
|
||||
roleSelection: '',
|
||||
customRole: 'Team Lead',
|
||||
workflow: leadMember.workflow,
|
||||
providerId: leadMember.providerId,
|
||||
model: leadMember.model ?? '',
|
||||
effort: leadMember.effort,
|
||||
});
|
||||
}, [leadMember]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = wasOpenRef.current;
|
||||
if (open) {
|
||||
setName(currentName);
|
||||
setDescription(currentDescription);
|
||||
setColor(currentColor);
|
||||
setMembers(membersToDrafts(currentMembers));
|
||||
setError(null);
|
||||
const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName;
|
||||
if (shouldInitialize) {
|
||||
setName(currentName);
|
||||
setDescription(currentDescription);
|
||||
setColor(currentColor);
|
||||
setMembers(membersToDrafts(currentMembers));
|
||||
setError(null);
|
||||
setSaveOutcomeError(null);
|
||||
setMembersPendingRestartRetry({});
|
||||
initializedTeamNameRef.current = teamName;
|
||||
baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||
name: currentName,
|
||||
description: currentDescription,
|
||||
color: currentColor,
|
||||
members: currentMembers,
|
||||
});
|
||||
pendingCommittedSourceSnapshotRef.current = null;
|
||||
} else if (pendingCommittedSourceSnapshotRef.current !== null) {
|
||||
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
|
||||
name: currentName,
|
||||
description: currentDescription,
|
||||
color: currentColor,
|
||||
members: currentMembers,
|
||||
});
|
||||
if (latestSourceSnapshot === pendingCommittedSourceSnapshotRef.current) {
|
||||
baselineSourceSnapshotRef.current = latestSourceSnapshot;
|
||||
pendingCommittedSourceSnapshotRef.current = null;
|
||||
}
|
||||
}
|
||||
} else if (wasOpen) {
|
||||
initializedTeamNameRef.current = null;
|
||||
baselineSourceSnapshotRef.current = null;
|
||||
pendingCommittedSourceSnapshotRef.current = null;
|
||||
}
|
||||
}, [open, currentName, currentDescription, currentColor, currentMembers]);
|
||||
wasOpenRef.current = open;
|
||||
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
|
||||
|
||||
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
|
||||
const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
|
||||
const hasDuplicateMembers = useMemo(() => {
|
||||
const names = members
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => member.name.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return new Set(names).size !== names.length;
|
||||
}, [members]);
|
||||
const membersToRestart = useMemo(
|
||||
() =>
|
||||
isTeamAlive
|
||||
? getMembersRequiringRuntimeRestart({
|
||||
previousMembers: currentMembers,
|
||||
nextMembers: builtMembers,
|
||||
})
|
||||
: [],
|
||||
[builtMembers, currentMembers, isTeamAlive]
|
||||
);
|
||||
const builtMembersByName = useMemo(
|
||||
() =>
|
||||
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
|
||||
[builtMembers]
|
||||
);
|
||||
const effectiveMembersToRestart = useMemo(() => {
|
||||
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
|
||||
([normalizedName, expectedRuntimeContractKey]) => {
|
||||
const nextMember = builtMembersByName.get(normalizedName);
|
||||
if (!nextMember) {
|
||||
return [];
|
||||
}
|
||||
return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey
|
||||
? [nextMember.name.trim()]
|
||||
: [];
|
||||
}
|
||||
);
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...membersToRestart, ...retryMembers]
|
||||
.map((memberName) => memberName.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}, [builtMembersByName, membersPendingRestartRetry, membersToRestart]);
|
||||
const liveIdentityChanges = useMemo(
|
||||
() =>
|
||||
isTeamAlive
|
||||
? getLiveRosterIdentityChanges({
|
||||
previousMembers: currentMembers,
|
||||
nextDrafts: members,
|
||||
})
|
||||
: { renamed: [], removed: [] },
|
||||
[currentMembers, isTeamAlive, members]
|
||||
);
|
||||
const hasBlockedLiveIdentityChanges = liveIdentityChanges.renamed.length > 0;
|
||||
const liveRemovedExistingMembers = useMemo(
|
||||
() => (isTeamAlive ? liveIdentityChanges.removed : []),
|
||||
[isTeamAlive, liveIdentityChanges.removed]
|
||||
);
|
||||
const hasNewLiveTeammates = useMemo(
|
||||
() =>
|
||||
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
|
||||
[isTeamAlive, members]
|
||||
);
|
||||
const memberWarningById = useMemo(() => {
|
||||
const restartNames = new Set(
|
||||
effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase())
|
||||
);
|
||||
if (restartNames.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
members.map((member) => [
|
||||
member.id,
|
||||
restartNames.has(member.name.trim().toLowerCase())
|
||||
? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.'
|
||||
: null,
|
||||
])
|
||||
);
|
||||
}, [effectiveMembersToRestart, members]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (!name.trim()) {
|
||||
setError('Team name cannot be empty');
|
||||
return;
|
||||
}
|
||||
const builtMembers = buildMembersFromDrafts(members);
|
||||
if (invalidMemberNamesError) {
|
||||
setError(invalidMemberNamesError);
|
||||
return;
|
||||
}
|
||||
if (hasDuplicateMembers) {
|
||||
setError('Member names must be unique before saving');
|
||||
return;
|
||||
}
|
||||
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
|
||||
name: currentName,
|
||||
description: currentDescription,
|
||||
color: currentColor,
|
||||
members: currentMembers,
|
||||
});
|
||||
const allowedSourceSnapshots = new Set(
|
||||
[baselineSourceSnapshotRef.current, pendingCommittedSourceSnapshotRef.current].filter(
|
||||
(value): value is string => value !== null
|
||||
)
|
||||
);
|
||||
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
|
||||
setError(
|
||||
'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (hasBlockedLiveIdentityChanges) {
|
||||
setError(
|
||||
`Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isTeamProvisioning) {
|
||||
setError(
|
||||
'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (hasNewLiveTeammates) {
|
||||
setError(
|
||||
'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaveOutcomeError(null);
|
||||
void (async () => {
|
||||
let configSaved = false;
|
||||
let membersSaved = false;
|
||||
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
|
||||
try {
|
||||
await api.teams.updateConfig(teamName, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
color,
|
||||
});
|
||||
configSaved = true;
|
||||
for (const removedMemberName of liveRemovedExistingMembers) {
|
||||
await api.teams.removeMember(teamName, removedMemberName);
|
||||
committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [
|
||||
removedMemberName,
|
||||
]);
|
||||
}
|
||||
await api.teams.replaceMembers(teamName, { members: builtMembers });
|
||||
onSaved();
|
||||
onClose();
|
||||
membersSaved = true;
|
||||
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
color: color.trim(),
|
||||
members: builtMembers.map((member) => ({
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
providerId: member.providerId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
})) as ResolvedTeamMember[],
|
||||
});
|
||||
|
||||
const restartFailures: string[] = [];
|
||||
const failedRestartMembers: string[] = [];
|
||||
for (const memberName of effectiveMembersToRestart) {
|
||||
try {
|
||||
await api.teams.restartMember(teamName, memberName);
|
||||
} catch (restartError) {
|
||||
const detail =
|
||||
restartError instanceof Error ? restartError.message : String(restartError);
|
||||
failedRestartMembers.push(memberName);
|
||||
restartFailures.push(`${memberName} (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.resolve(onSaved());
|
||||
if (restartFailures.length === 0) {
|
||||
setMembersPendingRestartRetry({});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setMembersPendingRestartRetry(
|
||||
Object.fromEntries(
|
||||
failedRestartMembers.flatMap((memberName) => {
|
||||
const nextMember = builtMembersByName.get(memberName.trim().toLowerCase());
|
||||
if (!nextMember) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
[memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const,
|
||||
];
|
||||
})
|
||||
)
|
||||
);
|
||||
setSaveOutcomeError(
|
||||
`Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save');
|
||||
const message = e instanceof Error ? e.message : 'Failed to save';
|
||||
if (membersSaved) {
|
||||
setSaveOutcomeError(
|
||||
`Team changes were saved, but failed to refresh the latest view: ${message}`
|
||||
);
|
||||
} else if (configSaved) {
|
||||
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
color: color.trim(),
|
||||
members: committedMembersForSnapshot,
|
||||
});
|
||||
let refreshErrorDetail: string | null = null;
|
||||
try {
|
||||
await Promise.resolve(onSaved());
|
||||
} catch (refreshError) {
|
||||
refreshErrorDetail =
|
||||
refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
}
|
||||
setSaveOutcomeError(
|
||||
refreshErrorDetail
|
||||
? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
|
||||
: `Team settings were saved, but member changes failed: ${message}`
|
||||
);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -131,7 +467,10 @@ export const EditTeamDialog = ({
|
|||
id="edit-team-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
clearTransientErrors();
|
||||
setName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
|
||||
}}
|
||||
|
|
@ -149,7 +488,10 @@ export const EditTeamDialog = ({
|
|||
<textarea
|
||||
id="edit-team-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={(e) => {
|
||||
clearTransientErrors();
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
|
||||
placeholder="Team description (optional)"
|
||||
|
|
@ -158,20 +500,82 @@ export const EditTeamDialog = ({
|
|||
<div>
|
||||
<MembersEditorSection
|
||||
members={members}
|
||||
onChange={setMembers}
|
||||
onChange={(nextMembers) => {
|
||||
clearTransientErrors();
|
||||
setMembers(nextMembers);
|
||||
}}
|
||||
fieldError={invalidMemberNamesError ?? undefined}
|
||||
validateMemberName={validateMemberNameInline}
|
||||
showWorkflow
|
||||
showJsonEditor={!isTeamAlive}
|
||||
draftKeyPrefix={`editTeam:${teamName}`}
|
||||
projectPath={projectPath ?? null}
|
||||
headerExtra={
|
||||
leadDraft ? (
|
||||
<div className="space-y-2">
|
||||
<MemberDraftRow
|
||||
member={leadDraft}
|
||||
index={0}
|
||||
resolvedColor={effectiveResolvedMemberColorMap.get(
|
||||
leadDraft.originalName ?? leadDraft.name
|
||||
)}
|
||||
nameError={null}
|
||||
onNameChange={() => undefined}
|
||||
onRoleChange={() => undefined}
|
||||
onCustomRoleChange={() => undefined}
|
||||
onRemove={() => undefined}
|
||||
onProviderChange={() => undefined}
|
||||
onModelChange={() => undefined}
|
||||
onEffortChange={() => undefined}
|
||||
projectPath={projectPath ?? null}
|
||||
lockProviderModel
|
||||
lockRole
|
||||
lockedRoleLabel="Team Lead"
|
||||
lockIdentity
|
||||
hideActionButton
|
||||
modelLockReason="The team lead is shown for context only and cannot be edited from Edit Team."
|
||||
/>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Team lead is shown for context only. Edit Team changes only teammate roster
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
existingMembers={currentMembers}
|
||||
lockProviderModel={isTeamAlive}
|
||||
existingMemberColorMap={effectiveResolvedMemberColorMap}
|
||||
lockProviderModel={false}
|
||||
lockExistingMemberIdentity={isTeamAlive}
|
||||
identityLockReason={undefined}
|
||||
disableAddMember={isTeamAlive}
|
||||
addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
|
||||
memberWarningById={memberWarningById}
|
||||
/>
|
||||
</div>
|
||||
{isTeamAlive ? (
|
||||
{isTeamProvisioning ? (
|
||||
<p className="text-xs text-amber-300">
|
||||
Provider and model changes are locked while the team is live. Reconnect the team to
|
||||
change them safely.
|
||||
Team provisioning is still in progress. Editing is temporarily locked until launch
|
||||
finishes.
|
||||
</p>
|
||||
) : null}
|
||||
{isTeamAlive && hasNewLiveTeammates ? (
|
||||
<p className="text-xs text-red-300">
|
||||
New teammates cannot be added from Edit Team while the team is live. Use the Add
|
||||
member dialog instead.
|
||||
</p>
|
||||
) : null}
|
||||
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
|
||||
<p className="text-xs text-red-300">
|
||||
Live save is blocked because existing teammates were renamed. Revert those identity
|
||||
changes or stop the team first.
|
||||
</p>
|
||||
) : null}
|
||||
{isTeamAlive && effectiveMembersToRestart.length > 0 ? (
|
||||
<p className="text-xs text-amber-300">
|
||||
Saving will restart{' '}
|
||||
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
|
||||
apply role, workflow, provider, model, or effort changes:{' '}
|
||||
{effectiveMembersToRestart.join(', ')}.
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
|
|
@ -196,7 +600,10 @@ export const EditTeamDialog = ({
|
|||
borderColor: isSelected ? colorSet.border : 'transparent',
|
||||
}}
|
||||
title={colorName}
|
||||
onClick={() => setColor(isSelected ? '' : colorName)}
|
||||
onClick={() => {
|
||||
clearTransientErrors();
|
||||
setColor(isSelected ? '' : colorName);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="size-3.5 rounded-full"
|
||||
|
|
@ -207,14 +614,26 @@ export const EditTeamDialog = ({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{(error || saveOutcomeError) && (
|
||||
<p className="text-xs text-red-400">{error ?? saveOutcomeError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saving ||
|
||||
isTeamProvisioning ||
|
||||
!name.trim() ||
|
||||
hasDuplicateMembers ||
|
||||
Boolean(invalidMemberNamesError)
|
||||
}
|
||||
>
|
||||
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
|
|
|
|||
165
src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts
Normal file
165
src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { ResolvedTeamMember, TeamProvisioningMemberInput } from '@shared/types';
|
||||
|
||||
function normalizeRestartSensitiveMemberContract(member: {
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}): {
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
} {
|
||||
const role = member.role?.trim() || undefined;
|
||||
const workflow = member.workflow?.trim() || undefined;
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const model = member.model?.trim() || undefined;
|
||||
const effort =
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined;
|
||||
return { role, workflow, providerId, model, effort };
|
||||
}
|
||||
|
||||
export function getMemberRuntimeContractKey(member: {
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}): string {
|
||||
return JSON.stringify(normalizeRestartSensitiveMemberContract(member));
|
||||
}
|
||||
|
||||
export function getMembersRequiringRuntimeRestart(params: {
|
||||
previousMembers: readonly ResolvedTeamMember[];
|
||||
nextMembers: readonly TeamProvisioningMemberInput[];
|
||||
}): string[] {
|
||||
const previousByName = new Map(
|
||||
params.previousMembers
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => [member.name.trim().toLowerCase(), member] as const)
|
||||
);
|
||||
|
||||
const membersToRestart: string[] = [];
|
||||
for (const nextMember of params.nextMembers) {
|
||||
const normalizedName = nextMember.name.trim().toLowerCase();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const previousMember = previousByName.get(normalizedName);
|
||||
if (!previousMember) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousRuntime = normalizeRestartSensitiveMemberContract(previousMember);
|
||||
const nextRuntime = normalizeRestartSensitiveMemberContract(nextMember);
|
||||
if (
|
||||
previousRuntime.role !== nextRuntime.role ||
|
||||
previousRuntime.workflow !== nextRuntime.workflow ||
|
||||
previousRuntime.providerId !== nextRuntime.providerId ||
|
||||
previousRuntime.model !== nextRuntime.model ||
|
||||
previousRuntime.effort !== nextRuntime.effort
|
||||
) {
|
||||
membersToRestart.push(previousMember.name);
|
||||
}
|
||||
}
|
||||
|
||||
return membersToRestart;
|
||||
}
|
||||
|
||||
export function getLiveRosterIdentityChanges(params: {
|
||||
previousMembers: readonly ResolvedTeamMember[];
|
||||
nextDrafts: readonly MemberDraft[];
|
||||
}): {
|
||||
renamed: string[];
|
||||
removed: string[];
|
||||
} {
|
||||
const previousMembers = params.previousMembers
|
||||
.filter((member) => !member.removedAt)
|
||||
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead');
|
||||
|
||||
const previousNamesByKey = new Map(
|
||||
previousMembers.map((member) => [member.name.trim().toLowerCase(), member.name.trim()] as const)
|
||||
);
|
||||
|
||||
const nextExistingOriginalKeys = new Set(
|
||||
params.nextDrafts
|
||||
.map((member) => member.originalName?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
|
||||
const renamed = params.nextDrafts
|
||||
.flatMap((member) => {
|
||||
const originalName = member.originalName?.trim();
|
||||
const nextName = member.name.trim();
|
||||
if (!originalName || !nextName) {
|
||||
return [];
|
||||
}
|
||||
return originalName.toLowerCase() === nextName.toLowerCase() ? [] : [originalName];
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const removed = Array.from(previousNamesByKey.entries())
|
||||
.filter(([normalizedName]) => !nextExistingOriginalKeys.has(normalizedName))
|
||||
.map(([, displayName]) => displayName)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return { renamed, removed };
|
||||
}
|
||||
|
||||
function normalizeEditableMemberSnapshot(member: {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
removedAt?: number | string | null;
|
||||
}): {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
} | null {
|
||||
if (member.removedAt) {
|
||||
return null;
|
||||
}
|
||||
const name = member.name.trim();
|
||||
if (!name || name.toLowerCase() === 'team-lead') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
...normalizeRestartSensitiveMemberContract(member),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditTeamSourceSnapshot(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
members: readonly ResolvedTeamMember[];
|
||||
}): string {
|
||||
const members = params.members
|
||||
.map(normalizeEditableMemberSnapshot)
|
||||
.filter((member): member is NonNullable<typeof member> => member !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return JSON.stringify({
|
||||
name: params.name.trim(),
|
||||
description: params.description.trim(),
|
||||
color: params.color.trim(),
|
||||
members,
|
||||
});
|
||||
}
|
||||
132
src/renderer/components/team/members/LeadModelRow.test.tsx
Normal file
132
src/renderer/components/team/members/LeadModelRow.test.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||
|
||||
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
||||
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
||||
EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
|
||||
LimitContextCheckbox: () => React.createElement('div', null, 'limit-context'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
||||
getTeamProviderLabel: (providerId: string) => providerId,
|
||||
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||
Checkbox: ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
...props
|
||||
}: {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) =>
|
||||
React.createElement('input', {
|
||||
...props,
|
||||
checked,
|
||||
type: 'checkbox',
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onCheckedChange?.(event.target.checked),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({
|
||||
children,
|
||||
...props
|
||||
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
|
||||
React.createElement('label', props, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useTheme', () => ({
|
||||
useTheme: () => ({ isLight: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/utils/teamModelCatalog', () => ({
|
||||
isAnthropicHaikuTeamModel: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{ className, disabled, onClick, type: 'button', 'aria-label': ariaLabel },
|
||||
children
|
||||
),
|
||||
}));
|
||||
|
||||
import { LeadModelRow } from './LeadModelRow';
|
||||
|
||||
function renderLeadModelRow(): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(LeadModelRow, {
|
||||
providerId: 'anthropic',
|
||||
model: 'opus',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
onProviderChange: () => undefined,
|
||||
onModelChange: () => undefined,
|
||||
onEffortChange: () => undefined,
|
||||
onLimitContextChange: () => undefined,
|
||||
syncModelsWithTeammates: true,
|
||||
onSyncModelsWithTeammatesChange: () => undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('LeadModelRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('uses the canonical team-lead color for the preview stripe', () => {
|
||||
const { host, root } = renderLeadModelRow();
|
||||
|
||||
const stripe = host.querySelector('[aria-hidden="true"]');
|
||||
const expectedBorder = getTeamColorSet(resolveTeamLeadColorName()).border;
|
||||
|
||||
expect(host.textContent).toContain('lead');
|
||||
expect(host.textContent).toContain('Team Lead');
|
||||
expect(stripe?.getAttribute('style')).toContain(expectedBorder);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
|
|
@ -54,7 +54,7 @@ export const LeadModelRow = ({
|
|||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
|
||||
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
|
||||
const modelButtonLabel = model.trim()
|
||||
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
||||
: 'Default';
|
||||
|
|
|
|||
|
|
@ -48,6 +48,26 @@ interface MemberCardProps {
|
|||
onAssignTask?: () => void;
|
||||
}
|
||||
|
||||
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||
summary: string | undefined;
|
||||
memory: string | undefined;
|
||||
} {
|
||||
const trimmed = runtimeSummary?.trim();
|
||||
if (!trimmed) {
|
||||
return { summary: undefined, memory: undefined };
|
||||
}
|
||||
|
||||
const match = /^(.*?)(?:\s·\s(\d+(?:\.\d+)?\s(?:B|KB|MB|GB|TB)))$/.exec(trimmed);
|
||||
if (!match) {
|
||||
return { summary: trimmed, memory: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
summary: match[1]?.trim() || undefined,
|
||||
memory: match[2]?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const MemberCard = ({
|
||||
member,
|
||||
memberColor,
|
||||
|
|
@ -102,6 +122,8 @@ export const MemberCard = ({
|
|||
const totalTasks = pending + inProgress + completed;
|
||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||
splitRuntimeSummaryMemory(runtimeSummary);
|
||||
const activityTask = currentTask ?? reviewTask ?? null;
|
||||
const activityTitle = currentTask
|
||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||
|
|
@ -215,13 +237,19 @@ export const MemberCard = ({
|
|||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||
/>
|
||||
</div>
|
||||
) : runtimeSummary || roleLabel ? (
|
||||
) : runtimeSummaryText || roleLabel || memoryLabel ? (
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
|
||||
{runtimeSummary && roleLabel ? (
|
||||
{runtimeSummaryText ? (
|
||||
<span className="min-w-0 truncate">{runtimeSummaryText}</span>
|
||||
) : null}
|
||||
{runtimeSummaryText && roleLabel ? (
|
||||
<span className="shrink-0 opacity-60">•</span>
|
||||
) : null}
|
||||
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
|
||||
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
|
||||
<span className="shrink-0 opacity-60">•</span>
|
||||
) : null}
|
||||
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ export const MemberDetailDialog = ({
|
|||
: undefined,
|
||||
[launchParams, member, runtimeEntry, spawnEntry]
|
||||
);
|
||||
const restartInFlight =
|
||||
spawnEntry?.launchState === 'starting' ||
|
||||
spawnEntry?.launchState === 'runtime_pending_bootstrap';
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !member) {
|
||||
|
|
@ -267,7 +270,7 @@ export const MemberDetailDialog = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={restarting}
|
||||
disabled={restarting || restartInFlight}
|
||||
onClick={async () => {
|
||||
setRestartError(null);
|
||||
setRestarting(true);
|
||||
|
|
|
|||
|
|
@ -50,10 +50,15 @@ interface MemberDraftRowProps {
|
|||
taskSuggestions?: MentionSuggestion[];
|
||||
teamSuggestions?: MentionSuggestion[];
|
||||
lockProviderModel?: boolean;
|
||||
lockRole?: boolean;
|
||||
lockedRoleLabel?: string;
|
||||
lockIdentity?: boolean;
|
||||
identityLockReason?: string;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
modelLockReason?: string;
|
||||
isRemoved?: boolean;
|
||||
onRestore?: (id: string) => void;
|
||||
hideActionButton?: boolean;
|
||||
warningText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
|
|
@ -83,10 +88,15 @@ export const MemberDraftRow = ({
|
|||
taskSuggestions,
|
||||
teamSuggestions,
|
||||
lockProviderModel = false,
|
||||
lockRole = false,
|
||||
lockedRoleLabel,
|
||||
lockIdentity = false,
|
||||
identityLockReason,
|
||||
forceInheritedModelSettings = false,
|
||||
modelLockReason,
|
||||
isRemoved = false,
|
||||
onRestore,
|
||||
hideActionButton = false,
|
||||
warningText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
|
|
@ -200,29 +210,28 @@ export const MemberDraftRow = ({
|
|||
className="h-8 text-xs"
|
||||
value={member.name}
|
||||
aria-label={`Member ${index + 1} name`}
|
||||
disabled={isRemoved}
|
||||
disabled={isRemoved || lockIdentity}
|
||||
onChange={(event) => onNameChange(member.id, event.target.value)}
|
||||
placeholder="member-name"
|
||||
style={
|
||||
member.name.trim()
|
||||
? {
|
||||
color: memberColorSet.text,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<RoleSelect
|
||||
value={member.roleSelection || '__none__'}
|
||||
disabled={isRemoved}
|
||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
||||
customRole={member.customRole}
|
||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||
triggerClassName="h-8 text-xs"
|
||||
inputClassName="h-8 text-xs"
|
||||
/>
|
||||
{lockRole ? (
|
||||
<div className="flex h-8 items-center rounded-md border border-[var(--color-border)] bg-transparent px-3 text-xs text-[var(--color-text)] opacity-80">
|
||||
{lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
|
||||
</div>
|
||||
) : (
|
||||
<RoleSelect
|
||||
value={member.roleSelection || '__none__'}
|
||||
disabled={isRemoved}
|
||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
||||
customRole={member.customRole}
|
||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||
triggerClassName="h-8 text-xs"
|
||||
inputClassName="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
|
|
@ -289,7 +298,7 @@ export const MemberDraftRow = ({
|
|||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isRemoved ? (
|
||||
{hideActionButton ? null : isRemoved ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -88,10 +88,15 @@ export interface MembersEditorSectionProps {
|
|||
hideContent?: boolean;
|
||||
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
||||
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
||||
/** Pre-resolved member colors from the live Team view. */
|
||||
existingMemberColorMap?: ReadonlyMap<string, string>;
|
||||
/** Default provider to use for newly added member rows. */
|
||||
defaultProviderId?: TeamProviderId;
|
||||
/** When true, provider/model controls stay read-only for existing rows. */
|
||||
lockProviderModel?: boolean;
|
||||
/** When true, existing teammate names stay read-only while the team is live. */
|
||||
lockExistingMemberIdentity?: boolean;
|
||||
identityLockReason?: string;
|
||||
inheritedProviderId?: TeamProviderId;
|
||||
inheritedModel?: string;
|
||||
inheritedEffort?: EffortLevel;
|
||||
|
|
@ -102,6 +107,8 @@ export interface MembersEditorSectionProps {
|
|||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
disableAddMember?: boolean;
|
||||
addMemberLockReason?: string;
|
||||
}
|
||||
|
||||
export const MembersEditorSection = ({
|
||||
|
|
@ -118,8 +125,11 @@ export const MembersEditorSection = ({
|
|||
headerExtra,
|
||||
hideContent = false,
|
||||
existingMembers,
|
||||
existingMemberColorMap,
|
||||
defaultProviderId = 'anthropic',
|
||||
lockProviderModel = false,
|
||||
lockExistingMemberIdentity = false,
|
||||
identityLockReason,
|
||||
inheritedProviderId,
|
||||
inheritedModel,
|
||||
inheritedEffort,
|
||||
|
|
@ -130,6 +140,8 @@ export const MembersEditorSection = ({
|
|||
memberWarningById,
|
||||
disableGeminiOption = false,
|
||||
memberModelIssueById,
|
||||
disableAddMember = false,
|
||||
addMemberLockReason,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
|
|
@ -257,8 +269,8 @@ export const MembersEditorSection = ({
|
|||
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||
const hasDuplicates = new Set(names).size !== names.length;
|
||||
const memberColorMap = useMemo(
|
||||
() => buildMemberDraftColorMap(members, existingMembers),
|
||||
[members, existingMembers]
|
||||
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
|
||||
[members, existingMembers, existingMemberColorMap]
|
||||
);
|
||||
|
||||
const mentionSuggestions = useMemo(
|
||||
|
|
@ -272,7 +284,14 @@ export const MembersEditorSection = ({
|
|||
<Label>Members</Label>
|
||||
{!hideContent && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={addMember}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={addMember}
|
||||
disabled={disableAddMember}
|
||||
title={disableAddMember ? addMemberLockReason : undefined}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add member
|
||||
</Button>
|
||||
|
|
@ -287,6 +306,9 @@ export const MembersEditorSection = ({
|
|||
{headerExtra}
|
||||
{!hideContent && (
|
||||
<>
|
||||
{disableAddMember && addMemberLockReason ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberDraftRow
|
||||
|
|
@ -315,6 +337,8 @@ export const MembersEditorSection = ({
|
|||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
lockProviderModel={lockProviderModel}
|
||||
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
|
||||
identityLockReason={identityLockReason}
|
||||
modelLockReason={modelLockReason}
|
||||
warningText={memberWarningById?.[member.id] ?? null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
|
|||
export interface MemberDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
originalName?: string;
|
||||
roleSelection: string;
|
||||
customRole: string;
|
||||
workflow?: string;
|
||||
|
|
|
|||
|
|
@ -1,29 +1,20 @@
|
|||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
|
||||
|
||||
function isValidMemberName(name: string): boolean {
|
||||
if (name.length < 1 || name.length > 128) return false;
|
||||
if (!/^[a-zA-Z0-9]/.test(name)) return false;
|
||||
return /^[a-zA-Z0-9._-]+$/.test(name);
|
||||
}
|
||||
|
||||
export function validateMemberNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!isValidMemberName(trimmed)) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
return null;
|
||||
return validateTeamMemberNameFormat(trimmed);
|
||||
}
|
||||
|
||||
function newDraftId(): string {
|
||||
|
|
@ -36,6 +27,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|||
return {
|
||||
id: initial?.id ?? newDraftId(),
|
||||
name: initial?.name ?? '',
|
||||
originalName: initial?.originalName,
|
||||
roleSelection: initial?.roleSelection ?? '',
|
||||
customRole: initial?.customRole ?? '',
|
||||
workflow: initial?.workflow,
|
||||
|
|
@ -66,6 +58,7 @@ export function createMemberDraftsFromInputs(
|
|||
const isPreset = presetRoles.includes(role);
|
||||
return createMemberDraft({
|
||||
name: member.name,
|
||||
originalName: member.name,
|
||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: member.workflow,
|
||||
|
|
@ -141,18 +134,29 @@ interface ExistingMemberColorInput {
|
|||
|
||||
export function buildMemberDraftColorMap(
|
||||
members: readonly Pick<MemberDraft, 'name'>[],
|
||||
existingMembers?: readonly ExistingMemberColorInput[]
|
||||
existingMembers?: readonly ExistingMemberColorInput[],
|
||||
existingColorMap?: ReadonlyMap<string, string>
|
||||
): Map<string, string> {
|
||||
const draftEntries = members
|
||||
.map((member) => member.name.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name }));
|
||||
|
||||
const normalizedExistingColorMap = new Map<string, string>(
|
||||
Array.from(existingColorMap?.entries() ?? []).map(([name, color]) => [
|
||||
name.trim().toLowerCase(),
|
||||
color,
|
||||
])
|
||||
);
|
||||
|
||||
const existingSeedEntries = (existingMembers ?? [])
|
||||
.map((member) => ({
|
||||
...member,
|
||||
name: member.name.trim(),
|
||||
color: member.color?.trim() || getMemberColorByName(member.name),
|
||||
color:
|
||||
normalizedExistingColorMap.get(member.name.trim().toLowerCase()) ??
|
||||
member.color?.trim() ??
|
||||
undefined,
|
||||
}))
|
||||
.filter((member) => member.name);
|
||||
const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase()));
|
||||
|
|
@ -166,17 +170,9 @@ export function buildMemberDraftColorMap(
|
|||
return true;
|
||||
});
|
||||
|
||||
const predictedDraftSeedEntries = uniqueNewDraftEntries.map((entry) => ({
|
||||
...entry,
|
||||
color: getMemberColorByName(entry.name),
|
||||
}));
|
||||
|
||||
// Mirror the team page color inputs:
|
||||
// 1. existing members keep their persisted/resolved color
|
||||
// 2. new draft members get the same name-based default color that the resolver
|
||||
// will assign after create/launch/add
|
||||
// 3. buildMemberColorMap still resolves rare collisions the same way as the UI
|
||||
const fullMap = buildMemberColorMap([...existingSeedEntries, ...predictedDraftSeedEntries]);
|
||||
const fullMap = buildTeamMemberColorMap([...existingSeedEntries, ...uniqueNewDraftEntries], {
|
||||
preferProvidedColors: true,
|
||||
});
|
||||
const fullColorByName = new Map(
|
||||
Array.from(fullMap.entries()).map(([name, color]) => [name.toLowerCase(), color] as const)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,83 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
|||
text: '#60a5fa',
|
||||
textLight: '#2563eb',
|
||||
},
|
||||
saffron: {
|
||||
border: '#eab308',
|
||||
badge: 'rgba(234, 179, 8, 0.15)',
|
||||
badgeLight: 'rgba(234, 179, 8, 0.12)',
|
||||
text: '#fde047',
|
||||
textLight: '#a16207',
|
||||
},
|
||||
turquoise: {
|
||||
border: '#14b8a6',
|
||||
badge: 'rgba(20, 184, 166, 0.15)',
|
||||
badgeLight: 'rgba(20, 184, 166, 0.12)',
|
||||
text: '#5eead4',
|
||||
textLight: '#0f766e',
|
||||
},
|
||||
brick: {
|
||||
border: '#ef4444',
|
||||
badge: 'rgba(239, 68, 68, 0.15)',
|
||||
badgeLight: 'rgba(239, 68, 68, 0.12)',
|
||||
text: '#f87171',
|
||||
textLight: '#b91c1c',
|
||||
},
|
||||
indigo: {
|
||||
border: '#8b5cf6',
|
||||
badge: 'rgba(139, 92, 246, 0.15)',
|
||||
badgeLight: 'rgba(139, 92, 246, 0.12)',
|
||||
text: '#c4b5fd',
|
||||
textLight: '#6d28d9',
|
||||
},
|
||||
forest: {
|
||||
border: '#22c55e',
|
||||
badge: 'rgba(34, 197, 94, 0.15)',
|
||||
badgeLight: 'rgba(34, 197, 94, 0.12)',
|
||||
text: '#86efac',
|
||||
textLight: '#15803d',
|
||||
},
|
||||
apricot: {
|
||||
border: '#fb923c',
|
||||
badge: 'rgba(251, 146, 60, 0.15)',
|
||||
badgeLight: 'rgba(251, 146, 60, 0.12)',
|
||||
text: '#fdba74',
|
||||
textLight: '#c2410c',
|
||||
},
|
||||
rose: {
|
||||
border: '#f43f5e',
|
||||
badge: 'rgba(244, 63, 94, 0.15)',
|
||||
badgeLight: 'rgba(244, 63, 94, 0.12)',
|
||||
text: '#fda4af',
|
||||
textLight: '#be123c',
|
||||
},
|
||||
cerulean: {
|
||||
border: '#38bdf8',
|
||||
badge: 'rgba(56, 189, 248, 0.15)',
|
||||
badgeLight: 'rgba(56, 189, 248, 0.12)',
|
||||
text: '#7dd3fc',
|
||||
textLight: '#0369a1',
|
||||
},
|
||||
olive: {
|
||||
border: '#84cc16',
|
||||
badge: 'rgba(132, 204, 22, 0.15)',
|
||||
badgeLight: 'rgba(132, 204, 22, 0.12)',
|
||||
text: '#bef264',
|
||||
textLight: '#4d7c0f',
|
||||
},
|
||||
copper: {
|
||||
border: '#b45309',
|
||||
badge: 'rgba(180, 83, 9, 0.15)',
|
||||
badgeLight: 'rgba(180, 83, 9, 0.12)',
|
||||
text: '#fdba74',
|
||||
textLight: '#92400e',
|
||||
},
|
||||
steel: {
|
||||
border: '#64748b',
|
||||
badge: 'rgba(100, 116, 139, 0.15)',
|
||||
badgeLight: 'rgba(100, 116, 139, 0.12)',
|
||||
text: '#cbd5e1',
|
||||
textLight: '#475569',
|
||||
},
|
||||
green: {
|
||||
border: '#22c55e',
|
||||
badge: 'rgba(34, 197, 94, 0.15)',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
getMemberColorByName,
|
||||
MEMBER_COLOR_PALETTE,
|
||||
normalizeMemberColorName,
|
||||
} from '@shared/constants/memberColors';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
|
|
@ -579,42 +575,7 @@ interface MemberColorInput {
|
|||
* Maps "user" to a reserved color.
|
||||
*/
|
||||
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const active = members.filter((m) => !m.removedAt);
|
||||
const removed = members.filter((m) => m.removedAt);
|
||||
const usedColors = new Set<string>();
|
||||
let nextPaletteIdx = 0;
|
||||
|
||||
for (const member of active) {
|
||||
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
|
||||
if (!color || usedColors.has(color)) {
|
||||
// Assign the next unused color from the pre-ordered palette.
|
||||
while (
|
||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
|
||||
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
|
||||
) {
|
||||
nextPaletteIdx++;
|
||||
}
|
||||
color =
|
||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
|
||||
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
|
||||
: MEMBER_COLOR_PALETTE[active.indexOf(member) % MEMBER_COLOR_PALETTE.length];
|
||||
nextPaletteIdx++;
|
||||
}
|
||||
map.set(member.name, color);
|
||||
usedColors.add(color);
|
||||
}
|
||||
|
||||
for (const member of removed) {
|
||||
const color = member.color
|
||||
? normalizeMemberColorName(member.color)
|
||||
: getMemberColorByName(member.name);
|
||||
map.set(member.name, color);
|
||||
}
|
||||
|
||||
map.set('user', 'user');
|
||||
|
||||
return map;
|
||||
return buildTeamMemberColorMap(members, { preferProvidedColors: true });
|
||||
}
|
||||
|
||||
export const KANBAN_COLUMN_DISPLAY: Record<
|
||||
|
|
|
|||
|
|
@ -6,36 +6,40 @@
|
|||
* Intentionally excludes purple-family tones.
|
||||
*/
|
||||
export const MEMBER_COLOR_PALETTE = [
|
||||
// ── First 10: maximum contrast (>40° hue gap between any pair) ──
|
||||
'blue', // 0°
|
||||
'saffron', // 177°
|
||||
'turquoise', // 268°
|
||||
'brick', // 85°
|
||||
'apricot', // 131°
|
||||
'indigo', // 314°
|
||||
'forest', // 223°
|
||||
'pink', // 39°
|
||||
'crimson', // 59°
|
||||
'tangerine', // 105°
|
||||
// ── First 12: intentionally distinct visual families for roster readability ──
|
||||
'blue',
|
||||
'saffron',
|
||||
'turquoise',
|
||||
'brick',
|
||||
'indigo',
|
||||
'forest',
|
||||
'apricot',
|
||||
'rose',
|
||||
'cerulean',
|
||||
'olive',
|
||||
'copper',
|
||||
'steel',
|
||||
|
||||
// ── Next 14: still good separation ──
|
||||
'gold', // 151°
|
||||
'emerald', // 203°
|
||||
'cerulean', // 288°
|
||||
'denim', // 334°
|
||||
'cyan', // 20°
|
||||
'sage', // 242°
|
||||
'tomato', // 72°
|
||||
'rust', // 118°
|
||||
'mustard', // 164°
|
||||
'canary', // 190°
|
||||
'teal', // 255°
|
||||
'arctic', // 301°
|
||||
'royal', // 347°
|
||||
'green', // 7°
|
||||
// ── Next 12: secondary accents after the core distinct set ──
|
||||
'gold',
|
||||
'emerald',
|
||||
'cobalt',
|
||||
'crimson',
|
||||
'tangerine',
|
||||
'denim',
|
||||
'cyan',
|
||||
'sage',
|
||||
'tomato',
|
||||
'rust',
|
||||
'mustard',
|
||||
'canary',
|
||||
'teal',
|
||||
'arctic',
|
||||
'royal',
|
||||
|
||||
// ── Remaining: fill the hue gaps progressively ──
|
||||
'rose', // 46°
|
||||
'green',
|
||||
'pink',
|
||||
'ruby', // 92°
|
||||
'sienna', // 144°
|
||||
'mint', // 216°
|
||||
|
|
@ -49,90 +53,45 @@ export const MEMBER_COLOR_PALETTE = [
|
|||
'salmon', // 79°
|
||||
'amber', // 98°
|
||||
'peach', // 111°
|
||||
'copper', // 124°
|
||||
'bronze', // 137°
|
||||
'lemon', // 157°
|
||||
'honey', // 170°
|
||||
'marigold', // 183°
|
||||
'sunflower', // 196°
|
||||
'lime', // 209°
|
||||
'olive', // 229°
|
||||
'jade', // 236°
|
||||
'chartreuse', // 249°
|
||||
'aqua', // 262°
|
||||
'azure', // 281°
|
||||
'seafoam', // 295°
|
||||
'cobalt', // 308°
|
||||
'periwinkle', // 327°
|
||||
'steel', // 340°
|
||||
'cornflower', // 353°
|
||||
] as const;
|
||||
|
||||
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
||||
|
||||
/**
|
||||
* Fixed hue angle (0-359) for each palette color name.
|
||||
* This is independent of array order — colors keep their visual identity
|
||||
* regardless of how MEMBER_COLOR_PALETTE is sorted.
|
||||
* Spread evenly across 360° so every name has a unique hue.
|
||||
* Canonical runtime/member id for the team lead.
|
||||
* UI surfaces should use this exact key when deriving the lead color so
|
||||
* previews match the resolved team roster.
|
||||
*/
|
||||
export const MEMBER_COLOR_HUE: Record<string, number> = {
|
||||
blue: 0,
|
||||
green: 7,
|
||||
yellow: 13,
|
||||
cyan: 20,
|
||||
red: 26,
|
||||
orange: 33,
|
||||
pink: 39,
|
||||
rose: 46,
|
||||
coral: 52,
|
||||
crimson: 59,
|
||||
scarlet: 65,
|
||||
tomato: 72,
|
||||
salmon: 79,
|
||||
brick: 85,
|
||||
ruby: 92,
|
||||
amber: 98,
|
||||
tangerine: 105,
|
||||
peach: 111,
|
||||
rust: 118,
|
||||
copper: 124,
|
||||
apricot: 131,
|
||||
bronze: 137,
|
||||
sienna: 144,
|
||||
gold: 151,
|
||||
lemon: 157,
|
||||
mustard: 164,
|
||||
honey: 170,
|
||||
saffron: 177,
|
||||
marigold: 183,
|
||||
canary: 190,
|
||||
sunflower: 196,
|
||||
emerald: 203,
|
||||
lime: 209,
|
||||
mint: 216,
|
||||
forest: 223,
|
||||
olive: 229,
|
||||
jade: 236,
|
||||
sage: 242,
|
||||
chartreuse: 249,
|
||||
teal: 255,
|
||||
aqua: 262,
|
||||
turquoise: 268,
|
||||
sky: 275,
|
||||
azure: 281,
|
||||
cerulean: 288,
|
||||
seafoam: 295,
|
||||
arctic: 301,
|
||||
cobalt: 308,
|
||||
indigo: 314,
|
||||
sapphire: 321,
|
||||
periwinkle: 327,
|
||||
denim: 334,
|
||||
steel: 340,
|
||||
royal: 347,
|
||||
cornflower: 353,
|
||||
};
|
||||
export const TEAM_LEAD_MEMBER_COLOR_ID = 'team-lead' as const;
|
||||
|
||||
/**
|
||||
* Fixed hue angle (0-359) for each palette color name.
|
||||
* The first roster-assigned colors are intentionally spaced far apart so the
|
||||
* first 10-12 teammates in a team remain visually distinct.
|
||||
*/
|
||||
const MEMBER_COLOR_HUES_BY_ORDER = [
|
||||
240, 60, 180, 0, 120, 300, 30, 210, 330, 90, 150, 270, 15, 195, 105, 285, 45, 225, 135, 315, 75,
|
||||
255, 165, 345, 7.5, 187.5, 97.5, 277.5, 37.5, 217.5, 127.5, 307.5, 67.5, 247.5, 157.5, 337.5,
|
||||
22.5, 202.5, 112.5, 292.5, 52.5, 232.5, 142.5, 322.5, 82.5, 262.5, 172.5, 352.5, 11.25, 191.25,
|
||||
101.25, 281.25, 41.25, 221.25, 131.25,
|
||||
] as const;
|
||||
|
||||
export const MEMBER_COLOR_HUE: Record<string, number> = Object.fromEntries(
|
||||
MEMBER_COLOR_PALETTE.map((colorName, index) => [colorName, MEMBER_COLOR_HUES_BY_ORDER[index]])
|
||||
) as Record<string, number>;
|
||||
|
||||
const DISALLOWED_MEMBER_COLORS = new Set([
|
||||
'purple',
|
||||
|
|
|
|||
107
src/shared/utils/teamMemberColors.ts
Normal file
107
src/shared/utils/teamMemberColors.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
getMemberColorByName,
|
||||
MEMBER_COLOR_PALETTE,
|
||||
TEAM_LEAD_MEMBER_COLOR_ID,
|
||||
normalizeMemberColorName,
|
||||
} from '@shared/constants/memberColors';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
export interface TeamMemberColorInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
removedAt?: number | string | null;
|
||||
agentType?: string;
|
||||
}
|
||||
|
||||
interface BuildTeamMemberColorMapOptions {
|
||||
preferProvidedColors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic roster color map that optimizes contrast inside a team.
|
||||
* Leads reserve their own color but do not consume the teammate palette order.
|
||||
*/
|
||||
export function buildTeamMemberColorMap(
|
||||
members: readonly TeamMemberColorInput[],
|
||||
options: BuildTeamMemberColorMapOptions = {}
|
||||
): Map<string, string> {
|
||||
const preferProvidedColors = options.preferProvidedColors ?? true;
|
||||
const map = new Map<string, string>();
|
||||
const active = members.filter((member) => !member.removedAt);
|
||||
const removed = members.filter((member) => member.removedAt);
|
||||
const activeLeads = active.filter((member) => isLeadMember(member));
|
||||
const activeTeammates = active.filter((member) => !isLeadMember(member));
|
||||
const usedColors = new Set<string>();
|
||||
let nextPaletteIdx = 0;
|
||||
|
||||
for (const member of activeLeads) {
|
||||
const color =
|
||||
preferProvidedColors && member.color
|
||||
? normalizeMemberColorName(member.color)
|
||||
: getMemberColorByName(member.name);
|
||||
map.set(member.name, color);
|
||||
usedColors.add(color);
|
||||
}
|
||||
|
||||
for (const member of activeTeammates) {
|
||||
let color =
|
||||
preferProvidedColors && member.color ? normalizeMemberColorName(member.color) : undefined;
|
||||
if (!color || usedColors.has(color)) {
|
||||
while (
|
||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
|
||||
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
|
||||
) {
|
||||
nextPaletteIdx += 1;
|
||||
}
|
||||
color =
|
||||
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
|
||||
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
|
||||
: MEMBER_COLOR_PALETTE[activeTeammates.indexOf(member) % MEMBER_COLOR_PALETTE.length];
|
||||
nextPaletteIdx += 1;
|
||||
}
|
||||
map.set(member.name, color);
|
||||
usedColors.add(color);
|
||||
}
|
||||
|
||||
for (const member of removed) {
|
||||
const color =
|
||||
preferProvidedColors && member.color
|
||||
? normalizeMemberColorName(member.color)
|
||||
: getMemberColorByName(member.name);
|
||||
map.set(member.name, color);
|
||||
}
|
||||
|
||||
map.set('user', 'user');
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the visual color for a standalone member preview by reusing the same
|
||||
* roster color pipeline that powers the team screen.
|
||||
*/
|
||||
export function resolveTeamMemberColorName(
|
||||
member: TeamMemberColorInput,
|
||||
options: BuildTeamMemberColorMapOptions = {}
|
||||
): string {
|
||||
const color = buildTeamMemberColorMap([member], options).get(member.name);
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
|
||||
if (options.preferProvidedColors !== false && member.color) {
|
||||
return normalizeMemberColorName(member.color);
|
||||
}
|
||||
|
||||
return getMemberColorByName(member.name);
|
||||
}
|
||||
|
||||
export function resolveTeamLeadColorName(): string {
|
||||
return resolveTeamMemberColorName(
|
||||
{
|
||||
name: TEAM_LEAD_MEMBER_COLOR_ID,
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{ preferProvidedColors: false }
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,21 @@ export function parseNumericSuffixName(name: string): { base: string; suffix: nu
|
|||
return { base: match[1], suffix };
|
||||
}
|
||||
|
||||
export function validateTeamMemberNameFormat(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length < 1 || trimmed.length > 128) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]/.test(trimmed)) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude CLI auto-suffixes teammate names when a name already exists in config.json
|
||||
* (e.g. "alice" → "alice-2"). We treat "-2+" as an auto-suffix only when the base
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ describe('ipc teams handlers', () => {
|
|||
deleteTeam: vi.fn(async () => undefined),
|
||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
||||
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
||||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
||||
|
|
@ -1732,6 +1733,47 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('notifies a live lead only when the team name actually changes', async () => {
|
||||
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
|
||||
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
|
||||
provisioningService.isTeamAlive = vi.fn(() => true);
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'Renamed Team',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
|
||||
name: 'Renamed Team',
|
||||
description: undefined,
|
||||
color: undefined,
|
||||
});
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
'The team has been renamed to "Renamed Team". Please use this name when referring to the team going forward.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not notify the lead when the submitted team name is unchanged', async () => {
|
||||
const handler = handlers.get(TEAM_UPDATE_CONFIG)!;
|
||||
service.getTeamDisplayName.mockResolvedValueOnce('My Team');
|
||||
provisioningService.isTeamAlive = vi.fn(() => true);
|
||||
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'My Team',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.updateConfig).toHaveBeenCalledWith('my-team', {
|
||||
name: 'My Team',
|
||||
description: undefined,
|
||||
color: undefined,
|
||||
});
|
||||
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('calls service on valid input', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
|
|
|
|||
|
|
@ -461,6 +461,73 @@ function buildResolvedMember(name: string): ResolvedTeamMember {
|
|||
}
|
||||
|
||||
describe('TeamDataService', () => {
|
||||
it('rejects duplicate member names in replaceMembers', 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: 'dup-team', reviewers: [], tasks: {} })) } as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.replaceMembers('dup-team', {
|
||||
members: [
|
||||
{ name: 'alice', role: 'Reviewer' },
|
||||
{ name: 'alice', role: 'Developer' },
|
||||
],
|
||||
})
|
||||
).rejects.toThrow('Member "alice" already exists');
|
||||
|
||||
expect(writeMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid or reserved member names in replaceMembers', 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: 'dup-team', reviewers: [], tasks: {} })) } as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.replaceMembers('dup-team', {
|
||||
members: [{ name: 'bad/name', role: 'Reviewer' }],
|
||||
})
|
||||
).rejects.toThrow('Member name "bad/name" is invalid');
|
||||
|
||||
await expect(
|
||||
service.replaceMembers('dup-team', {
|
||||
members: [{ name: 'user', role: 'Reviewer' }],
|
||||
})
|
||||
).rejects.toThrow('Member name "user" is reserved');
|
||||
|
||||
expect(writeMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
||||
const order: string[] = [];
|
||||
const tasks: TeamTask[] = [
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import {
|
|||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
} from '@main/services/team/AutoResumeService';
|
||||
import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader';
|
||||
import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
|
||||
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
|
|
@ -176,6 +177,28 @@ function writeLaunchState(
|
|||
);
|
||||
}
|
||||
|
||||
function writeBootstrapState(
|
||||
teamName: string,
|
||||
members: { name: string; status: string; lastAttemptAt?: number; lastObservedAt?: number }[],
|
||||
updatedAt = new Date().toISOString()
|
||||
): void {
|
||||
fs.writeFileSync(
|
||||
getTeamBootstrapStatePath(teamName),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt,
|
||||
phase: 'completed',
|
||||
members,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function createMemberSpawnStatusEntry(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
|
|
@ -226,6 +249,7 @@ function createMemberSpawnRun(params?: {
|
|||
expectedMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnToolUseIds: new Map(),
|
||||
pendingMemberRestarts: new Map(),
|
||||
memberSpawnLeadInboxCursorByMember:
|
||||
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
||||
provisioningOutputParts: [],
|
||||
|
|
@ -484,6 +508,403 @@ describe('TeamProvisioningService', () => {
|
|||
expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000);
|
||||
expect(snapshot.members.alice?.rssBytes).toBe(456_000_000);
|
||||
});
|
||||
|
||||
it('falls back to direct agent process lookup when tmux pane pid lookup is unavailable', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', model: 'gpt-5.2' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||
{
|
||||
name: 'alice',
|
||||
agentId: 'alice@nice-team',
|
||||
tmuxPaneId: '%0',
|
||||
backendType: 'tmux',
|
||||
},
|
||||
]);
|
||||
(svc as any).aliveRunByTeam.set('nice-team', 'run-1');
|
||||
(svc as any).runs.set('run-1', {
|
||||
runId: 'run-1',
|
||||
child: { pid: 111 },
|
||||
request: { model: 'gpt-5.4' },
|
||||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
spawnContext: null,
|
||||
});
|
||||
(svc as any).readUnixProcessTableRows = vi.fn(() => [
|
||||
{
|
||||
pid: 333,
|
||||
command:
|
||||
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
|
||||
},
|
||||
]);
|
||||
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
|
||||
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||
'111': createPidusageStat(111, 123_000_000),
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
} as any);
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
|
||||
|
||||
expect(snapshot.members['team-lead']).toMatchObject({
|
||||
pid: 111,
|
||||
rssBytes: 123_000_000,
|
||||
});
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
pid: 333,
|
||||
rssBytes: 456_000_000,
|
||||
runtimeModel: 'gpt-5.2',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', model: 'gpt-5.2' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||
{
|
||||
name: 'alice',
|
||||
agentId: 'alice@nice-team',
|
||||
tmuxPaneId: '%0',
|
||||
backendType: 'tmux',
|
||||
},
|
||||
]);
|
||||
(svc as any).aliveRunByTeam.set('nice-team', 'run-1');
|
||||
(svc as any).runs.set('run-1', {
|
||||
runId: 'run-1',
|
||||
child: { pid: 111 },
|
||||
request: { model: 'gpt-5.4' },
|
||||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
spawnContext: null,
|
||||
});
|
||||
(svc as any).readUnixProcessTableRows = vi.fn(() => [
|
||||
{
|
||||
pid: 222,
|
||||
command:
|
||||
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
|
||||
},
|
||||
{
|
||||
pid: 333,
|
||||
command:
|
||||
'/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2',
|
||||
},
|
||||
]);
|
||||
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
|
||||
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||
'111': createPidusageStat(111, 123_000_000),
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
} as any);
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
|
||||
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
pid: 333,
|
||||
rssBytes: 456_000_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartMember', () => {
|
||||
it('uses members meta runtime settings when config members are stale or absent', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'edited-team',
|
||||
expectedMembers: ['alice'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
|
||||
const sendMessageToRun = vi.fn(async () => {});
|
||||
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Edited Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
workflow: 'Use checklist',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'high',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
(svc as any).aliveRunByTeam.set('edited-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await svc.restartMember('edited-team', 'alice');
|
||||
|
||||
expect(sendMessageToRun).toHaveBeenCalledTimes(1);
|
||||
const restartCall = sendMessageToRun.mock.calls[0] as unknown as
|
||||
| [unknown, string]
|
||||
| undefined;
|
||||
const restartMessage = restartCall?.[1] ?? '';
|
||||
expect(restartMessage).toContain('provider="codex"');
|
||||
expect(restartMessage).toContain('model="gpt-5.4-mini"');
|
||||
expect(restartMessage).toContain('effort="high"');
|
||||
expect(restartMessage).toContain('with role "Reviewer"');
|
||||
expect(restartMessage).toContain('Their workflow: Use checklist');
|
||||
});
|
||||
|
||||
it('treats duplicate_skipped already_running as a failed codex restart because the old runtime is still active', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
|
||||
const sendMessageToRun = vi.fn(async () => {});
|
||||
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Codex Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await svc.restartMember('codex-team', 'bob');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'spawning',
|
||||
launchState: 'starting',
|
||||
});
|
||||
expect(sendMessageToRun).toHaveBeenCalledWith(
|
||||
run,
|
||||
expect.stringContaining('provider="codex", model="gpt-5.2", effort="medium"')
|
||||
);
|
||||
|
||||
run.activeToolCalls.set('tool-agent-1', {
|
||||
memberName: 'bob',
|
||||
toolUseId: 'tool-agent-1',
|
||||
toolName: 'Agent',
|
||||
preview: 'Spawn teammate bob',
|
||||
startedAt: new Date().toISOString(),
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
});
|
||||
run.memberSpawnToolUseIds.set('tool-agent-1', 'bob');
|
||||
|
||||
(svc as any).finishRuntimeToolActivity(
|
||||
run,
|
||||
'tool-agent-1',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: 'status: duplicate_skipped\nreason: already_running\nname: bob\nteam_name: codex-team',
|
||||
},
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason:
|
||||
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||
});
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a codex teammate restart pending instead of failed when lead reports duplicate_skipped bootstrap_pending', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
|
||||
(svc as any).sendMessageToRun = vi.fn(async () => {});
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Codex Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await svc.restartMember('codex-team', 'bob');
|
||||
|
||||
run.activeToolCalls.set('tool-agent-1', {
|
||||
memberName: 'bob',
|
||||
toolUseId: 'tool-agent-1',
|
||||
toolName: 'Agent',
|
||||
preview: 'Spawn teammate bob',
|
||||
startedAt: new Date().toISOString(),
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
});
|
||||
run.memberSpawnToolUseIds.set('tool-agent-1', 'bob');
|
||||
|
||||
(svc as any).finishRuntimeToolActivity(
|
||||
run,
|
||||
'tool-agent-1',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: 'status: duplicate_skipped\nreason: bootstrap_pending\nname: bob\nteam_name: codex-team',
|
||||
},
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
agentToolAccepted: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
});
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a second restart request while the first restart is still in flight', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.pendingMemberRestarts.set('bob', {
|
||||
requestedAt: new Date().toISOString(),
|
||||
desired: {
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
},
|
||||
});
|
||||
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Codex Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).aliveRunByTeam.set('codex-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await expect(svc.restartMember('codex-team', 'bob')).rejects.toThrow(
|
||||
'Restart for teammate "bob" is already in progress'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a pending restart as failed when the teammate never rejoins within the restart grace window', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.pendingMemberRestarts.set('bob', {
|
||||
requestedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
desired: {
|
||||
name: 'bob',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
},
|
||||
});
|
||||
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
|
||||
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
|
||||
|
||||
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error: 'Teammate "bob" did not rejoin within the restart grace window.',
|
||||
hardFailureReason: 'Teammate "bob" did not rejoin within the restart grace window.',
|
||||
});
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
|
||||
|
|
@ -1656,4 +2077,225 @@ describe('TeamProvisioningService', () => {
|
|||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||
});
|
||||
|
||||
it('treats duplicate_skipped already_running as process-confirmed online', () => {
|
||||
const run = createMemberSpawnRun();
|
||||
run.activeToolCalls.set('tool-agent-1', {
|
||||
memberName: 'alice',
|
||||
toolUseId: 'tool-agent-1',
|
||||
toolName: 'Agent',
|
||||
preview: 'Spawn teammate alice',
|
||||
startedAt: new Date().toISOString(),
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
});
|
||||
run.memberSpawnToolUseIds.set('tool-agent-1', 'alice');
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
(svc as any).finishRuntimeToolActivity(
|
||||
run,
|
||||
'tool-agent-1',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: 'status: duplicate_skipped\nreason: already_running\nname: alice\nteam_name: nice-team',
|
||||
},
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
hardFailure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale failed_to_start state when live runtime metadata proves the teammate is alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
model: 'gpt-5.2',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
|
||||
bob: createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error: 'Teammate did not join within the launch grace window.',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
error: undefined,
|
||||
runtimeModel: 'gpt-5.2',
|
||||
livenessSource: 'process',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
|
||||
bob: createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error:
|
||||
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||
hardFailure: true,
|
||||
hardFailureReason:
|
||||
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason:
|
||||
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||
error:
|
||||
'Restart for teammate "bob" was skipped because the previous runtime still appears to be active. The requested settings may not have been applied.',
|
||||
runtimeModel: 'gpt-5.3-codex',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not downgrade an already-online teammate when waiting is reported later', () => {
|
||||
const run = createMemberSpawnRun({
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
(svc as any).setMemberSpawnStatus(run, 'alice', 'waiting');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale hard failure state when a new spawn attempt starts', () => {
|
||||
const staleAcceptedAt = '2026-04-16T10:00:00.000Z';
|
||||
const run = createMemberSpawnRun({
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error: 'Teammate was never spawned during launch.',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
livenessSource: 'heartbeat',
|
||||
firstSpawnAcceptedAt: staleAcceptedAt,
|
||||
lastHeartbeatAt: staleAcceptedAt,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
(svc as any).setMemberSpawnStatus(run, 'alice', 'spawning');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'spawning',
|
||||
launchState: 'starting',
|
||||
error: undefined,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
livenessSource: undefined,
|
||||
firstSpawnAcceptedAt: undefined,
|
||||
lastHeartbeatAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles stale never-spawned failures when bootstrap state proves the teammate was registered', async () => {
|
||||
const teamName = 'registered-bootstrap-team';
|
||||
const leadSessionId = 'lead-session';
|
||||
const acceptedAt = new Date(Date.now() - 60_000).toISOString();
|
||||
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
|
||||
writeLaunchState(teamName, leadSessionId, {
|
||||
alice: {
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
},
|
||||
});
|
||||
writeBootstrapState(
|
||||
teamName,
|
||||
[
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'registered',
|
||||
lastAttemptAt: Date.parse(acceptedAt),
|
||||
lastObservedAt: Date.parse(acceptedAt),
|
||||
},
|
||||
],
|
||||
new Date(Date.now() - 30_000).toISOString()
|
||||
);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
expect(result.statuses.alice).toMatchObject({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
agentToolAccepted: true,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -293,6 +293,12 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
expect(message).toContain('team_name="forge-labs", name="alice"');
|
||||
expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"');
|
||||
expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.');
|
||||
expect(message).toContain(
|
||||
'If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in.'
|
||||
);
|
||||
expect(message).toContain(
|
||||
'If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.'
|
||||
);
|
||||
});
|
||||
|
||||
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
||||
|
|
|
|||
1123
test/renderer/components/team/dialogs/EditTeamDialog.test.ts
Normal file
1123
test/renderer/components/team/dialogs/EditTeamDialog.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildEditTeamSourceSnapshot,
|
||||
getLiveRosterIdentityChanges,
|
||||
getMembersRequiringRuntimeRestart,
|
||||
} from '@renderer/components/team/dialogs/editTeamRuntimeChanges';
|
||||
|
||||
describe('getMembersRequiringRuntimeRestart', () => {
|
||||
it('returns existing teammates whose role, workflow, provider, model, or effort changed', () => {
|
||||
const result = getMembersRequiringRuntimeRestart({
|
||||
previousMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
workflow: 'Review PRs',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
} as any,
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
workflow: 'Ship features',
|
||||
providerId: 'anthropic',
|
||||
model: '',
|
||||
effort: 'low',
|
||||
} as any,
|
||||
],
|
||||
nextMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
workflow: 'Review PRs',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
workflow: 'Ship safer features',
|
||||
providerId: 'anthropic',
|
||||
model: '',
|
||||
effort: 'high',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('ignores newly added or renamed teammates for restart targeting', () => {
|
||||
const result = getMembersRequiringRuntimeRestart({
|
||||
previousMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
workflow: 'Review PRs',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
} as any,
|
||||
],
|
||||
nextMembers: [
|
||||
{
|
||||
name: 'alice-2',
|
||||
role: 'Reviewer',
|
||||
workflow: 'Review PRs',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
workflow: 'Ship features',
|
||||
providerId: 'anthropic',
|
||||
model: '',
|
||||
effort: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats empty values as unchanged normalized runtime settings', () => {
|
||||
const result = getMembersRequiringRuntimeRestart({
|
||||
previousMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: undefined,
|
||||
workflow: undefined,
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
effort: undefined,
|
||||
} as any,
|
||||
],
|
||||
nextMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: '',
|
||||
workflow: '',
|
||||
providerId: undefined,
|
||||
model: '',
|
||||
effort: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports live rename and remove of existing teammates separately from runtime restarts', () => {
|
||||
const result = getLiveRosterIdentityChanges({
|
||||
previousMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
} as any,
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
} as any,
|
||||
],
|
||||
nextDrafts: [
|
||||
{
|
||||
id: 'draft-alice',
|
||||
name: 'alice-renamed',
|
||||
originalName: 'alice',
|
||||
roleSelection: '',
|
||||
customRole: '',
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
renamed: ['alice'],
|
||||
removed: ['bob'],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores live status-only member refreshes in the edit source snapshot', () => {
|
||||
const base = buildEditTeamSourceSnapshot({
|
||||
name: 'Team A',
|
||||
description: 'desc',
|
||||
color: 'blue',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
status: 'online',
|
||||
branch: 'main',
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
const refreshed = buildEditTeamSourceSnapshot({
|
||||
name: 'Team A',
|
||||
description: 'desc',
|
||||
color: 'blue',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
status: 'offline',
|
||||
branch: 'feature/x',
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
expect(refreshed).toBe(base);
|
||||
});
|
||||
});
|
||||
|
|
@ -274,4 +274,35 @@ describe('MemberCard starting-state visuals', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders memory after the role label in the compact runtime summary row', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
runtimeSummary: '5.2 · Medium · 238.3 MB',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const text = host.textContent ?? '';
|
||||
expect(text).toContain('5.2 · Medium');
|
||||
expect(text).toContain('Reviewer');
|
||||
expect(text).toContain('238.3 MB');
|
||||
expect(text.indexOf('Reviewer')).toBeLessThan(text.indexOf('238.3 MB'));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
createMemberDraftsFromInputs,
|
||||
filterEditableMemberInputs,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
|
|
@ -60,6 +60,7 @@ describe('members editor editable input filtering', () => {
|
|||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0]).toMatchObject({
|
||||
name: 'alice',
|
||||
originalName: 'alice',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
|
|
@ -95,12 +96,9 @@ describe('members editor editable input filtering', () => {
|
|||
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
|
||||
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||
|
||||
const expectedColors = buildMemberColorMap(
|
||||
existingMembers.map((member) => ({
|
||||
...member,
|
||||
color: getMemberColorByName(member.name),
|
||||
}))
|
||||
);
|
||||
const expectedColors = buildTeamMemberColorMap(existingMembers, {
|
||||
preferProvidedColors: false,
|
||||
});
|
||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
||||
|
||||
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
|
||||
|
|
@ -116,12 +114,9 @@ describe('members editor editable input filtering', () => {
|
|||
createMemberDraft({ name: 'bob' }),
|
||||
];
|
||||
|
||||
const expectedColors = buildMemberColorMap(
|
||||
[...existingMembers, { name: 'bob' }].map((member) => ({
|
||||
...member,
|
||||
color: getMemberColorByName(member.name),
|
||||
}))
|
||||
);
|
||||
const expectedColors = buildTeamMemberColorMap([...existingMembers, { name: 'bob' }], {
|
||||
preferProvidedColors: false,
|
||||
});
|
||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
||||
|
||||
expect(draftColors.get('alice')).toBe(expectedColors.get('alice'));
|
||||
|
|
@ -132,11 +127,11 @@ describe('members editor editable input filtering', () => {
|
|||
it('predicts the same colors as the team page for brand-new draft members', () => {
|
||||
const drafts = ['alice', 'tom', 'bob'].map((name) => createMemberDraft({ name }));
|
||||
|
||||
const expectedColors = buildMemberColorMap(
|
||||
const expectedColors = buildTeamMemberColorMap(
|
||||
drafts.map((draft) => ({
|
||||
name: draft.name,
|
||||
color: getMemberColorByName(draft.name),
|
||||
}))
|
||||
})),
|
||||
{ preferProvidedColors: false }
|
||||
);
|
||||
const draftColors = buildMemberDraftColorMap(drafts);
|
||||
|
||||
|
|
@ -145,16 +140,32 @@ describe('members editor editable input filtering', () => {
|
|||
expect(draftColors.get('bob')).toBe(expectedColors.get('bob'));
|
||||
});
|
||||
|
||||
it('preserves explicit existing colors in edit and launch dialogs', () => {
|
||||
it('preserves the resolved team colors in edit and launch dialogs', () => {
|
||||
const existingMembers = [
|
||||
{ name: 'alice', color: 'blue' },
|
||||
{ name: 'bob', color: 'pink' },
|
||||
{ name: 'alice', color: getMemberColorByName('alice') },
|
||||
{ name: 'bob', color: getMemberColorByName('bob') },
|
||||
{ name: 'tom', color: getMemberColorByName('tom') },
|
||||
];
|
||||
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||
|
||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
||||
|
||||
expect(draftColors.get('alice')).toBe(existingMembers[0].color);
|
||||
expect(draftColors.get('bob')).toBe(existingMembers[1].color);
|
||||
expect(draftColors.get('tom')).toBe(existingMembers[2].color);
|
||||
});
|
||||
|
||||
it('prefers an explicit resolved member color map from the team screen', () => {
|
||||
const existingMembers = [{ name: 'alice', color: 'brick' }, { name: 'tom', color: 'forest' }];
|
||||
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||
const resolvedColorMap = new Map<string, string>([
|
||||
['alice', 'blue'],
|
||||
['tom', 'saffron'],
|
||||
]);
|
||||
|
||||
const draftColors = buildMemberDraftColorMap(drafts, existingMembers, resolvedColorMap);
|
||||
|
||||
expect(draftColors.get('alice')).toBe('blue');
|
||||
expect(draftColors.get('bob')).toBe('pink');
|
||||
expect(draftColors.get('tom')).toBe('saffron');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ describe('getTeamColorSet', () => {
|
|||
expect(getTeamColorSet('purple').border).toBe('#a855f7');
|
||||
});
|
||||
|
||||
it('resolves curated member palette colors for the first roster slots', () => {
|
||||
expect(getTeamColorSet('saffron').border).toBe('#eab308');
|
||||
expect(getTeamColorSet('turquoise').border).toBe('#14b8a6');
|
||||
expect(getTeamColorSet('brick').border).toBe('#ef4444');
|
||||
expect(getTeamColorSet('indigo').border).toBe('#8b5cf6');
|
||||
expect(getTeamColorSet('forest').border).toBe('#22c55e');
|
||||
expect(getTeamColorSet('apricot').border).toBe('#fb923c');
|
||||
expect(getTeamColorSet('rose').border).toBe('#f43f5e');
|
||||
expect(getTeamColorSet('cerulean').border).toBe('#38bdf8');
|
||||
expect(getTeamColorSet('olive').border).toBe('#84cc16');
|
||||
expect(getTeamColorSet('copper').border).toBe('#b45309');
|
||||
expect(getTeamColorSet('steel').border).toBe('#64748b');
|
||||
});
|
||||
|
||||
it('is case-insensitive for named colors', () => {
|
||||
expect(getTeamColorSet('Green')).toEqual(getTeamColorSet('green'));
|
||||
expect(getTeamColorSet('BLUE')).toEqual(getTeamColorSet('blue'));
|
||||
|
|
|
|||
45
test/shared/utils/teamMemberColors.test.ts
Normal file
45
test/shared/utils/teamMemberColors.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getMemberColorByName, TEAM_LEAD_MEMBER_COLOR_ID } from '@shared/constants/memberColors';
|
||||
import {
|
||||
buildTeamMemberColorMap,
|
||||
resolveTeamLeadColorName,
|
||||
resolveTeamMemberColorName,
|
||||
} from '@shared/utils/teamMemberColors';
|
||||
|
||||
describe('buildTeamMemberColorMap', () => {
|
||||
it('assigns the high-contrast palette order to active teammates', () => {
|
||||
const members = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }, { name: 'atlas' }];
|
||||
|
||||
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||
|
||||
expect(colorMap.get('alice')).toBe('blue');
|
||||
expect(colorMap.get('tom')).toBe('saffron');
|
||||
expect(colorMap.get('bob')).toBe('turquoise');
|
||||
expect(colorMap.get('atlas')).toBe('brick');
|
||||
});
|
||||
|
||||
it('does not let the lead consume the teammate palette order', () => {
|
||||
const members = [
|
||||
{ name: 'team-lead', agentType: 'team-lead' as const },
|
||||
{ name: 'alice' },
|
||||
{ name: 'tom' },
|
||||
];
|
||||
|
||||
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
||||
|
||||
expect(colorMap.get('team-lead')).toBeDefined();
|
||||
expect(colorMap.get('alice')).toBe('blue');
|
||||
expect(colorMap.get('tom')).toBe('saffron');
|
||||
});
|
||||
|
||||
it('resolves standalone lead previews through the same shared roster pipeline', () => {
|
||||
expect(resolveTeamLeadColorName()).toBe(
|
||||
resolveTeamMemberColorName(
|
||||
{ name: TEAM_LEAD_MEMBER_COLOR_ID, agentType: 'team-lead' },
|
||||
{ preferProvidedColors: false }
|
||||
)
|
||||
);
|
||||
expect(resolveTeamLeadColorName()).not.toBe(getMemberColorByName('lead'));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue