From 3723eba5b4dd9255e84ac71d06bd128552ab03b3 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 12 Mar 2026 14:14:58 +0200 Subject: [PATCH] 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. --- src/main/ipc/teams.ts | 9 +- .../services/team/TeamProvisioningService.ts | 410 +++++++++++++----- src/preload/index.ts | 15 +- src/renderer/api/httpClient.ts | 8 +- .../components/team/TeamDetailView.tsx | 9 +- src/renderer/components/team/TeamListView.tsx | 37 +- .../team/TeamProvisioningBanner.tsx | 20 +- .../team/dialogs/CreateTeamDialog.tsx | 39 +- .../team/dialogs/LaunchTeamDialog.tsx | 32 +- .../team/messages/MessageComposer.tsx | 9 +- src/renderer/store/index.ts | 45 ++ src/renderer/store/slices/teamSlice.ts | 344 +++++++++++++-- src/shared/types/api.ts | 12 +- src/shared/types/team.ts | 16 + ...TeamProvisioningServiceIdempotency.test.ts | 142 ++++++ ...eamProvisioningServiceLiveMessages.test.ts | 5 +- .../TeamProvisioningServicePrepare.test.ts | 224 ++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 5 +- test/renderer/store/teamSlice.test.ts | 249 ++++++++++- test/shared/utils/teamMemberName.test.ts | 38 ++ 20 files changed, 1418 insertions(+), 250 deletions(-) create mode 100644 test/main/services/team/TeamProvisioningServiceIdempotency.test.ts create mode 100644 test/main/services/team/TeamProvisioningServicePrepare.test.ts create mode 100644 test/shared/utils/teamMemberName.test.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 44566ab7..9fe99e27 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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> { +): Promise> { 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> { +): Promise> { 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>> { +): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9010dfb5..4a3473dc 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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(); +const probeInFlightByKey = new Map>(); + +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(); - private readonly activeByTeam = new Map(); + private readonly provisioningRunByTeam = new Map(); + private readonly aliveRunByTeam = new Map(); private readonly teamOpLocks = new Map>(); private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); @@ -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 { - const runId = this.activeByTeam.get(teamName); - if (!runId) return {}; + getMemberSpawnStatuses(teamName: string): { + statuses: Record; + 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 = {}; 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 { 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 { - // 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 { + 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 { + 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 { - 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 { - 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 { - 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 { - 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 => { - 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 => { - 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 = 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 { - 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); diff --git a/src/preload/index.ts b/src/preload/index.ts index 40fedc17..77ce5323 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_KILL_PROCESS, teamName, pid); }, getLeadActivity: async (teamName: string) => { - const result = await invokeIpcWithResult(TEAM_LEAD_ACTIVITY, teamName); - return result as 'active' | 'idle' | 'offline'; + return invokeIpcWithResult(TEAM_LEAD_ACTIVITY, teamName); }, getLeadContext: async (teamName: string) => { - return invokeIpcWithResult(TEAM_LEAD_CONTEXT, teamName); + return invokeIpcWithResult(TEAM_LEAD_CONTEXT, teamName); }, getMemberSpawnStatuses: async (teamName: string) => { - return invokeIpcWithResult>( - TEAM_MEMBER_SPAWN_STATUSES, - teamName - ); + return invokeIpcWithResult(TEAM_MEMBER_SPAWN_STATUSES, teamName); }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d158d84f..da10a13b 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -820,14 +820,14 @@ export class HttpAPIClient implements ElectronAPI { killProcess: async (_teamName: string, _pid: number): Promise => { // 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 => { // Not available via HTTP client — no-op diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c2d27741..0166c680 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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, diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index cc01abbd..4143a2d7 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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, + currentProgress: ReturnType, leadActivityByTeam: Record ): 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 => { 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 diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index fea60239..70a5297f 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -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, - 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); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index ea078c71..c1f91875 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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; + 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(null); const [prepareWarnings, setPrepareWarnings] = useState([]); + 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; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index d632279f..8ca227eb 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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; } @@ -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(null); const [prepareWarnings, setPrepareWarnings] = useState([]); + 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); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 662e6c8d..2bfbbc65 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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]); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 5743cafb..c6d3370a 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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 = { @@ -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; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 35f771bd..79906b92 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -25,12 +25,18 @@ function sleep(ms: number): Promise { 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(promise: Promise, ms: number, label: string): Promise { let timer: ReturnType | undefined; const timeout = new Promise((_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; + currentProvisioningRunIdByTeam: Record; + currentRuntimeRunIdByTeam: Record; + /** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */ + ignoredProvisioningRunIds: Record; /** * Per-team lower bound for provisioning progress timestamps. * Used to ignore late progress events from a previous run after stop→launch. @@ -352,9 +366,8 @@ export interface TeamSlice { /** Per-team per-member spawn statuses during team provisioning/launch. */ memberSpawnStatusesByTeam: Record>; fetchMemberSpawnStatuses: (teamName: string) => Promise; - activeProvisioningRunId: string | null; - provisioningError: string | null; - clearProvisioningError: () => void; + provisioningErrorByTeam: Record; + clearProvisioningError: (teamName?: string) => void; /** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */ launchParamsByTeam: Record; kanbanFilterQuery: string | null; @@ -455,6 +468,7 @@ export interface TeamSlice { launchTeam: (request: TeamLaunchRequest) => Promise; cancelProvisioning: (runId: string) => Promise; getProvisioningStatus: (runId: string) => Promise; + 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, + teamName: string +): TeamProvisioningProgress | null { + const currentRunId = state.currentProvisioningRunIdByTeam[teamName]; + return currentRunId ? (state.provisioningRuns[currentRunId] ?? null) : null; +} + +export function isTeamProvisioningActive( + state: Pick, + teamName: string +): boolean { + const current = getCurrentProvisioningProgressForTeam(state, teamName); + return current != null && ACTIVE_PROVISIONING_STATES.has(current.state); +} + function loadAllLaunchParams(): Record { const result: Record = {}; try { @@ -581,23 +611,51 @@ export const createTeamSlice: StateCreator = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = { ...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. diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b0f86633..056c208a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; - getLeadActivity: (teamName: string) => Promise; - getLeadContext: (teamName: string) => Promise; - getMemberSpawnStatuses: (teamName: string) => Promise>; + getLeadActivity: (teamName: string) => Promise; + getLeadContext: (teamName: string) => Promise; + getMemberSpawnStatuses: (teamName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c9895305..8ac912ad 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; + runId: string | null; +} + export interface TeamChangeEvent { type: | 'config' @@ -449,6 +464,7 @@ export interface TeamChangeEvent { | 'process' | 'member-spawn'; teamName: string; + runId?: string; detail?: string; } diff --git a/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts b/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts new file mode 100644 index 00000000..2673341c --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceIdempotency.test.ts @@ -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(); + 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.` + ); + }); +}); diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index ce9a5f97..c7bd420b 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -169,7 +169,10 @@ function attachRun( activeCrossTeamReplyHints: [], }; - (service as unknown as { activeByTeam: Map }).activeByTeam.set(teamName, runId); + (service as unknown as { aliveRunByTeam: Map }).aliveRunByTeam.set( + teamName, + runId + ); (service as unknown as { runs: Map }).runs.set(runId, run); return run; diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts new file mode 100644 index 00000000..b521d28f --- /dev/null +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -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', + }) + ); + }); +}); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index f4d9ed15..6846e78b 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -155,7 +155,10 @@ function attachAliveRun( }); const writable = opts?.writable ?? true; - (service as unknown as { activeByTeam: Map }).activeByTeam.set(teamName, runId); + (service as unknown as { aliveRunByTeam: Map }).aliveRunByTeam.set( + teamName, + runId + ); (service as unknown as { runs: Map }).runs.set(runId, { runId, teamName, diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 219e2e5f..a04b94c8 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -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) => ({ + 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(); + }); + }); }); diff --git a/test/shared/utils/teamMemberName.test.ts b/test/shared/utils/teamMemberName.test.ts new file mode 100644 index 00000000..7979c735 --- /dev/null +++ b/test/shared/utils/teamMemberName.test.ts @@ -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); + }); +});