feat: improve team management and logging functionality
- Added background polling timer stop during service shutdown to prevent hanging. - Enhanced IPC handlers by importing and utilizing renderer log handlers for better logging. - Updated team-related services to handle member provisioning more robustly, including validation for empty member arrays. - Implemented timeout handling for file system operations to improve reliability. - Improved UI components to reflect solo team status and provide clearer feedback on member counts. Made-with: Cursor
This commit is contained in:
parent
a30727d3b0
commit
fa244052e8
20 changed files with 858 additions and 611 deletions
|
|
@ -765,6 +765,11 @@ function shutdownServices(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop background polling timers (prevents hanging shutdown).
|
||||||
|
if (teamDataService) {
|
||||||
|
teamDataService.stopProcessHealthPolling();
|
||||||
|
}
|
||||||
|
|
||||||
// Kill all PTY processes
|
// Kill all PTY processes
|
||||||
if (ptyTerminalService) {
|
if (ptyTerminalService) {
|
||||||
ptyTerminalService.killAll();
|
ptyTerminalService.killAll();
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
registerProjectHandlers,
|
registerProjectHandlers,
|
||||||
removeProjectHandlers,
|
removeProjectHandlers,
|
||||||
} from './projects';
|
} from './projects';
|
||||||
|
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
||||||
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
|
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
|
||||||
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
|
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
|
||||||
import {
|
import {
|
||||||
|
|
@ -69,7 +70,6 @@ import {
|
||||||
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
||||||
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
||||||
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
||||||
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChangeExtractorService,
|
ChangeExtractorService,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ const lastHeartbeatByWebContentsId = new Map<number, number>();
|
||||||
const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
|
const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
|
||||||
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
||||||
let heartbeatMonitorStarted = false;
|
let heartbeatMonitorStarted = false;
|
||||||
|
let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function startHeartbeatMonitor(): void {
|
function startHeartbeatMonitor(): void {
|
||||||
if (heartbeatMonitorStarted) return;
|
if (heartbeatMonitorStarted) return;
|
||||||
|
|
@ -32,7 +33,7 @@ function startHeartbeatMonitor(): void {
|
||||||
const STALE_AFTER_MS = 5000;
|
const STALE_AFTER_MS = 5000;
|
||||||
const WARN_THROTTLE_MS = 10_000;
|
const WARN_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
setInterval(() => {
|
heartbeatMonitorInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, last] of lastHeartbeatByWebContentsId.entries()) {
|
for (const [id, last] of lastHeartbeatByWebContentsId.entries()) {
|
||||||
if (!hasReceivedHeartbeatByWebContentsId.has(id)) {
|
if (!hasReceivedHeartbeatByWebContentsId.has(id)) {
|
||||||
|
|
@ -48,6 +49,9 @@ function startHeartbeatMonitor(): void {
|
||||||
logger.warn(`Renderer heartbeat stale webContentsId=${id} ageMs=${age}`);
|
logger.warn(`Renderer heartbeat stale webContentsId=${id} ageMs=${age}`);
|
||||||
}
|
}
|
||||||
}, CHECK_EVERY_MS);
|
}, CHECK_EVERY_MS);
|
||||||
|
|
||||||
|
// Diagnostics-only: should not keep the app alive.
|
||||||
|
heartbeatMonitorInterval.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
@ -91,4 +95,13 @@ export function removeRendererLogHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeAllListeners(RENDERER_LOG);
|
ipcMain.removeAllListeners(RENDERER_LOG);
|
||||||
ipcMain.removeAllListeners(RENDERER_BOOT);
|
ipcMain.removeAllListeners(RENDERER_BOOT);
|
||||||
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
||||||
|
|
||||||
|
if (heartbeatMonitorInterval) {
|
||||||
|
clearInterval(heartbeatMonitorInterval);
|
||||||
|
heartbeatMonitorInterval = null;
|
||||||
|
}
|
||||||
|
heartbeatMonitorStarted = false;
|
||||||
|
lastHeartbeatByWebContentsId.clear();
|
||||||
|
lastHeartbeatWarnedAtByWebContentsId.clear();
|
||||||
|
hasReceivedHeartbeatByWebContentsId.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -531,8 +531,8 @@ async function validateProvisioningRequest(
|
||||||
return { valid: false, error: 'description must be string' };
|
return { valid: false, error: 'description must be string' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
if (!Array.isArray(payload.members)) {
|
||||||
return { valid: false, error: 'members must contain at least one member' };
|
return { valid: false, error: 'members must be an array' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenNames = new Set<string>();
|
const seenNames = new Set<string>();
|
||||||
|
|
@ -1317,8 +1317,8 @@ async function handleCreateConfig(
|
||||||
return { success: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
|
return { success: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
if (!Array.isArray(payload.members)) {
|
||||||
return { success: false, error: 'members must contain at least one member' };
|
return { success: false, error: 'members must be an array' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.displayName !== undefined && typeof payload.displayName !== 'string') {
|
if (payload.displayName !== undefined && typeof payload.displayName !== 'string') {
|
||||||
|
|
@ -1590,8 +1590,8 @@ async function handleReplaceMembers(
|
||||||
return { success: false, error: 'request must be an object' };
|
return { success: false, error: 'request must be an object' };
|
||||||
}
|
}
|
||||||
const payload = request as { members?: unknown };
|
const payload = request as { members?: unknown };
|
||||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
if (!Array.isArray(payload.members)) {
|
||||||
return { success: false, error: 'members must contain at least one member' };
|
return { success: false, error: 'members must be an array' };
|
||||||
}
|
}
|
||||||
const seenNames = new Set<string>();
|
const seenNames = new Set<string>();
|
||||||
const members: { name: string; role?: string; workflow?: string }[] = [];
|
const members: { name: string; role?: string; workflow?: string }[] = [];
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ const logger = createLogger('Discovery:ProjectScanner');
|
||||||
|
|
||||||
// IPC payload safety: session ID arrays can be extremely large for long-lived projects.
|
// IPC payload safety: session ID arrays can be extremely large for long-lived projects.
|
||||||
// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive.
|
// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive.
|
||||||
// We no longer need session IDs in project/repository listings (session lists are fetched separately).
|
// Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list
|
||||||
// Keeping this at 0 avoids huge IPC payloads that can stall the renderer thread.
|
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||||
const MAX_SESSION_IDS_EXPORTED = 0;
|
const MAX_SESSION_IDS_EXPORTED = 200;
|
||||||
|
|
||||||
export class ProjectScanner {
|
export class ProjectScanner {
|
||||||
private readonly projectsDir: string;
|
private readonly projectsDir: string;
|
||||||
|
|
|
||||||
|
|
@ -253,17 +253,24 @@ export class CliInstallerService {
|
||||||
// Run the actual status gathering with an overall timeout.
|
// Run the actual status gathering with an overall timeout.
|
||||||
// On timeout, return whatever partial result was collected so far.
|
// On timeout, return whatever partial result was collected so far.
|
||||||
const ref = { current: result };
|
const ref = { current: result };
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.gatherStatus(ref),
|
this.gatherStatus(ref),
|
||||||
new Promise<void>((resolve) =>
|
new Promise<void>((resolve) => {
|
||||||
setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`getStatus() timed out after ${GET_STATUS_TIMEOUT_MS}ms, returning partial result`
|
`getStatus() timed out after ${GET_STATUS_TIMEOUT_MS}ms, returning partial result`
|
||||||
);
|
);
|
||||||
resolve();
|
resolve();
|
||||||
}, GET_STATUS_TIMEOUT_MS)
|
}, GET_STATUS_TIMEOUT_MS);
|
||||||
),
|
}),
|
||||||
]);
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -347,15 +354,22 @@ export class CliInstallerService {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Own timeout so slow auth doesn't eat the overall getStatus budget
|
// Own timeout so slow auth doesn't eat the overall getStatus budget
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
doCheck(),
|
doCheck(),
|
||||||
new Promise<void>((resolve) =>
|
new Promise<void>((resolve) => {
|
||||||
setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
||||||
resolve();
|
resolve();
|
||||||
}, AUTH_TOTAL_TIMEOUT_MS)
|
}, AUTH_TOTAL_TIMEOUT_MS);
|
||||||
),
|
}),
|
||||||
]);
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -359,9 +359,12 @@ export class DataCache {
|
||||||
*/
|
*/
|
||||||
startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout {
|
startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout {
|
||||||
const intervalMs = intervalMinutes * 60 * 1000;
|
const intervalMs = intervalMinutes * 60 * 1000;
|
||||||
return setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
this.cleanExpired();
|
this.cleanExpired();
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
// Background maintenance should not keep the process alive.
|
||||||
|
timer.unref();
|
||||||
|
return timer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* This is the default provider used when operating in local mode.
|
* This is the default provider used when operating in local mode.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -21,6 +23,18 @@ const STAT_TIMEOUT_MS = 2000;
|
||||||
// let callers stat only the files they actually need.
|
// let callers stat only the files they actually need.
|
||||||
const STAT_PREFETCH_LIMIT = 1500;
|
const STAT_PREFETCH_LIMIT = 1500;
|
||||||
|
|
||||||
|
async function statWithTimeout(filePath: string, timeoutMs: number): Promise<fs.Stats> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const timeout = new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error('stat timeout')), timeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([fs.promises.stat(filePath), timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function mapLimit<T, R>(
|
async function mapLimit<T, R>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
limit: number,
|
limit: number,
|
||||||
|
|
@ -57,12 +71,7 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async stat(filePath: string): Promise<FsStatResult> {
|
async stat(filePath: string): Promise<FsStatResult> {
|
||||||
const stats = await Promise.race([
|
const stats = await statWithTimeout(filePath, STAT_TIMEOUT_MS);
|
||||||
fs.promises.stat(filePath),
|
|
||||||
new Promise<fs.Stats>((_resolve, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs: stats.mtimeMs,
|
||||||
|
|
@ -90,13 +99,8 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
||||||
let birthtimeMs: number | undefined;
|
let birthtimeMs: number | undefined;
|
||||||
let size: number | undefined;
|
let size: number | undefined;
|
||||||
try {
|
try {
|
||||||
const fullPath = `${dirPath}/${entry.name}`;
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
const stat = await Promise.race([
|
const stat = await statWithTimeout(fullPath, STAT_TIMEOUT_MS);
|
||||||
fs.promises.stat(fullPath),
|
|
||||||
new Promise<fs.Stats>((_resolve, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
mtimeMs = stat.mtimeMs;
|
mtimeMs = stat.mtimeMs;
|
||||||
birthtimeMs = stat.birthtimeMs;
|
birthtimeMs = stat.birthtimeMs;
|
||||||
size = stat.size;
|
size = stat.size;
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,8 @@ export class TeamConfigReader {
|
||||||
const mergeMember = (m: TeamMember): void => {
|
const mergeMember = (m: TeamMember): void => {
|
||||||
const name = m.name?.trim();
|
const name = m.name?.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||||
|
if (name === 'team-lead' || m.agentType === 'team-lead') return;
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
const existing = memberMap.get(key);
|
const existing = memberMap.get(key);
|
||||||
memberMap.set(key, {
|
memberMap.set(key, {
|
||||||
|
|
|
||||||
|
|
@ -657,15 +657,15 @@ export class TeamDataService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
request: { members: { name: string; role?: string; workflow?: string }[] }
|
request: { members: { name: string; role?: string; workflow?: string }[] }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!request.members.length) {
|
|
||||||
throw new Error('At least one member is required');
|
|
||||||
}
|
|
||||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||||
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const newMembers: TeamMember[] = request.members.map((member, index) => {
|
const nextByName = new Set<string>();
|
||||||
|
|
||||||
|
const nextActive: TeamMember[] = request.members.map((member, index) => {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
|
nextByName.add(name.toLowerCase());
|
||||||
const prev = existingByName.get(name.toLowerCase());
|
const prev = existingByName.get(name.toLowerCase());
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
|
@ -674,9 +674,24 @@ export class TeamDataService {
|
||||||
agentType: prev?.agentType ?? 'general-purpose',
|
agentType: prev?.agentType ?? 'general-purpose',
|
||||||
color: prev?.color ?? getMemberColor(index),
|
color: prev?.color ?? getMemberColor(index),
|
||||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||||
|
removedAt: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await this.membersMetaStore.writeMembers(teamName, newMembers);
|
|
||||||
|
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||||
|
const nextRemoved: TeamMember[] = [];
|
||||||
|
for (const prev of existing) {
|
||||||
|
const prevName = prev.name.trim();
|
||||||
|
if (!prevName) continue;
|
||||||
|
const key = prevName.toLowerCase();
|
||||||
|
if (nextByName.has(key)) continue;
|
||||||
|
nextRemoved.push({
|
||||||
|
...prev,
|
||||||
|
removedAt: prev.removedAt ?? joinedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, [...nextActive, ...nextRemoved]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeMember(teamName: string, memberName: string): Promise<void> {
|
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -595,44 +595,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
||||||
const projectName = path.basename(request.cwd);
|
const projectName = path.basename(request.cwd);
|
||||||
|
|
||||||
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
const isSolo = request.members.length === 0;
|
||||||
|
const soloConstraint = isSolo
|
||||||
|
? '\n- You are starting as a SOLO team lead with no teammates. Do NOT use the Task tool to spawn teammates unless/until the team has members added later. Do NOT call SendMessage to any teammate unless/until such teammates exist (you may still message "user").'
|
||||||
|
: '';
|
||||||
|
|
||||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
const step2Block = isSolo
|
||||||
You are "${leadName}", the team lead.
|
? '2) Skip — this is a solo team with no teammates to spawn.'
|
||||||
|
: `2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown:
|
||||||
Goal: Provision a Claude Code agent team with live teammates.
|
|
||||||
${userPromptBlock}
|
|
||||||
${languageInstruction}
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Do NOT call TeamDelete under any circumstances.
|
|
||||||
- Do NOT use TodoWrite.
|
|
||||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
|
||||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
|
||||||
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
|
||||||
- Keep assistant text minimal.
|
|
||||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
|
||||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
|
||||||
- Use the team task board for assigned/substantial work.
|
|
||||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
|
||||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.
|
|
||||||
|
|
||||||
${teamCtlOps}
|
|
||||||
|
|
||||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
|
||||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
|
||||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
|
||||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
|
||||||
|
|
||||||
Message formatting:
|
|
||||||
${agentBlockPolicy}
|
|
||||||
|
|
||||||
Steps (execute in this exact order):
|
|
||||||
|
|
||||||
1) TeamCreate — create team "${request.teamName}":
|
|
||||||
- description: "${description}"
|
|
||||||
|
|
||||||
2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown:
|
|
||||||
|
|
||||||
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
|
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
|
||||||
// below, even though the text is identical across members. This duplicates ~4K chars per member
|
// below, even though the text is identical across members. This duplicates ~4K chars per member
|
||||||
|
|
@ -649,7 +619,48 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
|
||||||
.map((line) => ` ${line}`)
|
.map((line) => ` ${line}`)
|
||||||
.join('\n')}`
|
.join('\n')}`
|
||||||
)
|
)
|
||||||
.join('\n\n')}
|
.join('\n\n')}`;
|
||||||
|
|
||||||
|
const membersFooter = members ? `Members:\n${members}` : 'Members: (none — solo team lead)';
|
||||||
|
|
||||||
|
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||||
|
|
||||||
|
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||||
|
You are "${leadName}", the team lead.
|
||||||
|
|
||||||
|
Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
||||||
|
${userPromptBlock}
|
||||||
|
${languageInstruction}
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Do NOT call TeamDelete under any circumstances.
|
||||||
|
- Do NOT use TodoWrite.
|
||||||
|
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
||||||
|
- Do NOT shut down, terminate, or clean up the team or its members.
|
||||||
|
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
||||||
|
- Keep assistant text minimal.
|
||||||
|
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||||
|
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||||
|
- Use the team task board for assigned/substantial work.
|
||||||
|
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||||
|
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
||||||
|
|
||||||
|
${teamCtlOps}
|
||||||
|
|
||||||
|
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||||
|
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||||
|
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||||
|
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||||
|
|
||||||
|
Message formatting:
|
||||||
|
${agentBlockPolicy}
|
||||||
|
|
||||||
|
Steps (execute in this exact order):
|
||||||
|
|
||||||
|
1) TeamCreate — create team "${request.teamName}":
|
||||||
|
- description: "${description}"
|
||||||
|
|
||||||
|
${step2Block}
|
||||||
|
|
||||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||||
- Prefer fewer, broader tasks over many micro-tasks.
|
- Prefer fewer, broader tasks over many micro-tasks.
|
||||||
|
|
@ -666,8 +677,7 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
|
||||||
|
|
||||||
4) After all steps, output a short summary.
|
4) After all steps, output a short summary.
|
||||||
|
|
||||||
Members:
|
${membersFooter}
|
||||||
${members}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -690,6 +700,17 @@ function buildLaunchPrompt(
|
||||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
||||||
const projectName = path.basename(request.cwd);
|
const projectName = path.basename(request.cwd);
|
||||||
|
|
||||||
|
const isSolo = members.length === 0;
|
||||||
|
const soloConstraint = isSolo
|
||||||
|
? '\n- You are starting as a SOLO team lead with no teammates. Do NOT use the Task tool to spawn teammates unless/until the team has members added later. Do NOT call SendMessage to any teammate unless/until such teammates exist (you may still message "user").'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let step2And3Block: string;
|
||||||
|
if (isSolo) {
|
||||||
|
step2And3Block = `2) Skip — solo team, no teammates to spawn.
|
||||||
|
|
||||||
|
3) Check the task board. Work on pending tasks directly.`;
|
||||||
|
} else {
|
||||||
// Build per-member task snapshots to include in each teammate's spawn prompt
|
// Build per-member task snapshots to include in each teammate's spawn prompt
|
||||||
const memberTaskBlocks = new Map<string, string>();
|
const memberTaskBlocks = new Map<string, string>();
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
|
|
@ -721,6 +742,27 @@ function buildLaunchPrompt(
|
||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
|
step2And3Block = `2) Spawn each existing member as a live teammate using the Task tool:
|
||||||
|
- team_name: "${request.teamName}"
|
||||||
|
- name: the member's name
|
||||||
|
- subagent_type: "general-purpose"
|
||||||
|
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
|
||||||
|
Include the following agent-only instructions verbatim in each teammate's prompt:
|
||||||
|
|
||||||
|
${taskProtocol}
|
||||||
|
|
||||||
|
${processRegistration}
|
||||||
|
|
||||||
|
Per-member spawn instructions:
|
||||||
|
${memberSpawnInstructions}
|
||||||
|
|
||||||
|
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersFooter = membersBlock
|
||||||
|
? `Members:\n${membersBlock}`
|
||||||
|
: 'Members: (none — solo team lead)';
|
||||||
|
|
||||||
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||||
|
|
||||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||||
|
|
@ -741,7 +783,7 @@ Constraints:
|
||||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||||
- Use the team task board for assigned/substantial work.
|
- Use the team task board for assigned/substantial work.
|
||||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.
|
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
||||||
|
|
||||||
${teamCtlOps}
|
${teamCtlOps}
|
||||||
|
|
||||||
|
|
@ -757,26 +799,11 @@ Steps (execute in this exact order):
|
||||||
|
|
||||||
1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state.
|
1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state.
|
||||||
|
|
||||||
2) Spawn each existing member as a live teammate using the Task tool:
|
${step2And3Block}
|
||||||
- team_name: "${request.teamName}"
|
|
||||||
- name: the member's name
|
|
||||||
- subagent_type: "general-purpose"
|
|
||||||
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
|
|
||||||
Include the following agent-only instructions verbatim in each teammate's prompt:
|
|
||||||
|
|
||||||
${taskProtocol}
|
|
||||||
|
|
||||||
${processRegistration}
|
|
||||||
|
|
||||||
Per-member spawn instructions:
|
|
||||||
${memberSpawnInstructions}
|
|
||||||
|
|
||||||
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.
|
|
||||||
|
|
||||||
4) After all steps, output a short summary of reconnected members and resumed tasks.
|
4) After all steps, output a short summary of reconnected members and resumed tasks.
|
||||||
|
|
||||||
Members:
|
${membersFooter}
|
||||||
${membersBlock}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -878,6 +905,7 @@ let cachedProbeResult: CachedProbeResult | null = null;
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private readonly runs = new Map<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly activeByTeam = new Map<string, string>();
|
private readonly activeByTeam = new Map<string, string>();
|
||||||
|
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||||
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||||
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
|
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
|
||||||
|
|
@ -891,6 +919,29 @@ export class TeamProvisioningService {
|
||||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes operations per team name using promise-chaining.
|
||||||
|
* Same pattern as withInboxLock / withTaskLock.
|
||||||
|
* Prevents TOCTOU races between concurrent createTeam/launchTeam calls.
|
||||||
|
*/
|
||||||
|
private async withTeamLock<T>(teamName: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = this.teamOpLocks.get(teamName) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const mine = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
this.teamOpLocks.set(teamName, mine);
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (this.teamOpLocks.get(teamName) === mine) {
|
||||||
|
this.teamOpLocks.delete(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
||||||
this.teamChangeEmitter = emitter;
|
this.teamChangeEmitter = emitter;
|
||||||
}
|
}
|
||||||
|
|
@ -1273,11 +1324,25 @@ export class TeamProvisioningService {
|
||||||
async createTeam(
|
async createTeam(
|
||||||
request: TeamCreateRequest,
|
request: TeamCreateRequest,
|
||||||
onProgress: (progress: TeamProvisioningProgress) => void
|
onProgress: (progress: TeamProvisioningProgress) => void
|
||||||
|
): Promise<TeamCreateResponse> {
|
||||||
|
return this.withTeamLock(request.teamName, async () => {
|
||||||
|
return this._createTeamInner(request, onProgress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createTeamInner(
|
||||||
|
request: TeamCreateRequest,
|
||||||
|
onProgress: (progress: TeamProvisioningProgress) => void
|
||||||
): Promise<TeamCreateResponse> {
|
): Promise<TeamCreateResponse> {
|
||||||
if (this.activeByTeam.has(request.teamName)) {
|
if (this.activeByTeam.has(request.teamName)) {
|
||||||
throw new Error('Provisioning already running');
|
throw new Error('Provisioning already running');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock)
|
||||||
|
const pendingKey = `pending-${randomUUID()}`;
|
||||||
|
this.activeByTeam.set(request.teamName, pendingKey);
|
||||||
|
|
||||||
|
try {
|
||||||
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
||||||
for (const probe of teamsBasePathsToProbe) {
|
for (const probe of teamsBasePathsToProbe) {
|
||||||
const configPath = path.join(probe.basePath, request.teamName, 'config.json');
|
const configPath = path.join(probe.basePath, request.teamName, 'config.json');
|
||||||
|
|
@ -1358,7 +1423,7 @@ export class TeamProvisioningService {
|
||||||
'--setting-sources',
|
'--setting-sources',
|
||||||
'user,project,local',
|
'user,project,local',
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'TeamDelete,TodoWrite',
|
request.members.length === 0 ? 'TeamDelete,TodoWrite,Task' : 'TeamDelete,TodoWrite',
|
||||||
'--dangerously-skip-permissions',
|
'--dangerously-skip-permissions',
|
||||||
...(request.model ? ['--model', request.model] : []),
|
...(request.model ? ['--model', request.model] : []),
|
||||||
];
|
];
|
||||||
|
|
@ -1374,7 +1439,9 @@ export class TeamProvisioningService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress(run, 'spawning', 'Starting Claude CLI process', { pid: child.pid ?? undefined });
|
updateProgress(run, 'spawning', 'Starting Claude CLI process', {
|
||||||
|
pid: child.pid ?? undefined,
|
||||||
|
});
|
||||||
run.onProgress(run.progress);
|
run.onProgress(run.progress);
|
||||||
run.child = child;
|
run.child = child;
|
||||||
run.spawnContext = {
|
run.spawnContext = {
|
||||||
|
|
@ -1443,16 +1510,37 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
return { runId };
|
return { runId };
|
||||||
|
} catch (error) {
|
||||||
|
// Ensure the per-team lock doesn't get stuck on failures.
|
||||||
|
if (this.activeByTeam.get(request.teamName) === pendingKey) {
|
||||||
|
this.activeByTeam.delete(request.teamName);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async launchTeam(
|
async launchTeam(
|
||||||
request: TeamLaunchRequest,
|
request: TeamLaunchRequest,
|
||||||
onProgress: (progress: TeamProvisioningProgress) => void
|
onProgress: (progress: TeamProvisioningProgress) => void
|
||||||
|
): Promise<TeamLaunchResponse> {
|
||||||
|
return this.withTeamLock(request.teamName, async () => {
|
||||||
|
return this._launchTeamInner(request, onProgress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _launchTeamInner(
|
||||||
|
request: TeamLaunchRequest,
|
||||||
|
onProgress: (progress: TeamProvisioningProgress) => void
|
||||||
): Promise<TeamLaunchResponse> {
|
): Promise<TeamLaunchResponse> {
|
||||||
if (this.activeByTeam.has(request.teamName)) {
|
if (this.activeByTeam.has(request.teamName)) {
|
||||||
throw new Error('Team is already running');
|
throw new Error('Team is already running');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock)
|
||||||
|
const pendingKey = `pending-${randomUUID()}`;
|
||||||
|
this.activeByTeam.set(request.teamName, pendingKey);
|
||||||
|
|
||||||
|
try {
|
||||||
// Verify config.json exists — team must already be provisioned
|
// Verify config.json exists — team must already be provisioned
|
||||||
const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json');
|
||||||
const configRaw = await tryReadRegularFileUtf8(configPath, {
|
const configRaw = await tryReadRegularFileUtf8(configPath, {
|
||||||
|
|
@ -1496,7 +1584,10 @@ export class TeamProvisioningService {
|
||||||
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
|
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
|
||||||
// If the project path changed, the old session JSONL won't be found by the CLI
|
// If the project path changed, the old session JSONL won't be found by the CLI
|
||||||
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
|
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
|
||||||
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
|
if (
|
||||||
|
storedProjectPath &&
|
||||||
|
path.resolve(storedProjectPath) !== path.resolve(request.cwd)
|
||||||
|
) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${request.teamName}] Project path changed: ${storedProjectPath} → ${request.cwd}. ` +
|
`[${request.teamName}] Project path changed: ${storedProjectPath} → ${request.cwd}. ` +
|
||||||
`Skipping session resume — sessions are per-project.`
|
`Skipping session resume — sessions are per-project.`
|
||||||
|
|
@ -1615,7 +1706,9 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
existingTasks = await taskReader.getTasks(request.teamName);
|
existingTasks = await taskReader.getTasks(request.teamName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`[${request.teamName}] Failed to read tasks for launch prompt: ${String(error)}`);
|
logger.warn(
|
||||||
|
`[${request.teamName}] Failed to read tasks for launch prompt: ${String(error)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
||||||
|
|
@ -1636,7 +1729,7 @@ export class TeamProvisioningService {
|
||||||
'--setting-sources',
|
'--setting-sources',
|
||||||
'user,project,local',
|
'user,project,local',
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'TeamDelete,TodoWrite',
|
expectedMemberSpecs.length === 0 ? 'TeamDelete,TodoWrite,Task' : 'TeamDelete,TodoWrite',
|
||||||
'--dangerously-skip-permissions',
|
'--dangerously-skip-permissions',
|
||||||
];
|
];
|
||||||
if (previousSessionId) {
|
if (previousSessionId) {
|
||||||
|
|
@ -1737,6 +1830,13 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
return { runId };
|
return { runId };
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up pending key if failure occurred before runId was set
|
||||||
|
if (this.activeByTeam.get(request.teamName) === pendingKey) {
|
||||||
|
this.activeByTeam.delete(request.teamName);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProvisioningStatus(runId: string): Promise<TeamProvisioningProgress> {
|
async getProvisioningStatus(runId: string): Promise<TeamProvisioningProgress> {
|
||||||
|
|
@ -2381,6 +2481,34 @@ export class TeamProvisioningService {
|
||||||
if (run.isLaunch) {
|
if (run.isLaunch) {
|
||||||
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
||||||
await this.cleanupPrelaunchBackup(run.teamName);
|
await this.cleanupPrelaunchBackup(run.teamName);
|
||||||
|
|
||||||
|
// Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate
|
||||||
|
// a stale config.json was present during launch (double-launch race).
|
||||||
|
try {
|
||||||
|
const postLaunchConfigPath = path.join(getTeamsBasePath(), run.teamName, 'config.json');
|
||||||
|
const raw = await tryReadRegularFileUtf8(postLaunchConfigPath, {
|
||||||
|
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||||
|
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||||
|
});
|
||||||
|
if (raw) {
|
||||||
|
const config = JSON.parse(raw) as {
|
||||||
|
members?: { name?: string; agentType?: string }[];
|
||||||
|
};
|
||||||
|
const suffixed = (config.members ?? []).filter(
|
||||||
|
(m) => typeof m.name === 'string' && /-\d+$/.test(m.name) && m.agentType !== 'team-lead'
|
||||||
|
);
|
||||||
|
if (suffixed.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[${run.teamName}] Post-launch: detected suffixed members: ` +
|
||||||
|
`${suffixed.map((m) => m.name).join(', ')}. ` +
|
||||||
|
'This usually means the team was launched with stale config.json.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
|
||||||
const readyMessage = 'Team launched — process alive and ready';
|
const readyMessage = 'Team launched — process alive and ready';
|
||||||
const progress = updateProgress(run, 'ready', readyMessage, {
|
const progress = updateProgress(run, 'ready', readyMessage, {
|
||||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||||
|
|
@ -2513,6 +2641,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.fsPhase === 'waiting_members') {
|
if (run.fsPhase === 'waiting_members') {
|
||||||
|
if (request.members.length === 0) {
|
||||||
|
run.fsPhase = 'waiting_tasks';
|
||||||
|
const progress = updateProgress(
|
||||||
|
run,
|
||||||
|
'monitoring',
|
||||||
|
'Solo team, skipping member inbox wait'
|
||||||
|
);
|
||||||
|
run.onProgress(progress);
|
||||||
|
} else {
|
||||||
const teamDir = (await resolveTeamDir()) ?? configuredTeamDir;
|
const teamDir = (await resolveTeamDir()) ?? configuredTeamDir;
|
||||||
const inboxDir = path.join(teamDir, 'inboxes');
|
const inboxDir = path.join(teamDir, 'inboxes');
|
||||||
const inboxCount = await countFiles(inboxDir, '.json');
|
const inboxCount = await countFiles(inboxDir, '.json');
|
||||||
|
|
@ -2533,6 +2670,7 @@ export class TeamProvisioningService {
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (run.fsPhase === 'waiting_tasks') {
|
if (run.fsPhase === 'waiting_tasks') {
|
||||||
if (run.waitingTasksSince === null) {
|
if (run.waitingTasksSince === null) {
|
||||||
|
|
@ -3229,10 +3367,18 @@ export class TeamProvisioningService {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
if (baseNames.size === 0) {
|
if (baseNames.size === 0) {
|
||||||
|
const allConfigNames = new Set<string>();
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
|
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
|
||||||
if (name && agentType && agentType !== 'team-lead' && !/-\d+$/.test(name)) {
|
if (name && agentType && agentType !== 'team-lead') {
|
||||||
|
allConfigNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of allConfigNames) {
|
||||||
|
const match = /^(.+)-\d+$/.exec(name);
|
||||||
|
// Only exclude CLI-suffixed names (alice-2) when the base name (alice) also exists
|
||||||
|
if (!match || !allConfigNames.has(match[1])) {
|
||||||
baseNames.add(name);
|
baseNames.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3476,8 +3622,12 @@ export class TeamProvisioningService {
|
||||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
|
if (member.agentType === 'team-lead' || member.name === 'team-lead') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const name = member.name?.trim();
|
const name = member.name?.trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
if (member.removedAt) continue;
|
||||||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
||||||
const workflow =
|
const workflow =
|
||||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
||||||
|
|
@ -3505,13 +3655,21 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inboxNames = Array.from(
|
const allInboxNames = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
(await this.inboxReader.listInboxNames(teamName))
|
(await this.inboxReader.listInboxNames(teamName))
|
||||||
.map((name) => name.trim())
|
.map((name) => name.trim())
|
||||||
.filter((name) => name.length > 0)
|
.filter((name) => name.length > 0)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const inboxNameSet = new Set(allInboxNames);
|
||||||
|
const inboxNames = allInboxNames
|
||||||
|
.filter((name) => name !== 'team-lead')
|
||||||
|
.filter((name) => {
|
||||||
|
const match = /^(.+)-\d+$/.exec(name);
|
||||||
|
// Only filter CLI-suffixed names (alice-2) when the base name (alice) also exists
|
||||||
|
return !match || !inboxNameSet.has(match[1]);
|
||||||
|
});
|
||||||
if (inboxNames.length > 0) {
|
if (inboxNames.length > 0) {
|
||||||
const members = inboxNames.map((name) => ({ name }));
|
const members = inboxNames.map((name) => ({ name }));
|
||||||
return { members, source: 'inboxes' };
|
return { members, source: 'inboxes' };
|
||||||
|
|
@ -3535,11 +3693,23 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let configParseFailed = false;
|
||||||
|
try {
|
||||||
|
JSON.parse(configRaw);
|
||||||
|
} catch {
|
||||||
|
configParseFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
members: [],
|
members: [],
|
||||||
source: 'config-fallback',
|
source: 'config-fallback',
|
||||||
|
...(configParseFailed
|
||||||
|
? {
|
||||||
warning:
|
warning:
|
||||||
'No teammate roster found in members.meta.json, inboxes, or config.json. Launch will continue without explicit teammate names.',
|
'Config could not be parsed during launch roster discovery. ' +
|
||||||
|
'Launch will continue without explicit teammate names.',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3554,7 +3724,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
const byName = new Map<string, TeamCreateRequest['members'][number]>();
|
||||||
for (const member of parsed.members) {
|
for (const member of parsed.members) {
|
||||||
if (!member || member.agentType === 'team-lead') continue;
|
if (!member || member.agentType === 'team-lead' || member.name === 'team-lead') continue;
|
||||||
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
byName.set(name, { name });
|
byName.set(name, { name });
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
[data?.members]
|
[data?.members]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeTeammateCount = useMemo(
|
||||||
|
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||||||
|
[activeMembers]
|
||||||
|
);
|
||||||
|
|
||||||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||||
|
|
||||||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||||
|
|
@ -928,7 +933,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
sectionId="team"
|
sectionId="team"
|
||||||
title="Team"
|
title="Team"
|
||||||
icon={<Users size={14} />}
|
icon={<Users size={14} />}
|
||||||
badge={activeMembers.length}
|
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -729,6 +729,10 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
{team.members && team.members.length > 0 ? (
|
{team.members && team.members.length > 0 ? (
|
||||||
renderMemberChips(team.members)
|
renderMemberChips(team.members)
|
||||||
|
) : team.memberCount === 0 ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
|
Solo
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
Members: {team.memberCount}
|
Members: {team.memberCount}
|
||||||
|
|
|
||||||
|
|
@ -156,14 +156,6 @@ function validateRequest(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (request.members.length === 0) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
errors: {
|
|
||||||
members: 'At least one member is required',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.members.some((member) => !member.name.trim())) {
|
if (request.members.some((member) => !member.name.trim())) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,6 @@ export const EditTeamDialog = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const builtMembers = buildMembersFromDrafts(members);
|
const builtMembers = buildMembersFromDrafts(members);
|
||||||
if (builtMembers.length === 0) {
|
|
||||||
setError('At least one member is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export const MemberList = ({
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
No members found
|
Solo team — lead only
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,6 @@ declare global {
|
||||||
// module-level side effect guarded by a global flag.
|
// module-level side effect guarded by a global flag.
|
||||||
if (!window.__claudeTeamsUiDidInit) {
|
if (!window.__claudeTeamsUiDidInit) {
|
||||||
window.__claudeTeamsUiDidInit = true;
|
window.__claudeTeamsUiDidInit = true;
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
// Intentionally console.warn so it shows up in main terminal via preload forwarding.
|
|
||||||
console.warn('[Perf:Renderer] boot renderer/main.tsx');
|
|
||||||
}
|
|
||||||
initializeNotificationListeners();
|
initializeNotificationListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,43 +81,17 @@ export function initializeNotificationListeners(): () => void {
|
||||||
// Components also fire these from useEffect — loading guards in each action
|
// Components also fire these from useEffect — loading guards in each action
|
||||||
// prevent duplicate IPC calls (whichever caller starts first wins).
|
// prevent duplicate IPC calls (whichever caller starts first wins).
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const isDev = import.meta.env.DEV;
|
|
||||||
const log = (msg: string): void => {
|
|
||||||
if (!isDev) return;
|
|
||||||
console.warn(`[Perf:Renderer] init ${msg}`);
|
|
||||||
};
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
// Config: fast (in-memory read) — needed for theme before first paint.
|
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||||
log('fetchConfig:start');
|
|
||||||
const configStartedAt = Date.now();
|
|
||||||
await useStore.getState().fetchConfig();
|
await useStore.getState().fetchConfig();
|
||||||
log(`fetchConfig:done ms=${Date.now() - configStartedAt}`);
|
|
||||||
|
|
||||||
// Remaining fetches have no data dependency on each other — run in parallel
|
// Remaining fetches have no data dependency on each other — run in parallel
|
||||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||||
const run = async (label: string, fn: () => Promise<void>): Promise<void> => {
|
|
||||||
log(`${label}:start`);
|
|
||||||
const s = Date.now();
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
log(`${label}:done ms=${Date.now() - s}`);
|
|
||||||
} catch (e) {
|
|
||||||
log(
|
|
||||||
`${label}:error ms=${Date.now() - s} msg=${e instanceof Error ? e.message : String(e)}`
|
|
||||||
);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
run('fetchRepositoryGroups', () => useStore.getState().fetchRepositoryGroups()),
|
useStore.getState().fetchRepositoryGroups(),
|
||||||
run('fetchAllTasks', () => useStore.getState().fetchAllTasks()),
|
useStore.getState().fetchAllTasks(),
|
||||||
run('fetchTeams', () => useStore.getState().fetchTeams()),
|
useStore.getState().fetchTeams(),
|
||||||
run('fetchNotifications', () => useStore.getState().fetchNotifications()),
|
useStore.getState().fetchNotifications(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
log(`init:done ms=${Date.now() - startedAt}`);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// CLI status check is non-critical for initial render (spawns child processes
|
// CLI status check is non-critical for initial render (spawns child processes
|
||||||
|
|
|
||||||
|
|
@ -361,9 +361,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
fetchAllTasks: async () => {
|
fetchAllTasks: async () => {
|
||||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||||
if (get().globalTasksLoading) return;
|
if (get().globalTasksLoading) return;
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[Perf:Renderer] fetchAllTasks:enter');
|
|
||||||
}
|
|
||||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||||
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||||
const isInitialLoad = !get().globalTasksInitialized;
|
const isInitialLoad = !get().globalTasksInitialized;
|
||||||
|
|
@ -374,18 +371,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const wasFirst = isFirstFetchAllTasks;
|
const wasFirst = isFirstFetchAllTasks;
|
||||||
isFirstFetchAllTasks = false;
|
isFirstFetchAllTasks = false;
|
||||||
try {
|
try {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[Perf:Renderer] fetchAllTasks:invoke');
|
|
||||||
}
|
|
||||||
const tasks = await withTimeout(
|
const tasks = await withTimeout(
|
||||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
'fetchAllTasks'
|
'fetchAllTasks'
|
||||||
);
|
);
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn(`[Perf:Renderer] fetchAllTasks:received count=${tasks.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wasFirst) {
|
if (!wasFirst) {
|
||||||
const notifyOnClarifications =
|
const notifyOnClarifications =
|
||||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||||
|
|
@ -405,9 +395,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
globalTasksInitialized: true,
|
globalTasksInitialized: true,
|
||||||
globalTasksError: null,
|
globalTasksError: null,
|
||||||
});
|
});
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[Perf:Renderer] fetchAllTasks:setState:done');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
globalTasksLoading: false,
|
globalTasksLoading: false,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ import {
|
||||||
TEAM_UPDATE_MEMBER_ROLE,
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
TEAM_ADD_TASK_RELATIONSHIP,
|
TEAM_ADD_TASK_RELATIONSHIP,
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
|
TEAM_REPLACE_MEMBERS,
|
||||||
} from '../../../src/preload/constants/ipcChannels';
|
} from '../../../src/preload/constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
initializeTeamHandlers,
|
initializeTeamHandlers,
|
||||||
|
|
@ -149,6 +150,8 @@ describe('ipc teams handlers', () => {
|
||||||
setTaskNeedsClarification: vi.fn(async () => undefined),
|
setTaskNeedsClarification: vi.fn(async () => undefined),
|
||||||
addTaskRelationship: vi.fn(async () => undefined),
|
addTaskRelationship: vi.fn(async () => undefined),
|
||||||
removeTaskRelationship: vi.fn(async () => undefined),
|
removeTaskRelationship: vi.fn(async () => undefined),
|
||||||
|
replaceMembers: vi.fn(async () => undefined),
|
||||||
|
createTeamConfig: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const provisioningService = {
|
const provisioningService = {
|
||||||
prepareForProvisioning: vi.fn(async () => ({
|
prepareForProvisioning: vi.fn(async () => ({
|
||||||
|
|
@ -617,4 +620,68 @@ describe('ipc teams handlers', () => {
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('solo team (zero members)', () => {
|
||||||
|
it('createTeam accepts members: [] (provisioning validation)', async () => {
|
||||||
|
const handler = handlers.get(TEAM_CREATE)!;
|
||||||
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||||
|
teamName: 'solo-team',
|
||||||
|
members: [],
|
||||||
|
cwd: os.tmpdir(),
|
||||||
|
})) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
|
||||||
|
const callArg = provisioningService.createTeam.mock.calls[0][0];
|
||||||
|
expect(callArg.members).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleCreateConfig accepts members: []', async () => {
|
||||||
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||||
|
const result = (await handler({} as never, {
|
||||||
|
teamName: 'solo-team',
|
||||||
|
members: [],
|
||||||
|
cwd: os.tmpdir(),
|
||||||
|
})) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleReplaceMembers accepts members: []', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
members: [],
|
||||||
|
})) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.replaceMembers).toHaveBeenCalledWith('my-team', { members: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects members as non-array in createTeam', async () => {
|
||||||
|
const handler = handlers.get(TEAM_CREATE)!;
|
||||||
|
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||||
|
teamName: 'solo-team',
|
||||||
|
members: 'not-array',
|
||||||
|
cwd: os.tmpdir(),
|
||||||
|
})) as { success: boolean; error: string };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('members must be an array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects members as non-array in handleCreateConfig', async () => {
|
||||||
|
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||||
|
const result = (await handler({} as never, {
|
||||||
|
teamName: 'solo-team',
|
||||||
|
members: 'not-array',
|
||||||
|
})) as { success: boolean; error: string };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('members must be an array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects members as non-array in handleReplaceMembers', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
members: 'not-array',
|
||||||
|
})) as { success: boolean; error: string };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('members must be an array');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue