diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c1ba1b6f..97db4ad6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -37,7 +37,7 @@ import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; -import { spawn } from 'child_process'; +import { spawn, type ChildProcess } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; @@ -56,6 +56,19 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { isTaskCommentForwardingLive } from './TeamTaskCommentForwarding'; import { TeamTaskReader } from './TeamTaskReader'; +/** + * Kill a team CLI process using SIGKILL (uncatchable). + * + * Newer Claude CLI versions (≥2.1.x) handle SIGTERM gracefully and run cleanup + * that deletes team files (config.json, inboxes/, tasks/). SIGKILL prevents this. + * + * ALWAYS use this instead of killProcessTree() for team processes. + * stdin.end() is also forbidden — EOF triggers the same cleanup. + */ +function killTeamProcess(child: ChildProcess | null | undefined): void { + killProcessTree(child, 'SIGKILL'); +} + import type { CrossTeamSendResult, InboxMessage, @@ -2376,10 +2389,9 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; - // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful - // shutdown which deletes team files (config.json, inboxes/, tasks/). - // SIGTERM alone kills the process before cleanup runs, preserving files. - killProcessTree(run.child); + // SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete + // team files during cleanup. SIGKILL is uncatchable — files are preserved. + killTeamProcess(run.child); this.cleanupRun(run); } @@ -2406,7 +2418,7 @@ export class TeamProvisioningService { } else { logger.error(`[${run.teamName}] Auth failure detected in ${source} after retry — giving up`); run.processKilled = true; - killProcessTree(run.child); + killTeamProcess(run.child); const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', { error: 'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' + @@ -2441,7 +2453,7 @@ export class TeamProvisioningService { run.child.stderr?.removeAllListeners('data'); run.child.removeAllListeners('error'); run.child.removeAllListeners('exit'); - killProcessTree(run.child); + killTeamProcess(run.child); run.child = null; } @@ -2527,7 +2539,7 @@ export class TeamProvisioningService { run.finalizingByTimeout = true; void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); - killProcessTree(run.child); + killTeamProcess(run.child); if (readyOnTimeout) return; const hint = run.isLaunch ? ' (launch)' : ''; @@ -2828,7 +2840,7 @@ export class TeamProvisioningService { run.finalizingByTimeout = true; void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); - killProcessTree(run.child); + killTeamProcess(run.child); if (readyOnTimeout) { return; // cleanupRun already called inside tryCompleteAfterTimeout } @@ -3231,7 +3243,7 @@ export class TeamProvisioningService { run.finalizingByTimeout = true; void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); - killProcessTree(run.child); + killTeamProcess(run.child); if (readyOnTimeout) { return; } @@ -3288,10 +3300,9 @@ export class TeamProvisioningService { run.cancelRequested = true; run.processKilled = true; - // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful - // shutdown which deletes team files (config.json, inboxes/, tasks/). - // SIGTERM alone kills the process before cleanup runs, preserving files. - killProcessTree(run.child); + // SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete + // team files during cleanup. SIGKILL is uncatchable — files are preserved. + killTeamProcess(run.child); const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); run.onProgress(progress); this.cleanupRun(run); @@ -4347,8 +4358,9 @@ export class TeamProvisioningService { /** * Stop the running process for a team. No-op if team is not running. + * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ - stopTeam(teamName: string, signal?: NodeJS.Signals): void { + stopTeam(teamName: string): void { const runId = this.getTrackedRunId(teamName); if (!runId) { return; @@ -4364,27 +4376,24 @@ export class TeamProvisioningService { } run.processKilled = true; run.cancelRequested = true; - // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful - // shutdown which deletes team files (config.json, inboxes/, tasks/). - // SIGTERM/SIGKILL kills the process before cleanup runs, preserving files. - killProcessTree(run.child, signal); + killTeamProcess(run.child); const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); this.cleanupRun(run); - logger.info(`[${teamName}] Process stopped (signal=${signal ?? 'SIGTERM'})`); + logger.info(`[${teamName}] Process stopped (SIGKILL)`); } /** * Stop all running team processes. Called during app shutdown. - * Uses SIGKILL (uncatchable) to guarantee the process dies instantly - * without any cleanup — prevents CLI from deleting team files on exit. + * Uses killTeamProcess() (SIGKILL) to guarantee instant death + * without CLI cleanup that would delete team files. */ stopAllTeams(): void { const alive = this.getAliveTeams(); if (alive.length === 0) return; logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`); for (const teamName of alive) { - this.stopTeam(teamName, 'SIGKILL'); + this.stopTeam(teamName); } } @@ -4724,7 +4733,7 @@ export class TeamProvisioningService { run.onProgress(progress); // Kill the process on provisioning error run.processKilled = true; - killProcessTree(run.child); + killTeamProcess(run.child); this.cleanupRun(run); } else if (run.provisioningComplete) { // Post-provisioning error: process alive, waiting for input. @@ -5389,7 +5398,7 @@ export class TeamProvisioningService { }); run.onProgress(progress); run.processKilled = true; - killProcessTree(run.child); + killTeamProcess(run.child); this.cleanupRun(run); return; } diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 4143a2d7..673d3345 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -15,7 +15,10 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; +import { + getCurrentProvisioningProgressForTeam, + isTeamProvisioningActive, +} from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { getBaseName } from '@renderer/utils/pathUtils'; @@ -241,6 +244,13 @@ export const TeamListView = (): React.JSX.Element => { [currentProvisioningRunIdByTeam, provisioningRuns] ); + /** Team names currently in active provisioning — prevents name conflicts in create dialog. */ + const provisioningTeamNames = useMemo(() => { + return Object.keys(currentProvisioningRunIdByTeam).filter((teamName) => + isTeamProvisioningActive(provisioningState, teamName) + ); + }, [currentProvisioningRunIdByTeam, provisioningState]); + // Fetch alive teams on mount and when teams list changes useEffect(() => { if (!electronMode) return; @@ -536,6 +546,7 @@ export const TeamListView = (): React.JSX.Element => { provisioningErrorsByTeam={provisioningErrorByTeam} clearProvisioningError={clearProvisioningError} existingTeamNames={teams.map((t) => t.teamName)} + provisioningTeamNames={provisioningTeamNames} activeTeams={activeTeams} initialData={copyData ?? undefined} defaultProjectPath={currentProjectPath} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index b76b7cac..af50f790 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -83,6 +83,8 @@ interface CreateTeamDialogProps { provisioningErrorsByTeam: Record; clearProvisioningError?: (teamName?: string) => void; existingTeamNames: string[]; + /** Team names currently in active provisioning (launching) — used to prevent name conflicts. */ + provisioningTeamNames?: string[]; activeTeams?: ActiveTeamRef[]; initialData?: TeamCopyData; defaultProjectPath?: string | null; @@ -207,6 +209,7 @@ export const CreateTeamDialog = ({ provisioningErrorsByTeam, clearProvisioningError, existingTeamNames, + provisioningTeamNames = [], activeTeams, initialData, defaultProjectPath, @@ -339,7 +342,12 @@ export const CreateTeamDialog = ({ const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const dialogTeamNameKey = sanitizeTeamName(teamName.trim()); - const suggestedTeamName = getNextSuggestedTeamName(existingTeamNames); + /** All taken names: existing teams + teams currently being provisioned. */ + const allTakenTeamNames = useMemo( + () => [...new Set([...existingTeamNames, ...provisioningTeamNames])], + [existingTeamNames, provisioningTeamNames] + ); + const suggestedTeamName = getNextSuggestedTeamName(allTakenTeamNames); // Clear stale provisioning error when dialog opens useEffect(() => { @@ -552,6 +560,9 @@ export const CreateTeamDialog = ({ ); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); + const isNameProvisioning = + provisioningTeamNames.includes(sanitizedTeamName) && + !existingTeamNames.includes(sanitizedTeamName); const request = useMemo( () => ({ @@ -640,9 +651,10 @@ export const CreateTeamDialog = ({ }, [conflictingTeam?.teamName, effectiveCwd]); const handleSubmit = (): void => { - if (existingTeamNames.includes(sanitizedTeamName)) { - setFieldErrors({ teamName: 'Team name already exists' }); - setLocalError('Team name already exists'); + if (allTakenTeamNames.includes(sanitizedTeamName)) { + const msg = isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'; + setFieldErrors({ teamName: msg }); + setLocalError(msg); return; } const validation = validateRequest(request, { requireCwd: launchTeam }); @@ -818,16 +830,16 @@ export const CreateTeamDialog = ({ id="team-name" className={cn( 'h-8 text-xs', - (fieldErrors.teamName || existingTeamNames.includes(sanitizedTeamName)) && + (fieldErrors.teamName || allTakenTeamNames.includes(sanitizedTeamName)) && 'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]' )} value={teamName} onChange={(event) => handleTeamNameChange(event.target.value)} placeholder={suggestedTeamName} /> - {existingTeamNames.includes(sanitizedTeamName) ? ( + {allTakenTeamNames.includes(sanitizedTeamName) ? (

- Team name already exists + {isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'}

) : validateTeamNameInline(teamName) ? (