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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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