refactor: update IPC handlers and types for lead activity and context usage

- Modified IPC handlers to return structured snapshots for lead activity, lead context usage, and member spawn statuses, enhancing data consistency.
- Introduced new types for LeadActivitySnapshot, LeadContextUsageSnapshot, and MemberSpawnStatusesSnapshot to improve type safety and clarity.
- Refactored TeamProvisioningService to manage provisioning runs more effectively, including updates to state management for active runs.
- Enhanced UI components to utilize the new data structures, improving the overall user experience in team management features.
This commit is contained in:
iliya 2026-03-12 14:14:58 +02:00
parent d53999ba45
commit 3723eba5b4
20 changed files with 1418 additions and 250 deletions

View file

@ -109,7 +109,10 @@ import type {
IpcResult,
KanbanColumnId,
LeadContextUsage,
LeadActivitySnapshot,
LeadContextUsageSnapshot,
MemberFullStats,
MemberSpawnStatusesSnapshot,
MemberLogSummary,
MemberSpawnStatusEntry,
SendMessageRequest,
@ -1829,7 +1832,7 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
async function handleLeadActivity(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<string>> {
): Promise<IpcResult<LeadActivitySnapshot>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
@ -1842,7 +1845,7 @@ async function handleLeadActivity(
async function handleLeadContext(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<LeadContextUsage | null>> {
): Promise<IpcResult<LeadContextUsageSnapshot>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
@ -1855,7 +1858,7 @@ async function handleLeadContext(
async function handleMemberSpawnStatuses(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<Record<string, MemberSpawnStatusEntry>>> {
): Promise<IpcResult<MemberSpawnStatusesSnapshot>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };

View file

@ -150,6 +150,20 @@ function logsSuggestShutdownOrCleanup(logs: string): boolean {
);
}
function looksLikeClaudeStdoutJsonFragment(text: string): boolean {
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return false;
}
return (
/"type"\s*:/.test(trimmed) ||
/"message"\s*:/.test(trimmed) ||
/"content"\s*:/.test(trimmed) ||
/"subtype"\s*:/.test(trimmed) ||
/"session_id"\s*:/.test(trimmed)
);
}
interface ProvisioningRun {
runId: string;
teamName: string;
@ -165,6 +179,12 @@ interface ProvisioningRun {
stdoutLogLineBuf: string;
/** Carry buffer for stderr line splitting (CLI output). */
stderrLogLineBuf: string;
/** Raw stdout parser carry that has not been newline-delimited yet. */
stdoutParserCarry: string;
/** Whether the current stdout parser carry is a complete JSON fragment. */
stdoutParserCarryIsCompleteJson: boolean;
/** Whether the current stdout parser carry looks like Claude stream-json structure. */
stdoutParserCarryLooksLikeClaudeJson: boolean;
/** ISO timestamp when the last CLI line was recorded. */
claudeLogsUpdatedAt?: string;
processKilled: boolean;
@ -1076,18 +1096,27 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
}
interface CachedProbeResult {
cacheKey: string;
claudePath: string;
authSource: ProvisioningAuthSource;
warning?: string;
cachedAtMs: number;
}
let cachedProbeResult: CachedProbeResult | null = null;
let probeInFlight: Promise<{
type ProbeResult = {
claudePath: string;
authSource: ProvisioningAuthSource;
warning?: string;
} | null> | null = null;
};
type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-complete';
const cachedProbeResults = new Map<string, CachedProbeResult>();
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
function createProbeCacheKey(cwd: string): string {
return `${path.resolve(cwd)}::${getClaudeBasePath()}`;
}
function isTransientProbeWarning(warning: string): boolean {
const lower = warning.toLowerCase();
@ -1106,7 +1135,8 @@ export class TeamProvisioningService {
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
private readonly runs = new Map<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>();
private readonly provisioningRunByTeam = new Map<string, string>();
private readonly aliveRunByTeam = new Map<string, string>();
private readonly teamOpLocks = new Map<string, Promise<void>>();
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
@ -1166,7 +1196,7 @@ export class TeamProvisioningService {
teamName: string,
query?: { offset?: number; limit?: number }
): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } {
const runId = this.activeByTeam.get(teamName);
const runId = this.getTrackedRunId(teamName);
if (!runId) {
return { lines: [], total: 0, hasMore: false };
}
@ -1210,6 +1240,18 @@ export class TeamProvisioningService {
};
}
private getProvisioningRunId(teamName: string): string | null {
return this.provisioningRunByTeam.get(teamName) ?? null;
}
private getAliveRunId(teamName: string): string | null {
return this.aliveRunByTeam.get(teamName) ?? null;
}
private getTrackedRunId(teamName: string): string | null {
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
}
private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void {
const nowMs = Date.now();
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
@ -1722,31 +1764,78 @@ export class TeamProvisioningService {
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
}
getLeadActivityState(teamName: string): 'active' | 'idle' | 'offline' {
const runId = this.activeByTeam.get(teamName);
if (!runId) return 'offline';
getLeadActivityState(teamName: string): {
state: 'active' | 'idle' | 'offline';
runId: string | null;
} {
const runId = this.getTrackedRunId(teamName);
if (!runId) return { state: 'offline', runId: null };
const run = this.runs.get(runId);
if (!run || run.processKilled || run.cancelRequested) return 'offline';
return run.leadActivityState;
if (!run || run.processKilled || run.cancelRequested) return { state: 'offline', runId: null };
return { state: run.leadActivityState, runId };
}
getLeadContextUsage(teamName: string): LeadContextUsage | null {
const runId = this.activeByTeam.get(teamName);
if (!runId) return null;
getLeadContextUsage(teamName: string): { usage: LeadContextUsage | null; runId: string | null } {
const runId = this.getTrackedRunId(teamName);
if (!runId) return { usage: null, runId: null };
const run = this.runs.get(runId);
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null;
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) {
return { usage: null, runId: null };
}
const { currentTokens, contextWindow } = run.leadContextUsage;
const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
const percent = Math.max(0, Math.min(100, percentRaw));
return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() };
return {
usage: { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() },
runId,
};
}
private isCurrentTrackedRun(run: ProvisioningRun): boolean {
return this.getTrackedRunId(run.teamName) === run.runId;
}
private getRunTrackedCwd(run: ProvisioningRun | null | undefined): string | null {
const requestCwd = typeof run?.request?.cwd === 'string' ? run.request.cwd.trim() : '';
if (requestCwd) return path.resolve(requestCwd);
const spawnCwd = typeof run?.spawnContext?.cwd === 'string' ? run.spawnContext.cwd.trim() : '';
if (spawnCwd) return path.resolve(spawnCwd);
return null;
}
private getPreCompleteCliErrorText(run: ProvisioningRun): string {
const parts: string[] = [];
const stderrText = run.stderrBuffer.trim();
if (stderrText) {
parts.push(stderrText);
}
// Re-check only the parser-owned stdout carry that never became a newline-delimited message.
// If it is complete JSON or clearly looks like Claude stream-json structure, ignore it here.
// Otherwise treat it as trailing plaintext CLI output that should still participate in the
// final auth/API failure guard.
const trailingStdout = run.stdoutParserCarry.trim();
if (
trailingStdout &&
!run.stdoutParserCarryIsCompleteJson &&
!run.stdoutParserCarryLooksLikeClaudeJson
) {
parts.push(trailingStdout);
}
return parts.join('\n').trim();
}
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
if (run.leadActivityState === state) return;
run.leadActivityState = state;
if (!this.isCurrentTrackedRun(run)) return;
this.teamChangeEmitter?.({
type: 'lead-activity',
teamName: run.teamName,
runId: run.runId,
detail: state,
});
}
@ -1767,9 +1856,11 @@ export class TeamProvisioningService {
error,
updatedAt: nowIso(),
});
if (!this.isCurrentTrackedRun(run)) return;
this.teamChangeEmitter?.({
type: 'member-spawn',
teamName: run.teamName,
runId: run.runId,
detail: memberName,
});
}
@ -1778,16 +1869,19 @@ export class TeamProvisioningService {
* Get current member spawn statuses for a team.
* Returns a map of memberName MemberSpawnStatusEntry.
*/
getMemberSpawnStatuses(teamName: string): Record<string, MemberSpawnStatusEntry> {
const runId = this.activeByTeam.get(teamName);
if (!runId) return {};
getMemberSpawnStatuses(teamName: string): {
statuses: Record<string, MemberSpawnStatusEntry>;
runId: string | null;
} {
const runId = this.getTrackedRunId(teamName);
if (!runId) return { statuses: {}, runId: null };
const run = this.runs.get(runId);
if (!run) return {};
if (!run) return { statuses: {}, runId: null };
const result: Record<string, MemberSpawnStatusEntry> = {};
for (const [name, entry] of run.memberSpawnStatuses) {
result[name] = { status: entry.status, error: entry.error, updatedAt: entry.updatedAt };
}
return result;
return { statuses: result, runId };
}
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
@ -1795,6 +1889,7 @@ export class TeamProvisioningService {
private emitLeadContextUsage(run: ProvisioningRun): void {
if (!run.leadContextUsage || !run.provisioningComplete) return;
if (!this.isCurrentTrackedRun(run)) return;
const now = Date.now();
if (
now - run.leadContextUsage.lastEmittedAt <
@ -1815,15 +1910,16 @@ export class TeamProvisioningService {
this.teamChangeEmitter?.({
type: 'lead-context',
teamName: run.teamName,
runId: run.runId,
detail: JSON.stringify(payload),
});
}
async warmup(): Promise<void> {
try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS)
return;
const result = await this.getCachedOrProbeResult(process.cwd());
const cwd = process.cwd();
if (this.getFreshCachedProbeResult(cwd)) return;
const result = await this.getCachedOrProbeResult(cwd);
if (!result) return;
logger.info('CLI warmup completed');
} catch (error) {
@ -1835,23 +1931,20 @@ export class TeamProvisioningService {
cwd?: string,
opts?: { forceFresh?: boolean }
): Promise<TeamProvisioningPrepareResult> {
// Always validate cwd even when cache is available
const targetCwdForValidation = cwd?.trim() || process.cwd();
if (targetCwdForValidation && path.isAbsolute(targetCwdForValidation)) {
await ensureCwdExists(targetCwdForValidation);
}
await this.validatePrepareCwd(targetCwdForValidation);
// Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache
if (opts?.forceFresh) {
cachedProbeResult = null;
this.clearProbeCache(targetCwdForValidation);
}
const cached = this.getFreshCachedProbeResult();
const cached = this.getFreshCachedProbeResult(targetCwdForValidation);
if (cached) {
const { warning, authSource } = cached;
const warnings: string[] = [];
if (warning) warnings.push(warning);
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
const isAuthFailure = warning ? this.isAuthFailureWarning(warning, 'probe') : false;
const ready = !warning || authSource !== 'none' || !isAuthFailure;
return {
ready,
@ -1868,7 +1961,6 @@ export class TeamProvisioningService {
if (!path.isAbsolute(targetCwd)) {
throw new Error('cwd must be an absolute path');
}
await ensureCwdExists(targetCwd);
const warnings: string[] = [];
@ -1885,7 +1977,7 @@ export class TeamProvisioningService {
}
if (probeResult.warning) {
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning);
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
if (authSource === 'none' && isAuthFailure) {
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
return {
@ -1908,20 +2000,43 @@ export class TeamProvisioningService {
};
}
private getFreshCachedProbeResult(): CachedProbeResult | null {
if (!cachedProbeResult) return null;
const ageMs = Date.now() - cachedProbeResult.cachedAtMs;
private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null {
const cacheKey = createProbeCacheKey(cwd);
const cached = cachedProbeResults.get(cacheKey);
if (!cached) return null;
const ageMs = Date.now() - cached.cachedAtMs;
if (ageMs >= PROBE_CACHE_TTL_MS) {
cachedProbeResult = null;
cachedProbeResults.delete(cacheKey);
return null;
}
return cachedProbeResult;
return cached;
}
private async getCachedOrProbeResult(
cwd: string
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
const cached = this.getFreshCachedProbeResult();
private clearProbeCache(cwd: string): void {
cachedProbeResults.delete(createProbeCacheKey(cwd));
}
private async validatePrepareCwd(cwd: string): Promise<void> {
if (!path.isAbsolute(cwd)) {
throw new Error('cwd must be an absolute path');
}
try {
const stat = await fs.promises.stat(cwd);
if (!stat.isDirectory()) {
throw new Error('cwd must be a directory');
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
}
private async getCachedOrProbeResult(cwd: string): Promise<ProbeResult | null> {
const cacheKey = createProbeCacheKey(cwd);
const cached = this.getFreshCachedProbeResult(cwd);
if (cached) {
return {
claudePath: cached.claudePath,
@ -1930,11 +2045,12 @@ export class TeamProvisioningService {
};
}
if (probeInFlight) {
return await probeInFlight;
const existingProbe = probeInFlightByKey.get(cacheKey);
if (existingProbe) {
return await existingProbe;
}
probeInFlight = (async () => {
const probePromise = (async () => {
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return null;
@ -1948,36 +2064,57 @@ export class TeamProvisioningService {
const shouldCache =
!probe.warning ||
(!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning));
(!this.isAuthFailureWarning(probe.warning, 'probe') &&
!isTransientProbeWarning(probe.warning));
if (shouldCache) {
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
} else {
// Don't pin auth failures / transient failures in cache — user may fix and retry.
cachedProbeResult = null;
cachedProbeResults.delete(cacheKey);
}
return result;
})();
probeInFlightByKey.set(cacheKey, probePromise);
try {
return await probeInFlight;
return await probePromise;
} finally {
probeInFlight = null;
probeInFlightByKey.delete(cacheKey);
}
}
private isAuthFailureWarning(text: string): boolean {
private isAuthFailureWarning(text: string, source: AuthWarningSource): boolean {
const lower = text.toLowerCase();
const has401 = /(^|\D)401(\D|$)/.test(lower);
return (
const hasExplicitCliAuthSignal =
lower.includes('not authenticated') ||
lower.includes('not logged in') ||
lower.includes('please run /login') ||
lower.includes('missing api key') ||
lower.includes('invalid api key') ||
lower.includes('unauthorized') ||
has401
lower.includes('authentication failed') ||
lower.includes('run `claude auth login`') ||
lower.includes('claude auth login');
if (hasExplicitCliAuthSignal) {
return true;
}
if (source === 'assistant' || source === 'stdout') {
return false;
}
const hasAuthStatus401 =
/api error:\s*401\b/i.test(text) ||
/\b401 unauthorized\b/i.test(lower) ||
(/(^|\D)401(\D|$)/.test(lower) &&
(lower.includes('auth') || lower.includes('api') || lower.includes('login')));
return (
hasAuthStatus401 ||
(lower.includes('unauthorized') &&
(lower.includes('api') || lower.includes('auth') || lower.includes('login')))
);
}
@ -2048,9 +2185,13 @@ export class TeamProvisioningService {
* On first detection: kills process, waits, and respawns automatically.
* On second detection (after retry): fails fast with a clear error.
*/
private handleAuthFailureInOutput(run: ProvisioningRun, text: string, source: string): void {
private handleAuthFailureInOutput(
run: ProvisioningRun,
text: string,
source: AuthWarningSource
): void {
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
if (!this.isAuthFailureWarning(text)) return;
if (!this.isAuthFailureWarning(text, source)) return;
if (!run.authFailureRetried) {
logger.warn(
@ -2231,6 +2372,20 @@ export class TeamProvisioningService {
stdoutLineBuf += text;
const lines = stdoutLineBuf.split('\n');
stdoutLineBuf = lines.pop() ?? '';
run.stdoutParserCarry = stdoutLineBuf;
const trimmedCarry = stdoutLineBuf.trim();
if (!trimmedCarry) {
run.stdoutParserCarryIsCompleteJson = false;
run.stdoutParserCarryLooksLikeClaudeJson = false;
} else {
try {
JSON.parse(trimmedCarry);
run.stdoutParserCarryIsCompleteJson = true;
} catch {
run.stdoutParserCarryIsCompleteJson = false;
}
run.stdoutParserCarryLooksLikeClaudeJson = looksLikeClaudeStdoutJsonFragment(trimmedCarry);
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
@ -2240,7 +2395,7 @@ export class TeamProvisioningService {
} catch {
// Not valid JSON — check for auth failure in raw text output
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) {
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) {
this.failProvisioningWithApiError(run, trimmed);
}
}
@ -2269,7 +2424,7 @@ export class TeamProvisioningService {
// Detect auth failure early instead of waiting for 5-minute timeout
this.handleAuthFailureInOutput(run, text, 'stderr');
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
if (this.hasApiError(text) && !this.isAuthFailureWarning(text, 'stderr')) {
this.failProvisioningWithApiError(run, text);
}
@ -2294,13 +2449,14 @@ export class TeamProvisioningService {
request: TeamCreateRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamCreateResponse> {
if (this.activeByTeam.has(request.teamName)) {
throw new Error('Provisioning already running');
const existingProvisioningRunId = this.getProvisioningRunId(request.teamName);
if (existingProvisioningRunId) {
return { runId: existingProvisioningRunId };
}
// Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock)
const pendingKey = `pending-${randomUUID()}`;
this.activeByTeam.set(request.teamName, pendingKey);
this.provisioningRunByTeam.set(request.teamName, pendingKey);
try {
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
@ -2331,6 +2487,9 @@ export class TeamProvisioningService {
lastClaudeLogStream: null,
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
stdoutParserCarry: '',
stdoutParserCarryIsCompleteJson: false,
stdoutParserCarryLooksLikeClaudeJson: false,
claudeLogsUpdatedAt: undefined,
processKilled: false,
finalizingByTimeout: false,
@ -2379,7 +2538,7 @@ export class TeamProvisioningService {
};
this.runs.set(runId, run);
this.activeByTeam.set(request.teamName, runId);
this.provisioningRunByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
const prompt = buildProvisioningPrompt(request);
@ -2390,7 +2549,7 @@ export class TeamProvisioningService {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
this.provisioningRunByTeam.delete(request.teamName);
throw error;
}
const spawnArgs = [
@ -2423,7 +2582,7 @@ export class TeamProvisioningService {
});
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
this.provisioningRunByTeam.delete(request.teamName);
throw error;
}
@ -2500,8 +2659,8 @@ export class TeamProvisioningService {
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);
if (this.provisioningRunByTeam.get(request.teamName) === pendingKey) {
this.provisioningRunByTeam.delete(request.teamName);
}
throw error;
}
@ -2520,13 +2679,14 @@ export class TeamProvisioningService {
request: TeamLaunchRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamLaunchResponse> {
if (this.activeByTeam.has(request.teamName)) {
throw new Error('Team is already running');
const existingProvisioningRunId = this.getProvisioningRunId(request.teamName);
if (existingProvisioningRunId) {
return { runId: existingProvisioningRunId };
}
// Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock)
const pendingKey = `pending-${randomUUID()}`;
this.activeByTeam.set(request.teamName, pendingKey);
this.provisioningRunByTeam.set(request.teamName, pendingKey);
try {
// Verify config.json exists — team must already be provisioned
@ -2538,6 +2698,41 @@ export class TeamProvisioningService {
if (!configRaw) {
throw new Error(`Team "${request.teamName}" not found — config.json does not exist`);
}
let configProjectPath: string | null = null;
try {
const parsedConfig = JSON.parse(configRaw) as { projectPath?: unknown };
configProjectPath =
typeof parsedConfig.projectPath === 'string' && parsedConfig.projectPath.trim().length > 0
? path.resolve(parsedConfig.projectPath.trim())
: null;
} catch {
configProjectPath = null;
}
const existingAliveRunId = this.getAliveRunId(request.teamName);
if (existingAliveRunId) {
const existingRun = this.runs.get(existingAliveRunId);
const requestedCwd = path.resolve(request.cwd);
const existingRunCwd = this.getRunTrackedCwd(existingRun) ?? configProjectPath;
if (existingRun?.child && !existingRun.processKilled && !existingRun.cancelRequested) {
if (!existingRunCwd) {
this.provisioningRunByTeam.delete(request.teamName);
throw new Error(
`Team "${request.teamName}" is already running, but its cwd could not be determined. ` +
'Stop it before launching again.'
);
}
if (existingRunCwd && existingRunCwd !== requestedCwd) {
this.provisioningRunByTeam.delete(request.teamName);
throw new Error(
`Team "${request.teamName}" is already running in "${existingRunCwd}". ` +
`Stop it before launching with cwd "${request.cwd}".`
);
}
this.provisioningRunByTeam.delete(request.teamName);
return { runId: existingAliveRunId };
}
}
const {
members: expectedMemberSpecs,
@ -2656,6 +2851,9 @@ export class TeamProvisioningService {
lastClaudeLogStream: null,
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
stdoutParserCarry: '',
stdoutParserCarryIsCompleteJson: false,
stdoutParserCarryLooksLikeClaudeJson: false,
claudeLogsUpdatedAt: undefined,
processKilled: false,
finalizingByTimeout: false,
@ -2710,7 +2908,7 @@ export class TeamProvisioningService {
};
this.runs.set(runId, run);
this.activeByTeam.set(request.teamName, runId);
this.provisioningRunByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
// Read existing tasks to include in teammate prompts for work resumption
@ -2737,7 +2935,7 @@ export class TeamProvisioningService {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
this.provisioningRunByTeam.delete(request.teamName);
await this.restorePrelaunchConfig(request.teamName);
throw error;
}
@ -2788,7 +2986,7 @@ export class TeamProvisioningService {
});
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
this.provisioningRunByTeam.delete(request.teamName);
await this.restorePrelaunchConfig(request.teamName);
throw error;
}
@ -2866,8 +3064,8 @@ export class TeamProvisioningService {
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);
if (this.provisioningRunByTeam.get(request.teamName) === pendingKey) {
this.provisioningRunByTeam.delete(request.teamName);
}
throw error;
}
@ -2908,7 +3106,7 @@ export class TeamProvisioningService {
message: string,
attachments?: { data: string; mimeType: string }[]
): Promise<void> {
const runId = this.activeByTeam.get(teamName);
const runId = this.getAliveRunId(teamName);
if (!runId) {
throw new Error(`No active process for team "${teamName}"`);
}
@ -2962,7 +3160,7 @@ export class TeamProvisioningService {
userText: string,
userSummary?: string
): Promise<void> {
const runId = this.activeByTeam.get(teamName);
const runId = this.getAliveRunId(teamName);
if (!runId) {
throw new Error(`No active process for team "${teamName}"`);
}
@ -3012,7 +3210,7 @@ export class TeamProvisioningService {
}
const work = (async (): Promise<number> => {
const runId = this.activeByTeam.get(teamName);
const runId = this.getAliveRunId(teamName);
if (!runId) return 0;
const run = this.runs.get(runId);
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
@ -3153,7 +3351,7 @@ export class TeamProvisioningService {
}
const work = (async (): Promise<number> => {
const runId = this.activeByTeam.get(teamName);
const runId = this.getAliveRunId(teamName);
if (!runId) return 0;
const run = this.runs.get(runId);
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
@ -3469,14 +3667,14 @@ export class TeamProvisioningService {
* Check if a team has an active provisioning run (started but not yet finished).
*/
hasProvisioningRun(teamName: string): boolean {
return this.activeByTeam.has(teamName);
return this.provisioningRunByTeam.has(teamName);
}
/**
* Check if a team has a live process.
*/
isTeamAlive(teamName: string): boolean {
const runId = this.activeByTeam.get(teamName);
const runId = this.getAliveRunId(teamName);
if (!runId) return false;
const run = this.runs.get(runId);
return run?.child != null && !run.processKilled && !run.cancelRequested;
@ -3486,7 +3684,7 @@ export class TeamProvisioningService {
* Get list of teams with active processes.
*/
getAliveTeams(): string[] {
return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name));
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
}
private languageChangeInFlight: Promise<void> = Promise.resolve();
@ -3635,7 +3833,7 @@ export class TeamProvisioningService {
* Called from the inbox change handler.
*/
markMemberOnlineFromInbox(teamName: string, memberName: string): void {
const runId = this.activeByTeam.get(teamName);
const runId = this.getTrackedRunId(teamName);
if (!runId) return;
const run = this.runs.get(runId);
if (!run) return;
@ -3765,6 +3963,7 @@ export class TeamProvisioningService {
this.teamChangeEmitter?.({
type: 'lead-message',
teamName: run.teamName,
runId: run.runId,
detail: 'cross-team-send',
});
})
@ -3829,7 +4028,7 @@ export class TeamProvisioningService {
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
// Enrich with leadSessionId if missing — needed for session boundary separators
if (!message.leadSessionId) {
const runId = this.activeByTeam.get(teamName);
const runId = this.getTrackedRunId(teamName);
if (runId) {
const run = this.runs.get(runId);
if (run?.detectedSessionId) {
@ -3860,7 +4059,7 @@ export class TeamProvisioningService {
teamName: string,
toTeam: string
): { conversationId: string; replyToConversationId: string } | null {
const runId = this.activeByTeam.get(teamName);
const runId = this.getAliveRunId(teamName);
if (!runId) return null;
const run = this.runs.get(runId);
const hints = run?.activeCrossTeamReplyHints ?? [];
@ -3927,6 +4126,7 @@ export class TeamProvisioningService {
this.teamChangeEmitter?.({
type: 'lead-message',
teamName: run.teamName,
runId: run.runId,
detail: 'lead-text',
});
}
@ -3936,13 +4136,14 @@ export class TeamProvisioningService {
* Stop the running process for a team. No-op if team is not running.
*/
stopTeam(teamName: string): void {
const runId = this.activeByTeam.get(teamName);
const runId = this.getTrackedRunId(teamName);
if (!runId) {
return;
}
const run = this.runs.get(runId);
if (!run) {
this.activeByTeam.delete(teamName);
this.provisioningRunByTeam.delete(teamName);
this.aliveRunByTeam.delete(teamName);
return;
}
if (run.processKilled || run.cancelRequested) {
@ -3997,7 +4198,7 @@ export class TeamProvisioningService {
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant');
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
if (this.hasApiError(text) && !this.isAuthFailureWarning(text, 'assistant')) {
this.failProvisioningWithApiError(run, text);
return;
}
@ -4731,7 +4932,7 @@ export class TeamProvisioningService {
allow: boolean,
message?: string
): Promise<void> {
const currentRunId = this.activeByTeam.get(teamName);
const currentRunId = this.getAliveRunId(teamName);
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
const run = this.runs.get(currentRunId);
if (!run) throw new Error(`Run not found for team "${teamName}"`);
@ -4813,24 +5014,19 @@ export class TeamProvisioningService {
)
return;
// Prevent false "ready" when auth failure was printed as assistant text or logs
// but the filesystem monitor observed files on disk.
const preCompleteText = [
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
]
.filter(Boolean)
.join('\n')
.trim();
// Prevent false "ready" when auth failure was printed in CLI output but the filesystem monitor
// already observed files on disk. We only re-check stderr plus a trailing non-JSON stdout
// fragment here to avoid late false positives from assistant/result stream-json payloads.
const preCompleteText = this.getPreCompleteCliErrorText(run);
if (
preCompleteText &&
this.hasApiError(preCompleteText) &&
!this.isAuthFailureWarning(preCompleteText)
!this.isAuthFailureWarning(preCompleteText, 'pre-complete')
) {
this.failProvisioningWithApiError(run, preCompleteText);
return;
}
if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) {
if (preCompleteText && this.isAuthFailureWarning(preCompleteText, 'pre-complete')) {
this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete');
return;
}
@ -4854,10 +5050,6 @@ export class TeamProvisioningService {
);
await this.cleanupPrelaunchBackup(run.teamName);
// Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2),
// clean them up so they don't persist and reappear in the UI.
await this.cleanupCliAutoSuffixedMembers(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 {
@ -4890,6 +5082,8 @@ export class TeamProvisioningService {
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
this.provisioningRunByTeam.delete(run.teamName);
this.aliveRunByTeam.set(run.teamName, run.runId);
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
// Pick up any direct messages that arrived before/while reconnecting.
@ -4979,7 +5173,8 @@ export class TeamProvisioningService {
cliLogsTail: extractCliLogsFromRun(run),
});
run.onProgress(progress);
// NOTE: do NOT remove from activeByTeam — process stays alive
this.provisioningRunByTeam.delete(run.teamName);
this.aliveRunByTeam.set(run.teamName, run.runId);
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
// Pick up any direct messages that arrived during provisioning.
@ -5009,7 +5204,12 @@ export class TeamProvisioningService {
run.child.stdout?.removeAllListeners('data');
run.child.stderr?.removeAllListeners('data');
}
this.activeByTeam.delete(run.teamName);
if (this.provisioningRunByTeam.get(run.teamName) === run.runId) {
this.provisioningRunByTeam.delete(run.teamName);
}
if (this.aliveRunByTeam.get(run.teamName) === run.runId) {
this.aliveRunByTeam.delete(run.teamName);
}
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
this.pendingCrossTeamFirstReplies.delete(run.teamName);

View file

@ -231,10 +231,11 @@ import type {
HunkDecision,
IpcResult,
KanbanColumnId,
LeadContextUsage,
LeadActivitySnapshot,
LeadContextUsageSnapshot,
MemberSpawnStatusesSnapshot,
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusEntry,
NotificationTrigger,
RejectResult,
ReplaceMembersRequest,
@ -921,17 +922,13 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<void>(TEAM_KILL_PROCESS, teamName, pid);
},
getLeadActivity: async (teamName: string) => {
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
return result as 'active' | 'idle' | 'offline';
return invokeIpcWithResult<LeadActivitySnapshot>(TEAM_LEAD_ACTIVITY, teamName);
},
getLeadContext: async (teamName: string) => {
return invokeIpcWithResult<LeadContextUsage | null>(TEAM_LEAD_CONTEXT, teamName);
return invokeIpcWithResult<LeadContextUsageSnapshot>(TEAM_LEAD_CONTEXT, teamName);
},
getMemberSpawnStatuses: async (teamName: string) => {
return invokeIpcWithResult<Record<string, MemberSpawnStatusEntry>>(
TEAM_MEMBER_SPAWN_STATUSES,
teamName
);
return invokeIpcWithResult<MemberSpawnStatusesSnapshot>(TEAM_MEMBER_SPAWN_STATUSES, teamName);
},
softDeleteTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);

View file

@ -820,14 +820,14 @@ export class HttpAPIClient implements ElectronAPI {
killProcess: async (_teamName: string, _pid: number): Promise<void> => {
// Not available via HTTP client — no-op
},
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
return 'offline';
getLeadActivity: async (_teamName: string) => {
return { state: 'offline' as const, runId: null };
},
getLeadContext: async () => {
return null;
return { usage: null, runId: null };
},
getMemberSpawnStatuses: async () => {
return {};
return { statuses: {}, runId: null };
},
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op

View file

@ -20,6 +20,7 @@ import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
@ -95,8 +96,6 @@ interface TeamDetailViewProps {
teamName: string;
}
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
interface CreateTaskDialogState {
open: boolean;
defaultSubject: string;
@ -275,11 +274,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
removeMember: s.removeMember,
updateMemberRole: s.updateMemberRole,
launchTeam: s.launchTeam,
provisioningError: s.provisioningError,
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
clearProvisioningError: s.clearProvisioningError,
isTeamProvisioning: Object.values(s.provisioningRuns).some(
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
),
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
leadActivityByTeam: s.leadActivityByTeam,
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,

View file

@ -15,6 +15,7 @@ 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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
@ -39,12 +40,7 @@ import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopove
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type {
TeamCreateRequest,
TeamProvisioningProgress,
TeamSummary,
TeamSummaryMember,
} from '@shared/types';
import type { TeamCreateRequest, TeamSummary, TeamSummaryMember } from '@shared/types';
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
@ -129,17 +125,17 @@ function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX
function resolveTeamStatus(
teamName: string,
aliveTeams: string[],
provisioningRuns: Record<string, TeamProvisioningProgress>,
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
leadActivityByTeam: Record<string, string>
): TeamStatus {
if (aliveTeams.includes(teamName)) {
return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle';
}
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
for (const run of Object.values(provisioningRuns)) {
if (run.teamName === teamName && activeStates.has(run.state)) {
return 'provisioning';
}
if (
currentProgress &&
['validating', 'spawning', 'monitoring', 'verifying'].includes(currentProgress.state)
) {
return 'provisioning';
}
return 'offline';
}
@ -223,21 +219,27 @@ export const TeamListView = (): React.JSX.Element => {
const {
connectionMode,
createTeam,
provisioningError,
provisioningErrorByTeam,
clearProvisioningError,
provisioningRuns,
currentProvisioningRunIdByTeam,
leadActivityByTeam,
} = useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
provisioningError: s.provisioningError,
provisioningErrorByTeam: s.provisioningErrorByTeam,
clearProvisioningError: s.clearProvisioningError,
provisioningRuns: s.provisioningRuns,
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
leadActivityByTeam: s.leadActivityByTeam,
}))
);
const canCreate = electronMode && connectionMode === 'local';
const provisioningState = useMemo(
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
[currentProvisioningRunIdByTeam, provisioningRuns]
);
// Fetch alive teams on mount and when teams list changes
useEffect(() => {
@ -308,7 +310,7 @@ export const TeamListView = (): React.JSX.Element => {
const status = resolveTeamStatus(
t.teamName,
aliveTeams,
provisioningRuns,
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
leadActivityByTeam
);
const isRunning = status !== 'offline';
@ -357,6 +359,7 @@ export const TeamListView = (): React.JSX.Element => {
currentProjectPath,
aliveTeams,
filter,
currentProvisioningRunIdByTeam,
provisioningRuns,
leadActivityByTeam,
]);
@ -530,7 +533,7 @@ export const TeamListView = (): React.JSX.Element => {
<CreateTeamDialog
open={showCreateDialog}
canCreate={canCreate}
provisioningError={provisioningError}
provisioningErrorsByTeam={provisioningErrorByTeam}
clearProvisioningError={clearProvisioningError}
existingTeamNames={teams.map((t) => t.teamName)}
activeTeams={activeTeams}
@ -642,7 +645,7 @@ export const TeamListView = (): React.JSX.Element => {
const status = resolveTeamStatus(
team.teamName,
aliveTeams,
provisioningRuns,
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
leadActivityByTeam
);
const teamColorSet = team.color

View file

@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { CheckCircle2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -9,35 +10,20 @@ import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
import { STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
import type { TeamProvisioningProgress } from '@shared/types';
interface TeamProvisioningBannerProps {
teamName: string;
}
function findProgressForTeam(
runs: Record<string, TeamProvisioningProgress>,
teamName: string
): TeamProvisioningProgress | null {
const entries = Object.values(runs);
const matching = entries
.filter((r) => r.teamName === teamName)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return matching[0] ?? null;
}
export const TeamProvisioningBanner = ({
teamName,
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
const { provisioningRuns, cancelProvisioning, teamMembers } = useStore(
const { progress, cancelProvisioning, teamMembers } = useStore(
useShallow((s) => ({
provisioningRuns: s.provisioningRuns,
progress: getCurrentProvisioningProgressForTeam(s, teamName),
cancelProvisioning: s.cancelProvisioning,
teamMembers: s.selectedTeamData?.members,
}))
);
const progress = findProgressForTeam(provisioningRuns, teamName);
const [dismissed, setDismissed] = useState(false);
const prevRunIdRef = useRef(progress?.runId);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import {
@ -77,8 +77,8 @@ export interface ActiveTeamRef {
interface CreateTeamDialogProps {
open: boolean;
canCreate: boolean;
provisioningError: string | null;
clearProvisioningError?: () => void;
provisioningErrorsByTeam: Record<string, string | null>;
clearProvisioningError?: (teamName?: string) => void;
existingTeamNames: string[];
activeTeams?: ActiveTeamRef[];
initialData?: TeamCopyData;
@ -195,7 +195,7 @@ function validateRequest(
export const CreateTeamDialog = ({
open,
canCreate,
provisioningError,
provisioningErrorsByTeam,
clearProvisioningError,
existingTeamNames,
activeTeams,
@ -223,6 +223,7 @@ export const CreateTeamDialog = ({
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const prepareRequestSeqRef = useRef(0);
const [fieldErrors, setFieldErrors] = useState<{
teamName?: string;
members?: string;
@ -325,12 +326,15 @@ export const CreateTeamDialog = ({
resetUIState();
};
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (open) {
clearProvisioningError?.();
if (open && dialogTeamNameKey) {
clearProvisioningError?.(dialogTeamNameKey);
}
}, [open, clearProvisioningError]);
}, [open, clearProvisioningError, dialogTeamNameKey]);
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
@ -346,7 +350,15 @@ export const CreateTeamDialog = ({
return;
}
if (!effectiveCwd) {
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareMessage('Select a working directory to validate the launch environment.');
return;
}
let cancelled = false;
const requestSeq = ++prepareRequestSeqRef.current;
setPrepareState('loading');
setPrepareMessage('Warming up CLI environment...');
setPrepareWarnings([]);
@ -355,13 +367,14 @@ export const CreateTeamDialog = ({
const timer = setTimeout(() => {
void (async () => {
try {
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
if (cancelled) return;
const prepResult: TeamProvisioningPrepareResult =
await api.teams.prepareProvisioning(effectiveCwd);
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
setPrepareState(prepResult.ready ? 'ready' : 'failed');
setPrepareMessage(prepResult.message);
setPrepareWarnings(prepResult.warnings ?? []);
} catch (error) {
if (cancelled) return;
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareMessage(
@ -375,7 +388,7 @@ export const CreateTeamDialog = ({
cancelled = true;
clearTimeout(timer);
};
}, [open, canCreate, launchTeam]);
}, [open, canCreate, launchTeam, effectiveCwd]);
useEffect(() => {
if (!open) {
@ -486,8 +499,6 @@ export const CreateTeamDialog = ({
setSelectedProjectPath(projects[0].path);
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
useFileListCacheWarmer(effectiveCwd || null);
const description = descriptionDraft.value;
@ -590,7 +601,7 @@ export const CreateTeamDialog = ({
return summary;
}, [description, teamColor]);
const activeError = localError ?? provisioningError;
const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
@ -74,7 +74,7 @@ interface LaunchDialogLaunchMode extends LaunchDialogBase {
members: ResolvedTeamMember[];
defaultProjectPath?: string;
provisioningError: string | null;
clearProvisioningError?: () => void;
clearProvisioningError?: (teamName?: string) => void;
activeTeams?: ActiveTeamRef[];
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
}
@ -178,6 +178,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const prepareRequestSeqRef = useRef(0);
// Advanced CLI section state (with localStorage persistence)
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
@ -332,14 +333,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Launch-only effects
// ---------------------------------------------------------------------------
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (!open || !isLaunch) return;
(props as LaunchDialogLaunchMode).clearProvisioningError?.();
(props as LaunchDialogLaunchMode).clearProvisioningError?.(effectiveTeamName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLaunch]);
}, [open, isLaunch, effectiveTeamName]);
// Warm up CLI on open (launch mode only)
// Warm up CLI for the currently selected working directory (launch mode only).
useEffect(() => {
if (!open || !isLaunch) return;
@ -352,20 +355,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return;
}
if (!effectiveCwd) {
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareMessage('Select a working directory to validate the launch environment.');
return;
}
let cancelled = false;
const requestSeq = ++prepareRequestSeqRef.current;
setPrepareState('loading');
setPrepareMessage('Warming up CLI environment...');
setPrepareWarnings([]);
void (async () => {
try {
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
if (cancelled) return;
const prepResult: TeamProvisioningPrepareResult =
await api.teams.prepareProvisioning(effectiveCwd);
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
setPrepareState(prepResult.ready ? 'ready' : 'failed');
setPrepareMessage(prepResult.message);
setPrepareWarnings(prepResult.warnings ?? []);
} catch (error) {
if (cancelled) return;
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareMessage(
@ -377,7 +389,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => {
cancelled = true;
};
}, [open, isLaunch]);
}, [open, isLaunch, effectiveCwd]);
// ---------------------------------------------------------------------------
// Shared effects: projects
@ -447,8 +459,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setSelectedProjectPath(projects[0].path);
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
// Pre-warm file list cache so @-mention file search is instant
useFileListCacheWarmer(effectiveCwd || null);

View file

@ -12,6 +12,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -122,13 +123,7 @@ export const MessageComposer = ({
const displayName = s.selectedTeamData?.config.name ?? teamName;
return nameColorSet(displayName).border;
});
const isProvisioning = useStore((s) =>
Object.values(s.provisioningRuns).some(
(run) =>
run.teamName === teamName &&
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
)
);
const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName));
const draft = useComposerDraft(teamName);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);

View file

@ -355,8 +355,39 @@ export function initializeNotificationListeners(): () => void {
if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
const isIgnoredRuntimeRun = (() => {
if (!event.runId) return false;
return useStore.getState().ignoredProvisioningRunIds[event.runId] === event.teamName;
})();
if (isIgnoredRuntimeRun) {
return;
}
const isStaleRuntimeEvent = (() => {
if (!event.runId) return false;
const currentRunId = useStore.getState().currentRuntimeRunIdByTeam[event.teamName];
return currentRunId != null && currentRunId !== event.runId;
})();
const seedCurrentRunIdIfMissing = (): void => {
if (!event.runId) return;
const currentRunId = useStore.getState().currentRuntimeRunIdByTeam[event.teamName];
if (currentRunId == null) {
useStore.setState((prev) => ({
currentRuntimeRunIdByTeam: {
...prev.currentRuntimeRunIdByTeam,
[event.teamName]: event.runId ?? null,
},
}));
}
};
// Immediate in-memory update for lead activity — no filesystem refresh needed
if (event.type === 'lead-activity' && event.detail) {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
const nextActivity = event.detail as 'active' | 'idle' | 'offline';
useStore.setState((prev) => {
const nextState: Partial<typeof prev> = {
@ -379,6 +410,8 @@ export function initializeNotificationListeners(): () => void {
if (nextActivity === 'offline') {
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
delete nextState.leadContextByTeam[event.teamName];
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
}
return nextState as typeof prev;
@ -388,6 +421,10 @@ export function initializeNotificationListeners(): () => void {
// Immediate in-memory update for lead context usage — no filesystem refresh needed
if (event.type === 'lead-context' && event.detail) {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
try {
const ctx = JSON.parse(event.detail) as LeadContextUsage;
useStore.setState((prev) => ({
@ -402,6 +439,10 @@ export function initializeNotificationListeners(): () => void {
// Member spawn status change: fetch updated spawn statuses for the team.
if (event.type === 'member-spawn') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
void useStore.getState().fetchMemberSpawnStatuses(event.teamName);
return;
}
@ -409,6 +450,10 @@ export function initializeNotificationListeners(): () => void {
// Live lead-message events: only refresh the visible team detail, not team/task lists.
// This keeps the refresh lightweight and prevents one noisy team from starving another.
if (event.type === 'lead-message') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}

View file

@ -25,12 +25,18 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']);
function isPendingProvisioningRunId(runId: string): boolean {
return runId.startsWith('pending:');
}
function isUnknownProvisioningRunError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes('Unknown runId');
}
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_resolve, reject) => {
@ -61,7 +67,11 @@ async function pollProvisioningStatus(
if (TERMINAL_PROVISIONING_STATES.has(progress.state)) {
return;
}
} catch {
} catch (error) {
if (isUnknownProvisioningRunError(error)) {
state.clearMissingProvisioningRun(runId);
return;
}
// best-effort polling; don't fail launch because status fetch is flaky
}
await sleep(delayMs);
@ -342,6 +352,10 @@ export interface TeamSlice {
lastSendMessageResult: SendMessageResult | null;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
currentProvisioningRunIdByTeam: Record<string, string | null>;
currentRuntimeRunIdByTeam: Record<string, string | null>;
/** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */
ignoredProvisioningRunIds: Record<string, string>;
/**
* Per-team lower bound for provisioning progress timestamps.
* Used to ignore late progress events from a previous run after stoplaunch.
@ -352,9 +366,8 @@ export interface TeamSlice {
/** Per-team per-member spawn statuses during team provisioning/launch. */
memberSpawnStatusesByTeam: Record<string, Record<string, MemberSpawnStatusEntry>>;
fetchMemberSpawnStatuses: (teamName: string) => Promise<void>;
activeProvisioningRunId: string | null;
provisioningError: string | null;
clearProvisioningError: () => void;
provisioningErrorByTeam: Record<string, string | null>;
clearProvisioningError: (teamName?: string) => void;
/** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */
launchParamsByTeam: Record<string, TeamLaunchParams>;
kanbanFilterQuery: string | null;
@ -455,6 +468,7 @@ export interface TeamSlice {
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
clearMissingProvisioningRun: (runId: string) => void;
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
subscribeProvisioningProgress: () => void;
unsubscribeProvisioningProgress: () => void;
@ -479,6 +493,22 @@ export interface TeamSlice {
// --- Per-team launch params persistence ---
const LAUNCH_PARAMS_PREFIX = 'team:launchParams:';
export function getCurrentProvisioningProgressForTeam(
state: Pick<TeamSlice, 'currentProvisioningRunIdByTeam' | 'provisioningRuns'>,
teamName: string
): TeamProvisioningProgress | null {
const currentRunId = state.currentProvisioningRunIdByTeam[teamName];
return currentRunId ? (state.provisioningRuns[currentRunId] ?? null) : null;
}
export function isTeamProvisioningActive(
state: Pick<TeamSlice, 'currentProvisioningRunIdByTeam' | 'provisioningRuns'>,
teamName: string
): boolean {
const current = getCurrentProvisioningProgressForTeam(state, teamName);
return current != null && ACTIVE_PROVISIONING_STATES.has(current.state);
}
function loadAllLaunchParams(): Record<string, TeamLaunchParams> {
const result: Record<string, TeamLaunchParams> = {};
try {
@ -581,23 +611,51 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
crossTeamTargetsLoading: false,
reviewActionError: null,
provisioningRuns: {},
currentProvisioningRunIdByTeam: {},
currentRuntimeRunIdByTeam: {},
ignoredProvisioningRunIds: {},
provisioningStartedAtFloorByTeam: {},
leadActivityByTeam: {},
leadContextByTeam: {},
memberSpawnStatusesByTeam: {},
activeProvisioningRunId: null,
provisioningError: null,
clearProvisioningError: () => set({ provisioningError: null }),
provisioningErrorByTeam: {},
clearProvisioningError: (teamName?: string) =>
set((state) => {
if (!teamName) {
return { provisioningErrorByTeam: {} };
}
if (!(teamName in state.provisioningErrorByTeam)) {
return {};
}
const nextErrors = { ...state.provisioningErrorByTeam };
delete nextErrors[teamName];
return { provisioningErrorByTeam: nextErrors };
}),
launchParamsByTeam: loadAllLaunchParams(),
fetchMemberSpawnStatuses: async (teamName: string) => {
if (!api.teams?.getMemberSpawnStatuses) return;
try {
const statuses = await api.teams.getMemberSpawnStatuses(teamName);
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
set((prev) => ({
memberSpawnStatusesByTeam: {
...prev.memberSpawnStatusesByTeam,
[teamName]: statuses,
},
...(snapshot.runId != null &&
prev.currentRuntimeRunIdByTeam[teamName] != null &&
prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId
? {}
: {
currentRuntimeRunIdByTeam:
snapshot.runId == null
? prev.currentRuntimeRunIdByTeam
: {
...prev.currentRuntimeRunIdByTeam,
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
},
memberSpawnStatusesByTeam: {
...prev.memberSpawnStatusesByTeam,
[teamName]: snapshot.statuses,
},
}),
}));
} catch {
// ignore — spawn statuses are best-effort
@ -925,11 +983,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} catch (error) {
// If provisioning is in progress for this team, stay in loading state;
// file watcher / progress callback will refresh once config is written.
const isProvisioning = Object.values(get().provisioningRuns).some(
(run) =>
run.teamName === teamName &&
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
);
const isProvisioning = isTeamProvisioningActive(get(), teamName);
const msg = error instanceof Error ? error.message : String(error);
// IPC can report provisioning state explicitly.
@ -1296,7 +1350,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete cleaned[runId];
}
}
return { provisioningError: null, provisioningRuns: cleaned };
const nextErrors = { ...state.provisioningErrorByTeam };
delete nextErrors[request.teamName];
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
delete nextSpawnStatuses[request.teamName];
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
delete nextRuntimeRunIdByTeam[request.teamName];
const nextIgnoredRunIds = Object.fromEntries(
Object.entries(state.ignoredProvisioningRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
return {
provisioningRuns: cleaned,
provisioningErrorByTeam: nextErrors,
memberSpawnStatusesByTeam: nextSpawnStatuses,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
ignoredProvisioningRunIds: nextIgnoredRunIds,
};
});
// Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed.
@ -1313,7 +1384,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
updatedAt: floor,
},
},
activeProvisioningRunId: pendingRunId,
currentProvisioningRunIdByTeam: {
...state.currentProvisioningRunIdByTeam,
[request.teamName]: pendingRunId,
},
}));
try {
if (typeof api.teams.createTeam !== 'function') {
@ -1340,9 +1414,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}));
}
set({
activeProvisioningRunId: response.runId,
provisioningError: null,
set((state) => {
const nextRuns = { ...state.provisioningRuns };
const pendingRun = nextRuns[pendingRunId];
if (pendingRun) {
delete nextRuns[pendingRunId];
nextRuns[response.runId] = { ...pendingRun, runId: response.runId };
}
return {
provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: {
...state.currentProvisioningRunIdByTeam,
[request.teamName]: response.runId,
},
currentRuntimeRunIdByTeam: {
...state.currentRuntimeRunIdByTeam,
[request.teamName]: response.runId,
},
};
});
try {
await get().getProvisioningStatus(response.runId);
@ -1352,13 +1441,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
void pollProvisioningStatus(get, response.runId);
return response.runId;
} catch (error) {
set({
provisioningError:
error instanceof IpcError
const message =
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: error instanceof Error
? error.message
: 'Failed to create team',
: 'Failed to create team';
set((state) => {
const nextRuns = { ...state.provisioningRuns };
delete nextRuns[pendingRunId];
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
delete nextCurrentRunIdByTeam[request.teamName];
}
return {
provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
provisioningErrorByTeam: {
...state.provisioningErrorByTeam,
[request.teamName]: message,
},
};
});
throw error;
}
@ -1385,7 +1488,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete cleaned[runId];
}
}
return { provisioningError: null, provisioningRuns: cleaned };
const nextErrors = { ...state.provisioningErrorByTeam };
delete nextErrors[request.teamName];
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
delete nextSpawnStatuses[request.teamName];
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
delete nextRuntimeRunIdByTeam[request.teamName];
const nextIgnoredRunIds = Object.fromEntries(
Object.entries(state.ignoredProvisioningRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
return {
provisioningRuns: cleaned,
provisioningErrorByTeam: nextErrors,
memberSpawnStatusesByTeam: nextSpawnStatuses,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
ignoredProvisioningRunIds: nextIgnoredRunIds,
};
});
// Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed.
@ -1402,7 +1522,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
updatedAt: floor,
},
},
activeProvisioningRunId: pendingRunId,
currentProvisioningRunIdByTeam: {
...state.currentProvisioningRunIdByTeam,
[request.teamName]: pendingRunId,
},
}));
try {
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
@ -1432,9 +1555,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
});
}
set({
activeProvisioningRunId: response.runId,
provisioningError: null,
set((state) => {
const nextRuns = { ...state.provisioningRuns };
const pendingRun = nextRuns[pendingRunId];
if (pendingRun) {
delete nextRuns[pendingRunId];
nextRuns[response.runId] = { ...pendingRun, runId: response.runId };
}
return {
provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: {
...state.currentProvisioningRunIdByTeam,
[request.teamName]: response.runId,
},
currentRuntimeRunIdByTeam: {
...state.currentRuntimeRunIdByTeam,
[request.teamName]: response.runId,
},
};
});
try {
await get().getProvisioningStatus(response.runId);
@ -1444,13 +1582,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
void pollProvisioningStatus(get, response.runId);
return response.runId;
} catch (error) {
set({
provisioningError:
error instanceof IpcError
const message =
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: error instanceof Error
? error.message
: 'Failed to launch team',
: 'Failed to launch team';
set((state) => {
const nextRuns = { ...state.provisioningRuns };
delete nextRuns[pendingRunId];
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
delete nextCurrentRunIdByTeam[request.teamName];
}
return {
provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
provisioningErrorByTeam: {
...state.provisioningErrorByTeam,
[request.teamName]: message,
},
};
});
throw error;
}
@ -1464,43 +1616,139 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return progress;
},
clearMissingProvisioningRun: (runId: string) => {
set((state) => {
const existing = state.provisioningRuns[runId];
if (!existing) {
return {};
}
const nextRuns = { ...state.provisioningRuns };
delete nextRuns[runId];
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
const isCanonicalRun = nextCurrentRunIdByTeam[existing.teamName] === runId;
if (isCanonicalRun) {
delete nextCurrentRunIdByTeam[existing.teamName];
}
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
if (nextRuntimeRunIdByTeam[existing.teamName] === runId) {
delete nextRuntimeRunIdByTeam[existing.teamName];
}
const nextIgnoredRunIds = {
...state.ignoredProvisioningRunIds,
[runId]: existing.teamName,
};
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
if (isCanonicalRun) {
delete nextSpawnStatuses[existing.teamName];
}
return {
provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
memberSpawnStatusesByTeam: nextSpawnStatuses,
ignoredProvisioningRunIds: nextIgnoredRunIds,
};
});
},
cancelProvisioning: async (runId: string) => {
await unwrapIpc('team:cancelProvisioning', () => api.teams.cancelProvisioning(runId));
},
onProvisioningProgress: (progress: TeamProvisioningProgress) => {
if (get().ignoredProvisioningRunIds[progress.runId] === progress.teamName) {
return;
}
const floor = get().provisioningStartedAtFloorByTeam[progress.teamName];
if (floor && progress.startedAt < floor) {
// Ignore late progress from a previous run (common after stop→launch).
return;
}
const currentRunId = get().currentProvisioningRunIdByTeam[progress.teamName];
const existingProgress = get().provisioningRuns[progress.runId];
const isDuplicateProgress =
existingProgress?.updatedAt === progress.updatedAt &&
existingProgress?.state === progress.state &&
existingProgress?.message === progress.message &&
existingProgress?.error === progress.error &&
existingProgress?.pid === progress.pid;
if (isDuplicateProgress && currentRunId === progress.runId) {
return;
}
set((state) => {
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
const previousCurrentRunId = nextCurrentRunIdByTeam[progress.teamName];
let isCanonicalRun = false;
if (!previousCurrentRunId || previousCurrentRunId === progress.runId) {
nextCurrentRunIdByTeam[progress.teamName] = progress.runId;
isCanonicalRun = true;
} else if (
isPendingProvisioningRunId(previousCurrentRunId) &&
!isPendingProvisioningRunId(progress.runId)
) {
delete nextRuns[previousCurrentRunId];
nextCurrentRunIdByTeam[progress.teamName] = progress.runId;
isCanonicalRun = true;
}
if (!previousCurrentRunId) {
isCanonicalRun = true;
}
if (!isCanonicalRun) {
if (!(progress.runId in state.provisioningRuns)) {
return {};
}
const nextRuns = { ...state.provisioningRuns };
delete nextRuns[progress.runId];
return { provisioningRuns: nextRuns };
}
const nextRuns: Record<string, TeamProvisioningProgress> = {
...state.provisioningRuns,
[progress.runId]: progress,
};
// When real progress arrives, drop any pending placeholder runs for this team.
if (!isPendingProvisioningRunId(progress.runId)) {
for (const [runId, run] of Object.entries(nextRuns)) {
if (isPendingProvisioningRunId(runId) && run.teamName === progress.teamName) {
delete nextRuns[runId];
}
for (const [runId, run] of Object.entries(nextRuns)) {
if (runId !== progress.runId && run.teamName === progress.teamName) {
delete nextRuns[runId];
}
}
const nextErrors = { ...state.provisioningErrorByTeam };
if (progress.state === 'failed') {
nextErrors[progress.teamName] = progress.error ?? progress.message;
} else {
delete nextErrors[progress.teamName];
}
return {
provisioningRuns: nextRuns,
activeProvisioningRunId: progress.runId,
provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
currentRuntimeRunIdByTeam: {
...state.currentRuntimeRunIdByTeam,
[progress.teamName]: progress.runId,
},
provisioningErrorByTeam: nextErrors,
};
});
if (progress.state === 'ready' || progress.state === 'disconnected') {
const isCanonicalRun =
get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId;
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
set((prev) => {
const next = { ...prev.memberSpawnStatusesByTeam };
delete next[progress.teamName];
return { memberSpawnStatusesByTeam: next };
});
}
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
void get().fetchTeams();
// If the user already opened the team tab, reload team data now that
// config.json is guaranteed to exist.

View file

@ -47,11 +47,11 @@ import type {
CrossTeamSendResult,
GlobalTask,
KanbanColumnId,
LeadActivityState,
LeadContextUsage,
LeadActivitySnapshot,
LeadContextUsageSnapshot,
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
ReplaceMembersRequest,
SendMessageRequest,
SendMessageResult,
@ -483,9 +483,9 @@ export interface TeamsAPI {
getProjectBranch: (projectPath: string) => Promise<string | null>;
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
killProcess: (teamName: string, pid: number) => Promise<void>;
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
getMemberSpawnStatuses: (teamName: string) => Promise<Record<string, MemberSpawnStatusEntry>>;
getLeadActivity: (teamName: string) => Promise<LeadActivitySnapshot>;
getLeadContext: (teamName: string) => Promise<LeadContextUsageSnapshot>;
getMemberSpawnStatuses: (teamName: string) => Promise<MemberSpawnStatusesSnapshot>;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;

View file

@ -427,6 +427,11 @@ export interface CreateTaskRequest {
export type LeadActivityState = 'active' | 'idle' | 'offline';
export interface LeadActivitySnapshot {
state: LeadActivityState;
runId: string | null;
}
export interface LeadContextUsage {
/** Total tokens currently in context (input + cache_creation + cache_read) */
currentTokens: number;
@ -438,6 +443,16 @@ export interface LeadContextUsage {
updatedAt: string;
}
export interface LeadContextUsageSnapshot {
usage: LeadContextUsage | null;
runId: string | null;
}
export interface MemberSpawnStatusesSnapshot {
statuses: Record<string, MemberSpawnStatusEntry>;
runId: string | null;
}
export interface TeamChangeEvent {
type:
| 'config'
@ -449,6 +464,7 @@ export interface TeamChangeEvent {
| 'process'
| 'member-spawn';
teamName: string;
runId?: string;
detail?: string;
}

View file

@ -0,0 +1,142 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { EventEmitter } from 'events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let tempClaudeRoot = '';
let tempTeamsBase = '';
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return {
...actual,
getAutoDetectedClaudeBasePath: () => tempClaudeRoot,
getClaudeBasePath: () => tempClaudeRoot,
getTeamsBasePath: () => tempTeamsBase,
};
});
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
describe('TeamProvisioningService idempotent launch guards', () => {
beforeEach(() => {
vi.clearAllMocks();
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-launch-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
fs.mkdirSync(tempTeamsBase, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
});
it('reuses the alive run instead of spawning a duplicate launch', async () => {
const teamName = 'team-alpha';
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
projectPath: process.cwd(),
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'dev' }],
})
);
const svc = new TeamProvisioningService();
const aliveRun = {
runId: 'alive-run-1',
teamName,
request: { cwd: process.cwd() },
child: Object.assign(new EventEmitter(), {
stdin: { writable: true },
stdout: new EventEmitter(),
stderr: new EventEmitter(),
}),
processKilled: false,
cancelRequested: false,
};
(svc as any).runs.set(aliveRun.runId, aliveRun);
(svc as any).aliveRunByTeam.set(teamName, aliveRun.runId);
const response = await svc.launchTeam({ teamName, cwd: process.cwd() }, () => {});
expect(response.runId).toBe(aliveRun.runId);
});
it('does not reuse an alive run when cwd differs', async () => {
const teamName = 'team-alpha';
const currentCwd = fs.mkdtempSync(path.join(tempClaudeRoot, 'current-'));
const nextCwd = fs.mkdtempSync(path.join(tempClaudeRoot, 'next-'));
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
projectPath: currentCwd,
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'dev' }],
})
);
const svc = new TeamProvisioningService();
const aliveRun = {
runId: 'alive-run-1',
teamName,
request: { cwd: currentCwd },
child: Object.assign(new EventEmitter(), {
stdin: { writable: true },
stdout: new EventEmitter(),
stderr: new EventEmitter(),
}),
processKilled: false,
cancelRequested: false,
};
(svc as any).runs.set(aliveRun.runId, aliveRun);
(svc as any).aliveRunByTeam.set(teamName, aliveRun.runId);
await expect(svc.launchTeam({ teamName, cwd: nextCwd }, () => {})).rejects.toThrow(
`Team "${teamName}" is already running in "${path.resolve(currentCwd)}".`
);
});
it('fails closed when an alive run cwd cannot be determined', async () => {
const teamName = 'team-alpha';
const nextCwd = fs.mkdtempSync(path.join(tempClaudeRoot, 'next-'));
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'dev' }],
})
);
const svc = new TeamProvisioningService();
const aliveRun = {
runId: 'alive-run-1',
teamName,
request: { cwd: '' },
child: Object.assign(new EventEmitter(), {
stdin: { writable: true },
stdout: new EventEmitter(),
stderr: new EventEmitter(),
}),
processKilled: false,
cancelRequested: false,
spawnContext: { cwd: '' },
};
(svc as any).runs.set(aliveRun.runId, aliveRun);
(svc as any).aliveRunByTeam.set(teamName, aliveRun.runId);
await expect(svc.launchTeam({ teamName, cwd: nextCwd }, () => {})).rejects.toThrow(
`Team "${teamName}" is already running, but its cwd could not be determined.`
);
});
});

View file

@ -169,7 +169,10 @@ function attachRun(
activeCrossTeamReplyHints: [],
};
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
(service as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
teamName,
runId
);
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, run);
return run;

View file

@ -0,0 +1,224 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() },
}));
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: vi.fn(),
}));
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
describe('TeamProvisioningService prepare/auth behavior', () => {
let tempRoot = '';
beforeEach(() => {
vi.clearAllMocks();
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-'));
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
});
afterEach(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('does not create missing directories during prepareForProvisioning', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {},
authSource: 'none',
});
vi.spyOn(svc as any, 'probeClaudeRuntime').mockResolvedValue({});
const missingCwd = path.join(tempRoot, 'missing-project');
await svc.prepareForProvisioning(missingCwd, { forceFresh: true });
expect(fs.existsSync(missingCwd)).toBe(false);
});
it('keys the prepare probe cache by cwd', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {},
authSource: 'none',
});
const probeSpy = vi.spyOn(svc as any, 'probeClaudeRuntime').mockResolvedValue({});
const cwdA = fs.mkdtempSync(path.join(tempRoot, 'a-'));
const cwdB = fs.mkdtempSync(path.join(tempRoot, 'b-'));
await svc.prepareForProvisioning(cwdA, { forceFresh: true });
await svc.prepareForProvisioning(cwdA);
await svc.prepareForProvisioning(cwdB);
expect(probeSpy).toHaveBeenCalledTimes(2);
expect(probeSpy.mock.calls[0]?.[1]).toBe(cwdA);
expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB);
});
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
const result = await (svc as any).buildProvisioningEnv();
expect(result.authSource).toBe('anthropic_auth_token');
expect(result.env.ANTHROPIC_API_KEY).toBe('proxy-token');
});
it('prefers explicit ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
ANTHROPIC_API_KEY: 'real-key',
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
PATH: '/usr/bin',
SHELL: '/bin/zsh',
});
const result = await (svc as any).buildProvisioningEnv();
expect(result.authSource).toBe('anthropic_api_key');
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
});
it('does not treat assistant-text 401 noise as an auth failure', () => {
const svc = new TeamProvisioningService();
expect((svc as any).isAuthFailureWarning('assistant mentioned 401 unauthorized', 'assistant')).toBe(
false
);
expect((svc as any).isAuthFailureWarning('invalid api key', 'stderr')).toBe(true);
});
it('does not re-check auth from stdout json noise during pre-complete finalization', async () => {
const svc = new TeamProvisioningService();
const handleAuthFailureInOutput = vi.spyOn(svc as any, 'handleAuthFailureInOutput');
vi.spyOn(svc as any, 'updateConfigPostLaunch').mockResolvedValue(undefined);
vi.spyOn(svc as any, 'cleanupPrelaunchBackup').mockResolvedValue(undefined);
vi.spyOn(svc as any, 'relayLeadInboxMessages').mockResolvedValue(undefined);
const run = {
runId: 'run-1',
teamName: 'team-alpha',
request: {
cwd: tempRoot,
color: 'blue',
members: [{ name: 'dev', role: 'engineer' }],
},
progress: {
runId: 'run-1',
teamName: 'team-alpha',
state: 'monitoring',
message: 'Monitoring',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
provisioningComplete: false,
cancelRequested: false,
processKilled: false,
stdoutBuffer:
'{"type":"assistant","message":{"content":[{"type":"text","text":"invalid api key"}]}}\n',
stdoutLogLineBuf: '',
stdoutParserCarry:
'{"type":"assistant","message":{"content":[{"type":"text","text":"invalid api key"}]}}',
stdoutParserCarryIsCompleteJson: true,
stdoutParserCarryLooksLikeClaudeJson: true,
stderrBuffer: '',
stderrLogLineBuf: '',
provisioningOutputParts: ['invalid api key'],
onProgress: vi.fn(),
isLaunch: true,
detectedSessionId: null,
timeoutHandle: null,
fsMonitorHandle: null,
claudeLogLines: [],
leadActivityState: 'active',
leadContextUsage: null,
};
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
await (svc as any).handleProvisioningTurnComplete(run);
expect(handleAuthFailureInOutput).not.toHaveBeenCalledWith(run, expect.any(String), 'pre-complete');
expect(run.onProgress).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'run-1',
state: 'ready',
})
);
});
it('re-checks a trailing plaintext stdout auth failure during pre-complete finalization', async () => {
const svc = new TeamProvisioningService();
const handleAuthFailureInOutput = vi
.spyOn(svc as any, 'handleAuthFailureInOutput')
.mockImplementation(() => {});
const run = {
runId: 'run-2',
teamName: 'team-alpha',
request: {
cwd: tempRoot,
color: 'blue',
members: [{ name: 'dev', role: 'engineer' }],
},
progress: {
runId: 'run-2',
teamName: 'team-alpha',
state: 'monitoring',
message: 'Monitoring',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
provisioningComplete: false,
cancelRequested: false,
processKilled: false,
stdoutBuffer: '[ERROR] invalid api key',
stdoutLogLineBuf: '',
stdoutParserCarry: '[ERROR] invalid api key',
stdoutParserCarryIsCompleteJson: false,
stdoutParserCarryLooksLikeClaudeJson: false,
stderrBuffer: '',
stderrLogLineBuf: '',
provisioningOutputParts: [],
onProgress: vi.fn(),
isLaunch: true,
detectedSessionId: null,
timeoutHandle: null,
fsMonitorHandle: null,
claudeLogLines: [],
leadActivityState: 'active',
leadContextUsage: null,
};
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
await (svc as any).handleProvisioningTurnComplete(run);
expect(handleAuthFailureInOutput).toHaveBeenCalledWith(run, '[ERROR] invalid api key', 'pre-complete');
expect(run.onProgress).not.toHaveBeenCalledWith(
expect.objectContaining({
runId: 'run-2',
state: 'ready',
})
);
});
});

View file

@ -155,7 +155,10 @@ function attachAliveRun(
});
const writable = opts?.writable ?? true;
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
(service as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
teamName,
runId
);
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, {
runId,
teamName,

View file

@ -1,13 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand';
import { createTeamSlice } from '../../../src/renderer/store/slices/teamSlice';
import {
createTeamSlice,
getCurrentProvisioningProgressForTeam,
} from '../../../src/renderer/store/slices/teamSlice';
const hoisted = vi.hoisted(() => ({
list: vi.fn(),
getData: vi.fn(),
createTeam: vi.fn(),
getProvisioningStatus: vi.fn(),
getMemberSpawnStatuses: vi.fn(),
cancelProvisioning: vi.fn(),
sendMessage: vi.fn(),
requestReview: vi.fn(),
@ -23,6 +27,7 @@ vi.mock('@renderer/api', () => ({
getData: hoisted.getData,
createTeam: hoisted.createTeam,
getProvisioningStatus: hoisted.getProvisioningStatus,
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
cancelProvisioning: hoisted.cancelProvisioning,
sendMessage: hoisted.sendMessage,
requestReview: hoisted.requestReview,
@ -97,6 +102,7 @@ describe('teamSlice actions', () => {
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
hoisted.cancelProvisioning.mockResolvedValue(undefined);
});
@ -366,4 +372,245 @@ describe('teamSlice actions', () => {
]);
});
});
describe('provisioning run scoping', () => {
it('rolls back optimistic pending run on early createTeam failure', async () => {
const store = createSliceStore();
hoisted.createTeam.mockRejectedValue(new Error('create failed'));
await expect(
store.getState().createTeam({
teamName: 'my-team',
cwd: '/tmp/project',
members: [],
})
).rejects.toThrow('create failed');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
expect(Object.values(store.getState().provisioningRuns)).toHaveLength(0);
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
});
it('keeps the current run pinned when stale progress from another run arrives', () => {
const store = createSliceStore();
const startedAt = '2026-03-12T10:00:00.000Z';
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'spawning',
message: 'Current run',
startedAt,
updatedAt: startedAt,
});
store.getState().onProvisioningProgress({
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale failure',
error: 'stale',
startedAt: '2026-03-12T10:00:01.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
});
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().provisioningErrorByTeam['my-team']).toBeUndefined();
expect(store.getState().provisioningRuns['run-stale']).toBeUndefined();
});
it('clears orphaned runs when polling reports Unknown runId', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'pending:my-team:1': {
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'spawning',
message: 'Launching',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
},
});
store.getState().clearMissingProvisioningRun('pending:my-team:1');
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team');
});
it('does not resurrect a cleared missing run when late progress arrives', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'pending:my-team:1': {
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'spawning',
message: 'Launching',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
});
store.getState().clearMissingProvisioningRun('pending:my-team:1');
store.getState().onProvisioningProgress({
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'monitoring',
message: 'Late zombie progress',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:02.000Z',
});
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
});
it('keeps runtime run id separate from provisioning run id when fetching spawn statuses', async () => {
const store = createSliceStore();
store.setState({
currentProvisioningRunIdByTeam: {
'my-team': 'provisioning-run',
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({
runId: 'runtime-run',
statuses: {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
});
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('provisioning-run');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
});
});
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'run-current': {
runId: 'run-current',
teamName: 'my-team',
state: 'monitoring',
message: 'Current run',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
'run-stale': {
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale run',
startedAt: '2026-03-12T10:00:01.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-current',
},
memberSpawnStatusesByTeam: {
'my-team': {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
},
});
store.getState().clearMissingProvisioningRun('run-stale');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
});
});
it('keeps the terminal canonical run pinned and does not fall back to other team runs', () => {
const store = createSliceStore();
const startedAt = '2026-03-12T10:00:00.000Z';
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'monitoring',
message: 'Current run',
startedAt,
updatedAt: startedAt,
});
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'disconnected',
message: 'Disconnected',
startedAt,
updatedAt: '2026-03-12T10:00:01.000Z',
});
store.setState((state: ReturnType<typeof store.getState>) => ({
provisioningRuns: {
...state.provisioningRuns,
'run-stale': {
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale run',
startedAt: '2026-03-12T10:00:02.000Z',
updatedAt: '2026-03-12T10:00:02.000Z',
},
},
}));
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
expect(getCurrentProvisioningProgressForTeam(store.getState(), 'my-team')).toEqual(
expect.objectContaining({
runId: 'run-current',
state: 'disconnected',
})
);
});
it('does not fall back to a team-wide latest run when no current run is pinned', () => {
expect(
getCurrentProvisioningProgressForTeam(
{
currentProvisioningRunIdByTeam: {},
provisioningRuns: {
'run-stale': {
runId: 'run-stale',
teamName: 'my-team',
state: 'failed',
message: 'Stale run',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
},
'my-team'
)
).toBeNull();
});
});
});

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
describe('teamMemberName helpers', () => {
it('parses numeric suffix names', () => {
expect(parseNumericSuffixName('alice-2')).toEqual({ base: 'alice', suffix: 2 });
expect(parseNumericSuffixName('alice')).toBeNull();
expect(parseNumericSuffixName('')).toBeNull();
});
it('drops cli auto-suffixed names only when the base name also exists', () => {
const keepName = createCliAutoSuffixNameGuard(['dev', 'dev-2', 'dev-3']);
expect(keepName('dev')).toBe(true);
expect(keepName('dev-2')).toBe(false);
expect(keepName('dev-3')).toBe(false);
});
it('keeps -1 names because they are often intentional', () => {
const keepName = createCliAutoSuffixNameGuard(['worker', 'worker-1']);
expect(keepName('worker')).toBe(true);
expect(keepName('worker-1')).toBe(true);
});
it('keeps suffixed names when the base name is absent', () => {
const keepName = createCliAutoSuffixNameGuard(['alice-2']);
expect(keepName('alice-2')).toBe(true);
});
it('treats base-name collisions case-insensitively', () => {
const keepName = createCliAutoSuffixNameGuard(['Alice', 'alice-2']);
expect(keepName('alice-2')).toBe(false);
});
});