refactor: streamline team process termination and enhance team name conflict handling
- Introduced a new `killTeamProcess` function to consistently use SIGKILL for terminating team processes, preventing unintended cleanup of team files. - Updated multiple instances in `TeamProvisioningService` to utilize `killTeamProcess` for improved clarity and maintainability. - Enhanced `CreateTeamDialog` and `TeamListView` components to manage team name conflicts more effectively by incorporating active provisioning states.
This commit is contained in:
parent
b886b6bf83
commit
e60229622d
3 changed files with 65 additions and 33 deletions
|
|
@ -37,7 +37,7 @@ import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
import { spawn } from 'child_process';
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
@ -56,6 +56,19 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { isTaskCommentForwardingLive } from './TeamTaskCommentForwarding';
|
import { isTaskCommentForwardingLive } from './TeamTaskCommentForwarding';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
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 {
|
import type {
|
||||||
CrossTeamSendResult,
|
CrossTeamSendResult,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
|
@ -2376,10 +2389,9 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
run.cancelRequested = true;
|
run.cancelRequested = true;
|
||||||
// Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful
|
// SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete
|
||||||
// shutdown which deletes team files (config.json, inboxes/, tasks/).
|
// team files during cleanup. SIGKILL is uncatchable — files are preserved.
|
||||||
// SIGTERM alone kills the process before cleanup runs, preserving files.
|
killTeamProcess(run.child);
|
||||||
killProcessTree(run.child);
|
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2406,7 +2418,7 @@ export class TeamProvisioningService {
|
||||||
} else {
|
} else {
|
||||||
logger.error(`[${run.teamName}] Auth failure detected in ${source} after retry — giving up`);
|
logger.error(`[${run.teamName}] Auth failure detected in ${source} after retry — giving up`);
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
|
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
|
||||||
error:
|
error:
|
||||||
'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
|
'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.stderr?.removeAllListeners('data');
|
||||||
run.child.removeAllListeners('error');
|
run.child.removeAllListeners('error');
|
||||||
run.child.removeAllListeners('exit');
|
run.child.removeAllListeners('exit');
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
run.child = null;
|
run.child = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2527,7 +2539,7 @@ export class TeamProvisioningService {
|
||||||
run.finalizingByTimeout = true;
|
run.finalizingByTimeout = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
if (readyOnTimeout) return;
|
if (readyOnTimeout) return;
|
||||||
|
|
||||||
const hint = run.isLaunch ? ' (launch)' : '';
|
const hint = run.isLaunch ? ' (launch)' : '';
|
||||||
|
|
@ -2828,7 +2840,7 @@ export class TeamProvisioningService {
|
||||||
run.finalizingByTimeout = true;
|
run.finalizingByTimeout = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
if (readyOnTimeout) {
|
if (readyOnTimeout) {
|
||||||
return; // cleanupRun already called inside tryCompleteAfterTimeout
|
return; // cleanupRun already called inside tryCompleteAfterTimeout
|
||||||
}
|
}
|
||||||
|
|
@ -3231,7 +3243,7 @@ export class TeamProvisioningService {
|
||||||
run.finalizingByTimeout = true;
|
run.finalizingByTimeout = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
if (readyOnTimeout) {
|
if (readyOnTimeout) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3288,10 +3300,9 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
run.cancelRequested = true;
|
run.cancelRequested = true;
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
// Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful
|
// SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete
|
||||||
// shutdown which deletes team files (config.json, inboxes/, tasks/).
|
// team files during cleanup. SIGKILL is uncatchable — files are preserved.
|
||||||
// SIGTERM alone kills the process before cleanup runs, preserving files.
|
killTeamProcess(run.child);
|
||||||
killProcessTree(run.child);
|
|
||||||
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
|
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
|
|
@ -4347,8 +4358,9 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the running process for a team. No-op if team is not running.
|
* 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);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -4364,27 +4376,24 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
run.cancelRequested = true;
|
run.cancelRequested = true;
|
||||||
// Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful
|
killTeamProcess(run.child);
|
||||||
// shutdown which deletes team files (config.json, inboxes/, tasks/).
|
|
||||||
// SIGTERM/SIGKILL kills the process before cleanup runs, preserving files.
|
|
||||||
killProcessTree(run.child, signal);
|
|
||||||
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
this.cleanupRun(run);
|
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.
|
* Stop all running team processes. Called during app shutdown.
|
||||||
* Uses SIGKILL (uncatchable) to guarantee the process dies instantly
|
* Uses killTeamProcess() (SIGKILL) to guarantee instant death
|
||||||
* without any cleanup — prevents CLI from deleting team files on exit.
|
* without CLI cleanup that would delete team files.
|
||||||
*/
|
*/
|
||||||
stopAllTeams(): void {
|
stopAllTeams(): void {
|
||||||
const alive = this.getAliveTeams();
|
const alive = this.getAliveTeams();
|
||||||
if (alive.length === 0) return;
|
if (alive.length === 0) return;
|
||||||
logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`);
|
logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`);
|
||||||
for (const teamName of alive) {
|
for (const teamName of alive) {
|
||||||
this.stopTeam(teamName, 'SIGKILL');
|
this.stopTeam(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4724,7 +4733,7 @@ export class TeamProvisioningService {
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
// Kill the process on provisioning error
|
// Kill the process on provisioning error
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Post-provisioning error: process alive, waiting for input.
|
// Post-provisioning error: process alive, waiting for input.
|
||||||
|
|
@ -5389,7 +5398,7 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
killProcessTree(run.child);
|
killTeamProcess(run.child);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
|
|
@ -241,6 +244,13 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
[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
|
// Fetch alive teams on mount and when teams list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronMode) return;
|
if (!electronMode) return;
|
||||||
|
|
@ -536,6 +546,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
provisioningErrorsByTeam={provisioningErrorByTeam}
|
provisioningErrorsByTeam={provisioningErrorByTeam}
|
||||||
clearProvisioningError={clearProvisioningError}
|
clearProvisioningError={clearProvisioningError}
|
||||||
existingTeamNames={teams.map((t) => t.teamName)}
|
existingTeamNames={teams.map((t) => t.teamName)}
|
||||||
|
provisioningTeamNames={provisioningTeamNames}
|
||||||
activeTeams={activeTeams}
|
activeTeams={activeTeams}
|
||||||
initialData={copyData ?? undefined}
|
initialData={copyData ?? undefined}
|
||||||
defaultProjectPath={currentProjectPath}
|
defaultProjectPath={currentProjectPath}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ interface CreateTeamDialogProps {
|
||||||
provisioningErrorsByTeam: Record<string, string | null>;
|
provisioningErrorsByTeam: Record<string, string | null>;
|
||||||
clearProvisioningError?: (teamName?: string) => void;
|
clearProvisioningError?: (teamName?: string) => void;
|
||||||
existingTeamNames: string[];
|
existingTeamNames: string[];
|
||||||
|
/** Team names currently in active provisioning (launching) — used to prevent name conflicts. */
|
||||||
|
provisioningTeamNames?: string[];
|
||||||
activeTeams?: ActiveTeamRef[];
|
activeTeams?: ActiveTeamRef[];
|
||||||
initialData?: TeamCopyData;
|
initialData?: TeamCopyData;
|
||||||
defaultProjectPath?: string | null;
|
defaultProjectPath?: string | null;
|
||||||
|
|
@ -207,6 +209,7 @@ export const CreateTeamDialog = ({
|
||||||
provisioningErrorsByTeam,
|
provisioningErrorsByTeam,
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
existingTeamNames,
|
existingTeamNames,
|
||||||
|
provisioningTeamNames = [],
|
||||||
activeTeams,
|
activeTeams,
|
||||||
initialData,
|
initialData,
|
||||||
defaultProjectPath,
|
defaultProjectPath,
|
||||||
|
|
@ -339,7 +342,12 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||||
const dialogTeamNameKey = sanitizeTeamName(teamName.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
|
// Clear stale provisioning error when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -552,6 +560,9 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||||
|
const isNameProvisioning =
|
||||||
|
provisioningTeamNames.includes(sanitizedTeamName) &&
|
||||||
|
!existingTeamNames.includes(sanitizedTeamName);
|
||||||
|
|
||||||
const request = useMemo<TeamCreateRequest>(
|
const request = useMemo<TeamCreateRequest>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -640,9 +651,10 @@ export const CreateTeamDialog = ({
|
||||||
}, [conflictingTeam?.teamName, effectiveCwd]);
|
}, [conflictingTeam?.teamName, effectiveCwd]);
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
if (existingTeamNames.includes(sanitizedTeamName)) {
|
if (allTakenTeamNames.includes(sanitizedTeamName)) {
|
||||||
setFieldErrors({ teamName: 'Team name already exists' });
|
const msg = isNameProvisioning ? 'Team is currently launching' : 'Team name already exists';
|
||||||
setLocalError('Team name already exists');
|
setFieldErrors({ teamName: msg });
|
||||||
|
setLocalError(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const validation = validateRequest(request, { requireCwd: launchTeam });
|
const validation = validateRequest(request, { requireCwd: launchTeam });
|
||||||
|
|
@ -818,16 +830,16 @@ export const CreateTeamDialog = ({
|
||||||
id="team-name"
|
id="team-name"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-8 text-xs',
|
'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)]'
|
'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]'
|
||||||
)}
|
)}
|
||||||
value={teamName}
|
value={teamName}
|
||||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||||
placeholder={suggestedTeamName}
|
placeholder={suggestedTeamName}
|
||||||
/>
|
/>
|
||||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
{allTakenTeamNames.includes(sanitizedTeamName) ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
Team name already exists
|
{isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'}
|
||||||
</p>
|
</p>
|
||||||
) : validateTeamNameInline(teamName) ? (
|
) : validateTeamNameInline(teamName) ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue