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:
parent
d53999ba45
commit
3723eba5b4
20 changed files with 1418 additions and 250 deletions
|
|
@ -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' };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 stop→launch.
|
* Used to ignore late progress events from a previous run after stop→launch.
|
||||||
|
|
@ -352,9 +366,8 @@ export interface TeamSlice {
|
||||||
/** Per-team per-member spawn statuses during team provisioning/launch. */
|
/** 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.
|
||||||
|
|
|
||||||
|
|
@ -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[]>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
224
test/main/services/team/TeamProvisioningServicePrepare.test.ts
Normal file
224
test/main/services/team/TeamProvisioningServicePrepare.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
38
test/shared/utils/teamMemberName.test.ts
Normal file
38
test/shared/utils/teamMemberName.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue