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,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
SendMessageRequest,
|
||||
|
|
@ -1829,7 +1832,7 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
|
|||
async function handleLeadActivity(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<string>> {
|
||||
): Promise<IpcResult<LeadActivitySnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
|
|
@ -1842,7 +1845,7 @@ async function handleLeadActivity(
|
|||
async function handleLeadContext(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<LeadContextUsage | null>> {
|
||||
): Promise<IpcResult<LeadContextUsageSnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
|
|
@ -1855,7 +1858,7 @@ async function handleLeadContext(
|
|||
async function handleMemberSpawnStatuses(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<Record<string, MemberSpawnStatusEntry>>> {
|
||||
): Promise<IpcResult<MemberSpawnStatusesSnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
|
|
|
|||
|
|
@ -150,6 +150,20 @@ function logsSuggestShutdownOrCleanup(logs: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function looksLikeClaudeStdoutJsonFragment(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/"type"\s*:/.test(trimmed) ||
|
||||
/"message"\s*:/.test(trimmed) ||
|
||||
/"content"\s*:/.test(trimmed) ||
|
||||
/"subtype"\s*:/.test(trimmed) ||
|
||||
/"session_id"\s*:/.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
interface ProvisioningRun {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
|
|
@ -165,6 +179,12 @@ interface ProvisioningRun {
|
|||
stdoutLogLineBuf: string;
|
||||
/** Carry buffer for stderr line splitting (CLI output). */
|
||||
stderrLogLineBuf: string;
|
||||
/** Raw stdout parser carry that has not been newline-delimited yet. */
|
||||
stdoutParserCarry: string;
|
||||
/** Whether the current stdout parser carry is a complete JSON fragment. */
|
||||
stdoutParserCarryIsCompleteJson: boolean;
|
||||
/** Whether the current stdout parser carry looks like Claude stream-json structure. */
|
||||
stdoutParserCarryLooksLikeClaudeJson: boolean;
|
||||
/** ISO timestamp when the last CLI line was recorded. */
|
||||
claudeLogsUpdatedAt?: string;
|
||||
processKilled: boolean;
|
||||
|
|
@ -1076,18 +1096,27 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
|
|||
}
|
||||
|
||||
interface CachedProbeResult {
|
||||
cacheKey: string;
|
||||
claudePath: string;
|
||||
authSource: ProvisioningAuthSource;
|
||||
warning?: string;
|
||||
cachedAtMs: number;
|
||||
}
|
||||
|
||||
let cachedProbeResult: CachedProbeResult | null = null;
|
||||
let probeInFlight: Promise<{
|
||||
type ProbeResult = {
|
||||
claudePath: string;
|
||||
authSource: ProvisioningAuthSource;
|
||||
warning?: string;
|
||||
} | null> | null = null;
|
||||
};
|
||||
|
||||
type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-complete';
|
||||
|
||||
const cachedProbeResults = new Map<string, CachedProbeResult>();
|
||||
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
|
||||
|
||||
function createProbeCacheKey(cwd: string): string {
|
||||
return `${path.resolve(cwd)}::${getClaudeBasePath()}`;
|
||||
}
|
||||
|
||||
function isTransientProbeWarning(warning: string): boolean {
|
||||
const lower = warning.toLowerCase();
|
||||
|
|
@ -1106,7 +1135,8 @@ export class TeamProvisioningService {
|
|||
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
private readonly runs = new Map<string, ProvisioningRun>();
|
||||
private readonly activeByTeam = new Map<string, string>();
|
||||
private readonly provisioningRunByTeam = new Map<string, string>();
|
||||
private readonly aliveRunByTeam = new Map<string, string>();
|
||||
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||
|
|
@ -1166,7 +1196,7 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
query?: { offset?: number; limit?: number }
|
||||
): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) {
|
||||
return { lines: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
|
@ -1210,6 +1240,18 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private getProvisioningRunId(teamName: string): string | null {
|
||||
return this.provisioningRunByTeam.get(teamName) ?? null;
|
||||
}
|
||||
|
||||
private getAliveRunId(teamName: string): string | null {
|
||||
return this.aliveRunByTeam.get(teamName) ?? null;
|
||||
}
|
||||
|
||||
private getTrackedRunId(teamName: string): string | null {
|
||||
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
|
||||
}
|
||||
|
||||
private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void {
|
||||
const nowMs = Date.now();
|
||||
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
|
||||
|
|
@ -1722,31 +1764,78 @@ export class TeamProvisioningService {
|
|||
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
||||
}
|
||||
|
||||
getLeadActivityState(teamName: string): 'active' | 'idle' | 'offline' {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return 'offline';
|
||||
getLeadActivityState(teamName: string): {
|
||||
state: 'active' | 'idle' | 'offline';
|
||||
runId: string | null;
|
||||
} {
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) return { state: 'offline', runId: null };
|
||||
const run = this.runs.get(runId);
|
||||
if (!run || run.processKilled || run.cancelRequested) return 'offline';
|
||||
return run.leadActivityState;
|
||||
if (!run || run.processKilled || run.cancelRequested) return { state: 'offline', runId: null };
|
||||
return { state: run.leadActivityState, runId };
|
||||
}
|
||||
|
||||
getLeadContextUsage(teamName: string): LeadContextUsage | null {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return null;
|
||||
getLeadContextUsage(teamName: string): { usage: LeadContextUsage | null; runId: string | null } {
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) return { usage: null, runId: null };
|
||||
const run = this.runs.get(runId);
|
||||
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null;
|
||||
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) {
|
||||
return { usage: null, runId: null };
|
||||
}
|
||||
const { currentTokens, contextWindow } = run.leadContextUsage;
|
||||
const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
|
||||
const percent = Math.max(0, Math.min(100, percentRaw));
|
||||
return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() };
|
||||
return {
|
||||
usage: { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() },
|
||||
runId,
|
||||
};
|
||||
}
|
||||
|
||||
private isCurrentTrackedRun(run: ProvisioningRun): boolean {
|
||||
return this.getTrackedRunId(run.teamName) === run.runId;
|
||||
}
|
||||
|
||||
private getRunTrackedCwd(run: ProvisioningRun | null | undefined): string | null {
|
||||
const requestCwd = typeof run?.request?.cwd === 'string' ? run.request.cwd.trim() : '';
|
||||
if (requestCwd) return path.resolve(requestCwd);
|
||||
|
||||
const spawnCwd = typeof run?.spawnContext?.cwd === 'string' ? run.spawnContext.cwd.trim() : '';
|
||||
if (spawnCwd) return path.resolve(spawnCwd);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getPreCompleteCliErrorText(run: ProvisioningRun): string {
|
||||
const parts: string[] = [];
|
||||
const stderrText = run.stderrBuffer.trim();
|
||||
if (stderrText) {
|
||||
parts.push(stderrText);
|
||||
}
|
||||
|
||||
// Re-check only the parser-owned stdout carry that never became a newline-delimited message.
|
||||
// If it is complete JSON or clearly looks like Claude stream-json structure, ignore it here.
|
||||
// Otherwise treat it as trailing plaintext CLI output that should still participate in the
|
||||
// final auth/API failure guard.
|
||||
const trailingStdout = run.stdoutParserCarry.trim();
|
||||
if (
|
||||
trailingStdout &&
|
||||
!run.stdoutParserCarryIsCompleteJson &&
|
||||
!run.stdoutParserCarryLooksLikeClaudeJson
|
||||
) {
|
||||
parts.push(trailingStdout);
|
||||
}
|
||||
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
|
||||
if (run.leadActivityState === state) return;
|
||||
run.leadActivityState = state;
|
||||
if (!this.isCurrentTrackedRun(run)) return;
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'lead-activity',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: state,
|
||||
});
|
||||
}
|
||||
|
|
@ -1767,9 +1856,11 @@ export class TeamProvisioningService {
|
|||
error,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
if (!this.isCurrentTrackedRun(run)) return;
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'member-spawn',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: memberName,
|
||||
});
|
||||
}
|
||||
|
|
@ -1778,16 +1869,19 @@ export class TeamProvisioningService {
|
|||
* Get current member spawn statuses for a team.
|
||||
* Returns a map of memberName → MemberSpawnStatusEntry.
|
||||
*/
|
||||
getMemberSpawnStatuses(teamName: string): Record<string, MemberSpawnStatusEntry> {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return {};
|
||||
getMemberSpawnStatuses(teamName: string): {
|
||||
statuses: Record<string, MemberSpawnStatusEntry>;
|
||||
runId: string | null;
|
||||
} {
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) return { statuses: {}, runId: null };
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) return {};
|
||||
if (!run) return { statuses: {}, runId: null };
|
||||
const result: Record<string, MemberSpawnStatusEntry> = {};
|
||||
for (const [name, entry] of run.memberSpawnStatuses) {
|
||||
result[name] = { status: entry.status, error: entry.error, updatedAt: entry.updatedAt };
|
||||
}
|
||||
return result;
|
||||
return { statuses: result, runId };
|
||||
}
|
||||
|
||||
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
|
||||
|
|
@ -1795,6 +1889,7 @@ export class TeamProvisioningService {
|
|||
|
||||
private emitLeadContextUsage(run: ProvisioningRun): void {
|
||||
if (!run.leadContextUsage || !run.provisioningComplete) return;
|
||||
if (!this.isCurrentTrackedRun(run)) return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - run.leadContextUsage.lastEmittedAt <
|
||||
|
|
@ -1815,15 +1910,16 @@ export class TeamProvisioningService {
|
|||
this.teamChangeEmitter?.({
|
||||
type: 'lead-context',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
try {
|
||||
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS)
|
||||
return;
|
||||
const result = await this.getCachedOrProbeResult(process.cwd());
|
||||
const cwd = process.cwd();
|
||||
if (this.getFreshCachedProbeResult(cwd)) return;
|
||||
const result = await this.getCachedOrProbeResult(cwd);
|
||||
if (!result) return;
|
||||
logger.info('CLI warmup completed');
|
||||
} catch (error) {
|
||||
|
|
@ -1835,23 +1931,20 @@ export class TeamProvisioningService {
|
|||
cwd?: string,
|
||||
opts?: { forceFresh?: boolean }
|
||||
): Promise<TeamProvisioningPrepareResult> {
|
||||
// Always validate cwd even when cache is available
|
||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||
if (targetCwdForValidation && path.isAbsolute(targetCwdForValidation)) {
|
||||
await ensureCwdExists(targetCwdForValidation);
|
||||
}
|
||||
await this.validatePrepareCwd(targetCwdForValidation);
|
||||
|
||||
// Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache
|
||||
if (opts?.forceFresh) {
|
||||
cachedProbeResult = null;
|
||||
this.clearProbeCache(targetCwdForValidation);
|
||||
}
|
||||
|
||||
const cached = this.getFreshCachedProbeResult();
|
||||
const cached = this.getFreshCachedProbeResult(targetCwdForValidation);
|
||||
if (cached) {
|
||||
const { warning, authSource } = cached;
|
||||
const warnings: string[] = [];
|
||||
if (warning) warnings.push(warning);
|
||||
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
|
||||
const isAuthFailure = warning ? this.isAuthFailureWarning(warning, 'probe') : false;
|
||||
const ready = !warning || authSource !== 'none' || !isAuthFailure;
|
||||
return {
|
||||
ready,
|
||||
|
|
@ -1868,7 +1961,6 @@ export class TeamProvisioningService {
|
|||
if (!path.isAbsolute(targetCwd)) {
|
||||
throw new Error('cwd must be an absolute path');
|
||||
}
|
||||
await ensureCwdExists(targetCwd);
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
|
|
@ -1885,7 +1977,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
if (probeResult.warning) {
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning);
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||
if (authSource === 'none' && isAuthFailure) {
|
||||
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
|
||||
return {
|
||||
|
|
@ -1908,20 +2000,43 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
private getFreshCachedProbeResult(): CachedProbeResult | null {
|
||||
if (!cachedProbeResult) return null;
|
||||
const ageMs = Date.now() - cachedProbeResult.cachedAtMs;
|
||||
private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null {
|
||||
const cacheKey = createProbeCacheKey(cwd);
|
||||
const cached = cachedProbeResults.get(cacheKey);
|
||||
if (!cached) return null;
|
||||
const ageMs = Date.now() - cached.cachedAtMs;
|
||||
if (ageMs >= PROBE_CACHE_TTL_MS) {
|
||||
cachedProbeResult = null;
|
||||
cachedProbeResults.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
return cachedProbeResult;
|
||||
return cached;
|
||||
}
|
||||
|
||||
private async getCachedOrProbeResult(
|
||||
cwd: string
|
||||
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
|
||||
const cached = this.getFreshCachedProbeResult();
|
||||
private clearProbeCache(cwd: string): void {
|
||||
cachedProbeResults.delete(createProbeCacheKey(cwd));
|
||||
}
|
||||
|
||||
private async validatePrepareCwd(cwd: string): Promise<void> {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new Error('cwd must be an absolute path');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(cwd);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('cwd must be a directory');
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getCachedOrProbeResult(cwd: string): Promise<ProbeResult | null> {
|
||||
const cacheKey = createProbeCacheKey(cwd);
|
||||
const cached = this.getFreshCachedProbeResult(cwd);
|
||||
if (cached) {
|
||||
return {
|
||||
claudePath: cached.claudePath,
|
||||
|
|
@ -1930,11 +2045,12 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
if (probeInFlight) {
|
||||
return await probeInFlight;
|
||||
const existingProbe = probeInFlightByKey.get(cacheKey);
|
||||
if (existingProbe) {
|
||||
return await existingProbe;
|
||||
}
|
||||
|
||||
probeInFlight = (async () => {
|
||||
const probePromise = (async () => {
|
||||
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||
if (!claudePath) return null;
|
||||
|
||||
|
|
@ -1948,36 +2064,57 @@ export class TeamProvisioningService {
|
|||
|
||||
const shouldCache =
|
||||
!probe.warning ||
|
||||
(!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning));
|
||||
(!this.isAuthFailureWarning(probe.warning, 'probe') &&
|
||||
!isTransientProbeWarning(probe.warning));
|
||||
|
||||
if (shouldCache) {
|
||||
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
|
||||
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
|
||||
} else {
|
||||
// Don't pin auth failures / transient failures in cache — user may fix and retry.
|
||||
cachedProbeResult = null;
|
||||
cachedProbeResults.delete(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
})();
|
||||
probeInFlightByKey.set(cacheKey, probePromise);
|
||||
|
||||
try {
|
||||
return await probeInFlight;
|
||||
return await probePromise;
|
||||
} finally {
|
||||
probeInFlight = null;
|
||||
probeInFlightByKey.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private isAuthFailureWarning(text: string): boolean {
|
||||
private isAuthFailureWarning(text: string, source: AuthWarningSource): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
const has401 = /(^|\D)401(\D|$)/.test(lower);
|
||||
return (
|
||||
const hasExplicitCliAuthSignal =
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('not logged in') ||
|
||||
lower.includes('please run /login') ||
|
||||
lower.includes('missing api key') ||
|
||||
lower.includes('invalid api key') ||
|
||||
lower.includes('unauthorized') ||
|
||||
has401
|
||||
lower.includes('authentication failed') ||
|
||||
lower.includes('run `claude auth login`') ||
|
||||
lower.includes('claude auth login');
|
||||
|
||||
if (hasExplicitCliAuthSignal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (source === 'assistant' || source === 'stdout') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasAuthStatus401 =
|
||||
/api error:\s*401\b/i.test(text) ||
|
||||
/\b401 unauthorized\b/i.test(lower) ||
|
||||
(/(^|\D)401(\D|$)/.test(lower) &&
|
||||
(lower.includes('auth') || lower.includes('api') || lower.includes('login')));
|
||||
|
||||
return (
|
||||
hasAuthStatus401 ||
|
||||
(lower.includes('unauthorized') &&
|
||||
(lower.includes('api') || lower.includes('auth') || lower.includes('login')))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2048,9 +2185,13 @@ export class TeamProvisioningService {
|
|||
* On first detection: kills process, waits, and respawns automatically.
|
||||
* On second detection (after retry): fails fast with a clear error.
|
||||
*/
|
||||
private handleAuthFailureInOutput(run: ProvisioningRun, text: string, source: string): void {
|
||||
private handleAuthFailureInOutput(
|
||||
run: ProvisioningRun,
|
||||
text: string,
|
||||
source: AuthWarningSource
|
||||
): void {
|
||||
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
|
||||
if (!this.isAuthFailureWarning(text)) return;
|
||||
if (!this.isAuthFailureWarning(text, source)) return;
|
||||
|
||||
if (!run.authFailureRetried) {
|
||||
logger.warn(
|
||||
|
|
@ -2231,6 +2372,20 @@ export class TeamProvisioningService {
|
|||
stdoutLineBuf += text;
|
||||
const lines = stdoutLineBuf.split('\n');
|
||||
stdoutLineBuf = lines.pop() ?? '';
|
||||
run.stdoutParserCarry = stdoutLineBuf;
|
||||
const trimmedCarry = stdoutLineBuf.trim();
|
||||
if (!trimmedCarry) {
|
||||
run.stdoutParserCarryIsCompleteJson = false;
|
||||
run.stdoutParserCarryLooksLikeClaudeJson = false;
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(trimmedCarry);
|
||||
run.stdoutParserCarryIsCompleteJson = true;
|
||||
} catch {
|
||||
run.stdoutParserCarryIsCompleteJson = false;
|
||||
}
|
||||
run.stdoutParserCarryLooksLikeClaudeJson = looksLikeClaudeStdoutJsonFragment(trimmedCarry);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
|
@ -2240,7 +2395,7 @@ export class TeamProvisioningService {
|
|||
} catch {
|
||||
// Not valid JSON — check for auth failure in raw text output
|
||||
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
|
||||
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) {
|
||||
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) {
|
||||
this.failProvisioningWithApiError(run, trimmed);
|
||||
}
|
||||
}
|
||||
|
|
@ -2269,7 +2424,7 @@ export class TeamProvisioningService {
|
|||
|
||||
// Detect auth failure early instead of waiting for 5-minute timeout
|
||||
this.handleAuthFailureInOutput(run, text, 'stderr');
|
||||
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
|
||||
if (this.hasApiError(text) && !this.isAuthFailureWarning(text, 'stderr')) {
|
||||
this.failProvisioningWithApiError(run, text);
|
||||
}
|
||||
|
||||
|
|
@ -2294,13 +2449,14 @@ export class TeamProvisioningService {
|
|||
request: TeamCreateRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
): Promise<TeamCreateResponse> {
|
||||
if (this.activeByTeam.has(request.teamName)) {
|
||||
throw new Error('Provisioning already running');
|
||||
const existingProvisioningRunId = this.getProvisioningRunId(request.teamName);
|
||||
if (existingProvisioningRunId) {
|
||||
return { runId: existingProvisioningRunId };
|
||||
}
|
||||
|
||||
// Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock)
|
||||
const pendingKey = `pending-${randomUUID()}`;
|
||||
this.activeByTeam.set(request.teamName, pendingKey);
|
||||
this.provisioningRunByTeam.set(request.teamName, pendingKey);
|
||||
|
||||
try {
|
||||
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
||||
|
|
@ -2331,6 +2487,9 @@ export class TeamProvisioningService {
|
|||
lastClaudeLogStream: null,
|
||||
stdoutLogLineBuf: '',
|
||||
stderrLogLineBuf: '',
|
||||
stdoutParserCarry: '',
|
||||
stdoutParserCarryIsCompleteJson: false,
|
||||
stdoutParserCarryLooksLikeClaudeJson: false,
|
||||
claudeLogsUpdatedAt: undefined,
|
||||
processKilled: false,
|
||||
finalizingByTimeout: false,
|
||||
|
|
@ -2379,7 +2538,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
|
||||
this.runs.set(runId, run);
|
||||
this.activeByTeam.set(request.teamName, runId);
|
||||
this.provisioningRunByTeam.set(request.teamName, runId);
|
||||
run.onProgress(run.progress);
|
||||
|
||||
const prompt = buildProvisioningPrompt(request);
|
||||
|
|
@ -2390,7 +2549,7 @@ export class TeamProvisioningService {
|
|||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
throw error;
|
||||
}
|
||||
const spawnArgs = [
|
||||
|
|
@ -2423,7 +2582,7 @@ export class TeamProvisioningService {
|
|||
});
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -2500,8 +2659,8 @@ export class TeamProvisioningService {
|
|||
return { runId };
|
||||
} catch (error) {
|
||||
// Ensure the per-team lock doesn't get stuck on failures.
|
||||
if (this.activeByTeam.get(request.teamName) === pendingKey) {
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
if (this.provisioningRunByTeam.get(request.teamName) === pendingKey) {
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -2520,13 +2679,14 @@ export class TeamProvisioningService {
|
|||
request: TeamLaunchRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
): Promise<TeamLaunchResponse> {
|
||||
if (this.activeByTeam.has(request.teamName)) {
|
||||
throw new Error('Team is already running');
|
||||
const existingProvisioningRunId = this.getProvisioningRunId(request.teamName);
|
||||
if (existingProvisioningRunId) {
|
||||
return { runId: existingProvisioningRunId };
|
||||
}
|
||||
|
||||
// Set immediately to prevent TOCTOU (defense in depth alongside withTeamLock)
|
||||
const pendingKey = `pending-${randomUUID()}`;
|
||||
this.activeByTeam.set(request.teamName, pendingKey);
|
||||
this.provisioningRunByTeam.set(request.teamName, pendingKey);
|
||||
|
||||
try {
|
||||
// Verify config.json exists — team must already be provisioned
|
||||
|
|
@ -2538,6 +2698,41 @@ export class TeamProvisioningService {
|
|||
if (!configRaw) {
|
||||
throw new Error(`Team "${request.teamName}" not found — config.json does not exist`);
|
||||
}
|
||||
let configProjectPath: string | null = null;
|
||||
try {
|
||||
const parsedConfig = JSON.parse(configRaw) as { projectPath?: unknown };
|
||||
configProjectPath =
|
||||
typeof parsedConfig.projectPath === 'string' && parsedConfig.projectPath.trim().length > 0
|
||||
? path.resolve(parsedConfig.projectPath.trim())
|
||||
: null;
|
||||
} catch {
|
||||
configProjectPath = null;
|
||||
}
|
||||
|
||||
const existingAliveRunId = this.getAliveRunId(request.teamName);
|
||||
if (existingAliveRunId) {
|
||||
const existingRun = this.runs.get(existingAliveRunId);
|
||||
const requestedCwd = path.resolve(request.cwd);
|
||||
const existingRunCwd = this.getRunTrackedCwd(existingRun) ?? configProjectPath;
|
||||
if (existingRun?.child && !existingRun.processKilled && !existingRun.cancelRequested) {
|
||||
if (!existingRunCwd) {
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
throw new Error(
|
||||
`Team "${request.teamName}" is already running, but its cwd could not be determined. ` +
|
||||
'Stop it before launching again.'
|
||||
);
|
||||
}
|
||||
if (existingRunCwd && existingRunCwd !== requestedCwd) {
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
throw new Error(
|
||||
`Team "${request.teamName}" is already running in "${existingRunCwd}". ` +
|
||||
`Stop it before launching with cwd "${request.cwd}".`
|
||||
);
|
||||
}
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
return { runId: existingAliveRunId };
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
members: expectedMemberSpecs,
|
||||
|
|
@ -2656,6 +2851,9 @@ export class TeamProvisioningService {
|
|||
lastClaudeLogStream: null,
|
||||
stdoutLogLineBuf: '',
|
||||
stderrLogLineBuf: '',
|
||||
stdoutParserCarry: '',
|
||||
stdoutParserCarryIsCompleteJson: false,
|
||||
stdoutParserCarryLooksLikeClaudeJson: false,
|
||||
claudeLogsUpdatedAt: undefined,
|
||||
processKilled: false,
|
||||
finalizingByTimeout: false,
|
||||
|
|
@ -2710,7 +2908,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
|
||||
this.runs.set(runId, run);
|
||||
this.activeByTeam.set(request.teamName, runId);
|
||||
this.provisioningRunByTeam.set(request.teamName, runId);
|
||||
run.onProgress(run.progress);
|
||||
|
||||
// Read existing tasks to include in teammate prompts for work resumption
|
||||
|
|
@ -2737,7 +2935,7 @@ export class TeamProvisioningService {
|
|||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
await this.restorePrelaunchConfig(request.teamName);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -2788,7 +2986,7 @@ export class TeamProvisioningService {
|
|||
});
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
await this.restorePrelaunchConfig(request.teamName);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -2866,8 +3064,8 @@ export class TeamProvisioningService {
|
|||
return { runId };
|
||||
} catch (error) {
|
||||
// Clean up pending key if failure occurred before runId was set
|
||||
if (this.activeByTeam.get(request.teamName) === pendingKey) {
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
if (this.provisioningRunByTeam.get(request.teamName) === pendingKey) {
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -2908,7 +3106,7 @@ export class TeamProvisioningService {
|
|||
message: string,
|
||||
attachments?: { data: string; mimeType: string }[]
|
||||
): Promise<void> {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) {
|
||||
throw new Error(`No active process for team "${teamName}"`);
|
||||
}
|
||||
|
|
@ -2962,7 +3160,7 @@ export class TeamProvisioningService {
|
|||
userText: string,
|
||||
userSummary?: string
|
||||
): Promise<void> {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) {
|
||||
throw new Error(`No active process for team "${teamName}"`);
|
||||
}
|
||||
|
|
@ -3012,7 +3210,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const work = (async (): Promise<number> => {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) return 0;
|
||||
const run = this.runs.get(runId);
|
||||
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
||||
|
|
@ -3153,7 +3351,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const work = (async (): Promise<number> => {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) return 0;
|
||||
const run = this.runs.get(runId);
|
||||
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
||||
|
|
@ -3469,14 +3667,14 @@ export class TeamProvisioningService {
|
|||
* Check if a team has an active provisioning run (started but not yet finished).
|
||||
*/
|
||||
hasProvisioningRun(teamName: string): boolean {
|
||||
return this.activeByTeam.has(teamName);
|
||||
return this.provisioningRunByTeam.has(teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a team has a live process.
|
||||
*/
|
||||
isTeamAlive(teamName: string): boolean {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) return false;
|
||||
const run = this.runs.get(runId);
|
||||
return run?.child != null && !run.processKilled && !run.cancelRequested;
|
||||
|
|
@ -3486,7 +3684,7 @@ export class TeamProvisioningService {
|
|||
* Get list of teams with active processes.
|
||||
*/
|
||||
getAliveTeams(): string[] {
|
||||
return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
||||
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
||||
}
|
||||
|
||||
private languageChangeInFlight: Promise<void> = Promise.resolve();
|
||||
|
|
@ -3635,7 +3833,7 @@ export class TeamProvisioningService {
|
|||
* Called from the inbox change handler.
|
||||
*/
|
||||
markMemberOnlineFromInbox(teamName: string, memberName: string): void {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) return;
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) return;
|
||||
|
|
@ -3765,6 +3963,7 @@ export class TeamProvisioningService {
|
|||
this.teamChangeEmitter?.({
|
||||
type: 'lead-message',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: 'cross-team-send',
|
||||
});
|
||||
})
|
||||
|
|
@ -3829,7 +4028,7 @@ export class TeamProvisioningService {
|
|||
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||
// Enrich with leadSessionId if missing — needed for session boundary separators
|
||||
if (!message.leadSessionId) {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (runId) {
|
||||
const run = this.runs.get(runId);
|
||||
if (run?.detectedSessionId) {
|
||||
|
|
@ -3860,7 +4059,7 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
toTeam: string
|
||||
): { conversationId: string; replyToConversationId: string } | null {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) return null;
|
||||
const run = this.runs.get(runId);
|
||||
const hints = run?.activeCrossTeamReplyHints ?? [];
|
||||
|
|
@ -3927,6 +4126,7 @@ export class TeamProvisioningService {
|
|||
this.teamChangeEmitter?.({
|
||||
type: 'lead-message',
|
||||
teamName: run.teamName,
|
||||
runId: run.runId,
|
||||
detail: 'lead-text',
|
||||
});
|
||||
}
|
||||
|
|
@ -3936,13 +4136,14 @@ export class TeamProvisioningService {
|
|||
* Stop the running process for a team. No-op if team is not running.
|
||||
*/
|
||||
stopTeam(teamName: string): void {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
this.activeByTeam.delete(teamName);
|
||||
this.provisioningRunByTeam.delete(teamName);
|
||||
this.aliveRunByTeam.delete(teamName);
|
||||
return;
|
||||
}
|
||||
if (run.processKilled || run.cancelRequested) {
|
||||
|
|
@ -3997,7 +4198,7 @@ export class TeamProvisioningService {
|
|||
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
|
||||
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
|
||||
this.handleAuthFailureInOutput(run, text, 'assistant');
|
||||
if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) {
|
||||
if (this.hasApiError(text) && !this.isAuthFailureWarning(text, 'assistant')) {
|
||||
this.failProvisioningWithApiError(run, text);
|
||||
return;
|
||||
}
|
||||
|
|
@ -4731,7 +4932,7 @@ export class TeamProvisioningService {
|
|||
allow: boolean,
|
||||
message?: string
|
||||
): Promise<void> {
|
||||
const currentRunId = this.activeByTeam.get(teamName);
|
||||
const currentRunId = this.getAliveRunId(teamName);
|
||||
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
|
||||
const run = this.runs.get(currentRunId);
|
||||
if (!run) throw new Error(`Run not found for team "${teamName}"`);
|
||||
|
|
@ -4813,24 +5014,19 @@ export class TeamProvisioningService {
|
|||
)
|
||||
return;
|
||||
|
||||
// Prevent false "ready" when auth failure was printed as assistant text or logs
|
||||
// but the filesystem monitor observed files on disk.
|
||||
const preCompleteText = [
|
||||
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer),
|
||||
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
// Prevent false "ready" when auth failure was printed in CLI output but the filesystem monitor
|
||||
// already observed files on disk. We only re-check stderr plus a trailing non-JSON stdout
|
||||
// fragment here to avoid late false positives from assistant/result stream-json payloads.
|
||||
const preCompleteText = this.getPreCompleteCliErrorText(run);
|
||||
if (
|
||||
preCompleteText &&
|
||||
this.hasApiError(preCompleteText) &&
|
||||
!this.isAuthFailureWarning(preCompleteText)
|
||||
!this.isAuthFailureWarning(preCompleteText, 'pre-complete')
|
||||
) {
|
||||
this.failProvisioningWithApiError(run, preCompleteText);
|
||||
return;
|
||||
}
|
||||
if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) {
|
||||
if (preCompleteText && this.isAuthFailureWarning(preCompleteText, 'pre-complete')) {
|
||||
this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete');
|
||||
return;
|
||||
}
|
||||
|
|
@ -4854,10 +5050,6 @@ export class TeamProvisioningService {
|
|||
);
|
||||
await this.cleanupPrelaunchBackup(run.teamName);
|
||||
|
||||
// Defense in depth: if the CLI (or a stale config) produced auto-suffixed members (alice-2),
|
||||
// clean them up so they don't persist and reappear in the UI.
|
||||
await this.cleanupCliAutoSuffixedMembers(run.teamName);
|
||||
|
||||
// Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate
|
||||
// a stale config.json was present during launch (double-launch race).
|
||||
try {
|
||||
|
|
@ -4890,6 +5082,8 @@ export class TeamProvisioningService {
|
|||
cliLogsTail: extractCliLogsFromRun(run),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
this.provisioningRunByTeam.delete(run.teamName);
|
||||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Pick up any direct messages that arrived before/while reconnecting.
|
||||
|
|
@ -4979,7 +5173,8 @@ export class TeamProvisioningService {
|
|||
cliLogsTail: extractCliLogsFromRun(run),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
// NOTE: do NOT remove from activeByTeam — process stays alive
|
||||
this.provisioningRunByTeam.delete(run.teamName);
|
||||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Pick up any direct messages that arrived during provisioning.
|
||||
|
|
@ -5009,7 +5204,12 @@ export class TeamProvisioningService {
|
|||
run.child.stdout?.removeAllListeners('data');
|
||||
run.child.stderr?.removeAllListeners('data');
|
||||
}
|
||||
this.activeByTeam.delete(run.teamName);
|
||||
if (this.provisioningRunByTeam.get(run.teamName) === run.runId) {
|
||||
this.provisioningRunByTeam.delete(run.teamName);
|
||||
}
|
||||
if (this.aliveRunByTeam.get(run.teamName) === run.runId) {
|
||||
this.aliveRunByTeam.delete(run.teamName);
|
||||
}
|
||||
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
||||
|
|
|
|||
|
|
@ -231,10 +231,11 @@ import type {
|
|||
HunkDecision,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
NotificationTrigger,
|
||||
RejectResult,
|
||||
ReplaceMembersRequest,
|
||||
|
|
@ -921,17 +922,13 @@ const electronAPI: ElectronAPI = {
|
|||
return invokeIpcWithResult<void>(TEAM_KILL_PROCESS, teamName, pid);
|
||||
},
|
||||
getLeadActivity: async (teamName: string) => {
|
||||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||
return result as 'active' | 'idle' | 'offline';
|
||||
return invokeIpcWithResult<LeadActivitySnapshot>(TEAM_LEAD_ACTIVITY, teamName);
|
||||
},
|
||||
getLeadContext: async (teamName: string) => {
|
||||
return invokeIpcWithResult<LeadContextUsage | null>(TEAM_LEAD_CONTEXT, teamName);
|
||||
return invokeIpcWithResult<LeadContextUsageSnapshot>(TEAM_LEAD_CONTEXT, teamName);
|
||||
},
|
||||
getMemberSpawnStatuses: async (teamName: string) => {
|
||||
return invokeIpcWithResult<Record<string, MemberSpawnStatusEntry>>(
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
teamName
|
||||
);
|
||||
return invokeIpcWithResult<MemberSpawnStatusesSnapshot>(TEAM_MEMBER_SPAWN_STATUSES, teamName);
|
||||
},
|
||||
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||
|
|
|
|||
|
|
@ -820,14 +820,14 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
killProcess: async (_teamName: string, _pid: number): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
},
|
||||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||
return 'offline';
|
||||
getLeadActivity: async (_teamName: string) => {
|
||||
return { state: 'offline' as const, runId: null };
|
||||
},
|
||||
getLeadContext: async () => {
|
||||
return null;
|
||||
return { usage: null, runId: null };
|
||||
},
|
||||
getMemberSpawnStatuses: async () => {
|
||||
return {};
|
||||
return { statuses: {}, runId: null };
|
||||
},
|
||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useTabUI } from '@renderer/hooks/useTabUI';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
|
|
@ -95,8 +96,6 @@ interface TeamDetailViewProps {
|
|||
teamName: string;
|
||||
}
|
||||
|
||||
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||||
|
||||
interface CreateTaskDialogState {
|
||||
open: boolean;
|
||||
defaultSubject: string;
|
||||
|
|
@ -275,11 +274,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
removeMember: s.removeMember,
|
||||
updateMemberRole: s.updateMemberRole,
|
||||
launchTeam: s.launchTeam,
|
||||
provisioningError: s.provisioningError,
|
||||
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
|
||||
clearProvisioningError: s.clearProvisioningError,
|
||||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||
),
|
||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||
leadActivityByTeam: s.leadActivityByTeam,
|
||||
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
|
|
@ -39,12 +40,7 @@ import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopove
|
|||
|
||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||
import type {
|
||||
TeamCreateRequest,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamSummaryMember,
|
||||
} from '@shared/types';
|
||||
import type { TeamCreateRequest, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||
|
||||
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||
const base = sourceName.replace(/-\d+$/, '');
|
||||
|
|
@ -129,17 +125,17 @@ function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX
|
|||
function resolveTeamStatus(
|
||||
teamName: string,
|
||||
aliveTeams: string[],
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>,
|
||||
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
|
||||
leadActivityByTeam: Record<string, string>
|
||||
): TeamStatus {
|
||||
if (aliveTeams.includes(teamName)) {
|
||||
return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle';
|
||||
}
|
||||
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||||
for (const run of Object.values(provisioningRuns)) {
|
||||
if (run.teamName === teamName && activeStates.has(run.state)) {
|
||||
return 'provisioning';
|
||||
}
|
||||
if (
|
||||
currentProgress &&
|
||||
['validating', 'spawning', 'monitoring', 'verifying'].includes(currentProgress.state)
|
||||
) {
|
||||
return 'provisioning';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
|
|
@ -223,21 +219,27 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const {
|
||||
connectionMode,
|
||||
createTeam,
|
||||
provisioningError,
|
||||
provisioningErrorByTeam,
|
||||
clearProvisioningError,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
leadActivityByTeam,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
connectionMode: s.connectionMode,
|
||||
createTeam: s.createTeam,
|
||||
provisioningError: s.provisioningError,
|
||||
provisioningErrorByTeam: s.provisioningErrorByTeam,
|
||||
clearProvisioningError: s.clearProvisioningError,
|
||||
provisioningRuns: s.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
|
||||
leadActivityByTeam: s.leadActivityByTeam,
|
||||
}))
|
||||
);
|
||||
const canCreate = electronMode && connectionMode === 'local';
|
||||
const provisioningState = useMemo(
|
||||
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||
);
|
||||
|
||||
// Fetch alive teams on mount and when teams list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -308,7 +310,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const status = resolveTeamStatus(
|
||||
t.teamName,
|
||||
aliveTeams,
|
||||
provisioningRuns,
|
||||
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
|
||||
leadActivityByTeam
|
||||
);
|
||||
const isRunning = status !== 'offline';
|
||||
|
|
@ -357,6 +359,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
currentProjectPath,
|
||||
aliveTeams,
|
||||
filter,
|
||||
currentProvisioningRunIdByTeam,
|
||||
provisioningRuns,
|
||||
leadActivityByTeam,
|
||||
]);
|
||||
|
|
@ -530,7 +533,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<CreateTeamDialog
|
||||
open={showCreateDialog}
|
||||
canCreate={canCreate}
|
||||
provisioningError={provisioningError}
|
||||
provisioningErrorsByTeam={provisioningErrorByTeam}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
existingTeamNames={teams.map((t) => t.teamName)}
|
||||
activeTeams={activeTeams}
|
||||
|
|
@ -642,7 +645,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const status = resolveTeamStatus(
|
||||
team.teamName,
|
||||
aliveTeams,
|
||||
provisioningRuns,
|
||||
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
|
||||
leadActivityByTeam
|
||||
);
|
||||
const teamColorSet = team.color
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
|||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||
import { CheckCircle2, X } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -9,35 +10,20 @@ import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
|
|||
import { STEP_ORDER } from './provisioningSteps';
|
||||
|
||||
import type { ProvisioningStep } from './provisioningSteps';
|
||||
import type { TeamProvisioningProgress } from '@shared/types';
|
||||
|
||||
interface TeamProvisioningBannerProps {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
function findProgressForTeam(
|
||||
runs: Record<string, TeamProvisioningProgress>,
|
||||
teamName: string
|
||||
): TeamProvisioningProgress | null {
|
||||
const entries = Object.values(runs);
|
||||
const matching = entries
|
||||
.filter((r) => r.teamName === teamName)
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return matching[0] ?? null;
|
||||
}
|
||||
|
||||
export const TeamProvisioningBanner = ({
|
||||
teamName,
|
||||
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
|
||||
const { provisioningRuns, cancelProvisioning, teamMembers } = useStore(
|
||||
const { progress, cancelProvisioning, teamMembers } = useStore(
|
||||
useShallow((s) => ({
|
||||
provisioningRuns: s.provisioningRuns,
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
cancelProvisioning: s.cancelProvisioning,
|
||||
teamMembers: s.selectedTeamData?.members,
|
||||
}))
|
||||
);
|
||||
|
||||
const progress = findProgressForTeam(provisioningRuns, teamName);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const prevRunIdRef = useRef(progress?.runId);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
|
|
@ -77,8 +77,8 @@ export interface ActiveTeamRef {
|
|||
interface CreateTeamDialogProps {
|
||||
open: boolean;
|
||||
canCreate: boolean;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError?: () => void;
|
||||
provisioningErrorsByTeam: Record<string, string | null>;
|
||||
clearProvisioningError?: (teamName?: string) => void;
|
||||
existingTeamNames: string[];
|
||||
activeTeams?: ActiveTeamRef[];
|
||||
initialData?: TeamCopyData;
|
||||
|
|
@ -195,7 +195,7 @@ function validateRequest(
|
|||
export const CreateTeamDialog = ({
|
||||
open,
|
||||
canCreate,
|
||||
provisioningError,
|
||||
provisioningErrorsByTeam,
|
||||
clearProvisioningError,
|
||||
existingTeamNames,
|
||||
activeTeams,
|
||||
|
|
@ -223,6 +223,7 @@ export const CreateTeamDialog = ({
|
|||
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
teamName?: string;
|
||||
members?: string;
|
||||
|
|
@ -325,12 +326,15 @@ export const CreateTeamDialog = ({
|
|||
resetUIState();
|
||||
};
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
|
||||
|
||||
// Clear stale provisioning error when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
clearProvisioningError?.();
|
||||
if (open && dialogTeamNameKey) {
|
||||
clearProvisioningError?.(dialogTeamNameKey);
|
||||
}
|
||||
}, [open, clearProvisioningError]);
|
||||
}, [open, clearProvisioningError, dialogTeamNameKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
|
|
@ -346,7 +350,15 @@ export const CreateTeamDialog = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!effectiveCwd) {
|
||||
setPrepareState('idle');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage('Select a working directory to validate the launch environment.');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const requestSeq = ++prepareRequestSeqRef.current;
|
||||
setPrepareState('loading');
|
||||
setPrepareMessage('Warming up CLI environment...');
|
||||
setPrepareWarnings([]);
|
||||
|
|
@ -355,13 +367,14 @@ export const CreateTeamDialog = ({
|
|||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
|
||||
if (cancelled) return;
|
||||
const prepResult: TeamProvisioningPrepareResult =
|
||||
await api.teams.prepareProvisioning(effectiveCwd);
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
||||
setPrepareMessage(prepResult.message);
|
||||
setPrepareWarnings(prepResult.warnings ?? []);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage(
|
||||
|
|
@ -375,7 +388,7 @@ export const CreateTeamDialog = ({
|
|||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, canCreate, launchTeam]);
|
||||
}, [open, canCreate, launchTeam, effectiveCwd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -486,8 +499,6 @@ export const CreateTeamDialog = ({
|
|||
setSelectedProjectPath(projects[0].path);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
|
||||
useFileListCacheWarmer(effectiveCwd || null);
|
||||
|
||||
const description = descriptionDraft.value;
|
||||
|
|
@ -590,7 +601,7 @@ export const CreateTeamDialog = ({
|
|||
return summary;
|
||||
}, [description, teamColor]);
|
||||
|
||||
const activeError = localError ?? provisioningError;
|
||||
const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||
const canOpenExistingTeam =
|
||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
||||
|
|
@ -74,7 +74,7 @@ interface LaunchDialogLaunchMode extends LaunchDialogBase {
|
|||
members: ResolvedTeamMember[];
|
||||
defaultProjectPath?: string;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError?: () => void;
|
||||
clearProvisioningError?: (teamName?: string) => void;
|
||||
activeTeams?: ActiveTeamRef[];
|
||||
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
||||
}
|
||||
|
|
@ -178,6 +178,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
|
||||
// Advanced CLI section state (with localStorage persistence)
|
||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
||||
|
|
@ -332,14 +333,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// Launch-only effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
|
||||
// Clear stale provisioning error when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !isLaunch) return;
|
||||
(props as LaunchDialogLaunchMode).clearProvisioningError?.();
|
||||
(props as LaunchDialogLaunchMode).clearProvisioningError?.(effectiveTeamName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, isLaunch]);
|
||||
}, [open, isLaunch, effectiveTeamName]);
|
||||
|
||||
// Warm up CLI on open (launch mode only)
|
||||
// Warm up CLI for the currently selected working directory (launch mode only).
|
||||
useEffect(() => {
|
||||
if (!open || !isLaunch) return;
|
||||
|
||||
|
|
@ -352,20 +355,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return;
|
||||
}
|
||||
|
||||
if (!effectiveCwd) {
|
||||
setPrepareState('idle');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage('Select a working directory to validate the launch environment.');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const requestSeq = ++prepareRequestSeqRef.current;
|
||||
setPrepareState('loading');
|
||||
setPrepareMessage('Warming up CLI environment...');
|
||||
setPrepareWarnings([]);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
|
||||
if (cancelled) return;
|
||||
const prepResult: TeamProvisioningPrepareResult =
|
||||
await api.teams.prepareProvisioning(effectiveCwd);
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
||||
setPrepareMessage(prepResult.message);
|
||||
setPrepareWarnings(prepResult.warnings ?? []);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage(
|
||||
|
|
@ -377,7 +389,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isLaunch]);
|
||||
}, [open, isLaunch, effectiveCwd]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared effects: projects
|
||||
|
|
@ -447,8 +459,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setSelectedProjectPath(projects[0].path);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
|
||||
// Pre-warm file list cache so @-mention file search is instant
|
||||
useFileListCacheWarmer(effectiveCwd || null);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -122,13 +123,7 @@ export const MessageComposer = ({
|
|||
const displayName = s.selectedTeamData?.config.name ?? teamName;
|
||||
return nameColorSet(displayName).border;
|
||||
});
|
||||
const isProvisioning = useStore((s) =>
|
||||
Object.values(s.provisioningRuns).some(
|
||||
(run) =>
|
||||
run.teamName === teamName &&
|
||||
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
|
||||
)
|
||||
);
|
||||
const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName));
|
||||
const draft = useComposerDraft(teamName);
|
||||
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
|
|
|||
|
|
@ -355,8 +355,39 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
if (api.teams?.onTeamChange) {
|
||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
||||
const isIgnoredRuntimeRun = (() => {
|
||||
if (!event.runId) return false;
|
||||
return useStore.getState().ignoredProvisioningRunIds[event.runId] === event.teamName;
|
||||
})();
|
||||
if (isIgnoredRuntimeRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isStaleRuntimeEvent = (() => {
|
||||
if (!event.runId) return false;
|
||||
const currentRunId = useStore.getState().currentRuntimeRunIdByTeam[event.teamName];
|
||||
return currentRunId != null && currentRunId !== event.runId;
|
||||
})();
|
||||
|
||||
const seedCurrentRunIdIfMissing = (): void => {
|
||||
if (!event.runId) return;
|
||||
const currentRunId = useStore.getState().currentRuntimeRunIdByTeam[event.teamName];
|
||||
if (currentRunId == null) {
|
||||
useStore.setState((prev) => ({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
...prev.currentRuntimeRunIdByTeam,
|
||||
[event.teamName]: event.runId ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Immediate in-memory update for lead activity — no filesystem refresh needed
|
||||
if (event.type === 'lead-activity' && event.detail) {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
const nextActivity = event.detail as 'active' | 'idle' | 'offline';
|
||||
useStore.setState((prev) => {
|
||||
const nextState: Partial<typeof prev> = {
|
||||
|
|
@ -379,6 +410,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
if (nextActivity === 'offline') {
|
||||
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
||||
delete nextState.leadContextByTeam[event.teamName];
|
||||
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
|
||||
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
|
||||
}
|
||||
|
||||
return nextState as typeof prev;
|
||||
|
|
@ -388,6 +421,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
// Immediate in-memory update for lead context usage — no filesystem refresh needed
|
||||
if (event.type === 'lead-context' && event.detail) {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
try {
|
||||
const ctx = JSON.parse(event.detail) as LeadContextUsage;
|
||||
useStore.setState((prev) => ({
|
||||
|
|
@ -402,6 +439,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
|
||||
// Member spawn status change: fetch updated spawn statuses for the team.
|
||||
if (event.type === 'member-spawn') {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
void useStore.getState().fetchMemberSpawnStatuses(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
|
@ -409,6 +450,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
// Live lead-message events: only refresh the visible team detail, not team/task lists.
|
||||
// This keeps the refresh lightweight and prevents one noisy team from starving another.
|
||||
if (event.type === 'lead-message') {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,18 @@ function sleep(ms: number): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||||
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']);
|
||||
|
||||
function isPendingProvisioningRunId(runId: string): boolean {
|
||||
return runId.startsWith('pending:');
|
||||
}
|
||||
|
||||
function isUnknownProvisioningRunError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes('Unknown runId');
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<T>((_resolve, reject) => {
|
||||
|
|
@ -61,7 +67,11 @@ async function pollProvisioningStatus(
|
|||
if (TERMINAL_PROVISIONING_STATES.has(progress.state)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isUnknownProvisioningRunError(error)) {
|
||||
state.clearMissingProvisioningRun(runId);
|
||||
return;
|
||||
}
|
||||
// best-effort polling; don't fail launch because status fetch is flaky
|
||||
}
|
||||
await sleep(delayMs);
|
||||
|
|
@ -342,6 +352,10 @@ export interface TeamSlice {
|
|||
lastSendMessageResult: SendMessageResult | null;
|
||||
reviewActionError: string | null;
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
||||
currentProvisioningRunIdByTeam: Record<string, string | null>;
|
||||
currentRuntimeRunIdByTeam: Record<string, string | null>;
|
||||
/** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */
|
||||
ignoredProvisioningRunIds: Record<string, string>;
|
||||
/**
|
||||
* Per-team lower bound for provisioning progress timestamps.
|
||||
* Used to ignore late progress events from a previous run after stop→launch.
|
||||
|
|
@ -352,9 +366,8 @@ export interface TeamSlice {
|
|||
/** Per-team per-member spawn statuses during team provisioning/launch. */
|
||||
memberSpawnStatusesByTeam: Record<string, Record<string, MemberSpawnStatusEntry>>;
|
||||
fetchMemberSpawnStatuses: (teamName: string) => Promise<void>;
|
||||
activeProvisioningRunId: string | null;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError: () => void;
|
||||
provisioningErrorByTeam: Record<string, string | null>;
|
||||
clearProvisioningError: (teamName?: string) => void;
|
||||
/** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */
|
||||
launchParamsByTeam: Record<string, TeamLaunchParams>;
|
||||
kanbanFilterQuery: string | null;
|
||||
|
|
@ -455,6 +468,7 @@ export interface TeamSlice {
|
|||
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
||||
cancelProvisioning: (runId: string) => Promise<void>;
|
||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
clearMissingProvisioningRun: (runId: string) => void;
|
||||
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
|
||||
subscribeProvisioningProgress: () => void;
|
||||
unsubscribeProvisioningProgress: () => void;
|
||||
|
|
@ -479,6 +493,22 @@ export interface TeamSlice {
|
|||
// --- Per-team launch params persistence ---
|
||||
const LAUNCH_PARAMS_PREFIX = 'team:launchParams:';
|
||||
|
||||
export function getCurrentProvisioningProgressForTeam(
|
||||
state: Pick<TeamSlice, 'currentProvisioningRunIdByTeam' | 'provisioningRuns'>,
|
||||
teamName: string
|
||||
): TeamProvisioningProgress | null {
|
||||
const currentRunId = state.currentProvisioningRunIdByTeam[teamName];
|
||||
return currentRunId ? (state.provisioningRuns[currentRunId] ?? null) : null;
|
||||
}
|
||||
|
||||
export function isTeamProvisioningActive(
|
||||
state: Pick<TeamSlice, 'currentProvisioningRunIdByTeam' | 'provisioningRuns'>,
|
||||
teamName: string
|
||||
): boolean {
|
||||
const current = getCurrentProvisioningProgressForTeam(state, teamName);
|
||||
return current != null && ACTIVE_PROVISIONING_STATES.has(current.state);
|
||||
}
|
||||
|
||||
function loadAllLaunchParams(): Record<string, TeamLaunchParams> {
|
||||
const result: Record<string, TeamLaunchParams> = {};
|
||||
try {
|
||||
|
|
@ -581,23 +611,51 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
crossTeamTargetsLoading: false,
|
||||
reviewActionError: null,
|
||||
provisioningRuns: {},
|
||||
currentProvisioningRunIdByTeam: {},
|
||||
currentRuntimeRunIdByTeam: {},
|
||||
ignoredProvisioningRunIds: {},
|
||||
provisioningStartedAtFloorByTeam: {},
|
||||
leadActivityByTeam: {},
|
||||
leadContextByTeam: {},
|
||||
memberSpawnStatusesByTeam: {},
|
||||
activeProvisioningRunId: null,
|
||||
provisioningError: null,
|
||||
clearProvisioningError: () => set({ provisioningError: null }),
|
||||
provisioningErrorByTeam: {},
|
||||
clearProvisioningError: (teamName?: string) =>
|
||||
set((state) => {
|
||||
if (!teamName) {
|
||||
return { provisioningErrorByTeam: {} };
|
||||
}
|
||||
|
||||
if (!(teamName in state.provisioningErrorByTeam)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextErrors = { ...state.provisioningErrorByTeam };
|
||||
delete nextErrors[teamName];
|
||||
return { provisioningErrorByTeam: nextErrors };
|
||||
}),
|
||||
launchParamsByTeam: loadAllLaunchParams(),
|
||||
fetchMemberSpawnStatuses: async (teamName: string) => {
|
||||
if (!api.teams?.getMemberSpawnStatuses) return;
|
||||
try {
|
||||
const statuses = await api.teams.getMemberSpawnStatuses(teamName);
|
||||
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
|
||||
set((prev) => ({
|
||||
memberSpawnStatusesByTeam: {
|
||||
...prev.memberSpawnStatusesByTeam,
|
||||
[teamName]: statuses,
|
||||
},
|
||||
...(snapshot.runId != null &&
|
||||
prev.currentRuntimeRunIdByTeam[teamName] != null &&
|
||||
prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId
|
||||
? {}
|
||||
: {
|
||||
currentRuntimeRunIdByTeam:
|
||||
snapshot.runId == null
|
||||
? prev.currentRuntimeRunIdByTeam
|
||||
: {
|
||||
...prev.currentRuntimeRunIdByTeam,
|
||||
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
...prev.memberSpawnStatusesByTeam,
|
||||
[teamName]: snapshot.statuses,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
} catch {
|
||||
// ignore — spawn statuses are best-effort
|
||||
|
|
@ -925,11 +983,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
} catch (error) {
|
||||
// If provisioning is in progress for this team, stay in loading state;
|
||||
// file watcher / progress callback will refresh once config is written.
|
||||
const isProvisioning = Object.values(get().provisioningRuns).some(
|
||||
(run) =>
|
||||
run.teamName === teamName &&
|
||||
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
|
||||
);
|
||||
const isProvisioning = isTeamProvisioningActive(get(), teamName);
|
||||
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
// IPC can report provisioning state explicitly.
|
||||
|
|
@ -1296,7 +1350,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
delete cleaned[runId];
|
||||
}
|
||||
}
|
||||
return { provisioningError: null, provisioningRuns: cleaned };
|
||||
const nextErrors = { ...state.provisioningErrorByTeam };
|
||||
delete nextErrors[request.teamName];
|
||||
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
|
||||
delete nextSpawnStatuses[request.teamName];
|
||||
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
||||
delete nextRuntimeRunIdByTeam[request.teamName];
|
||||
const nextIgnoredRunIds = Object.fromEntries(
|
||||
Object.entries(state.ignoredProvisioningRunIds).filter(
|
||||
([, teamName]) => teamName !== request.teamName
|
||||
)
|
||||
);
|
||||
return {
|
||||
provisioningRuns: cleaned,
|
||||
provisioningErrorByTeam: nextErrors,
|
||||
memberSpawnStatusesByTeam: nextSpawnStatuses,
|
||||
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
||||
ignoredProvisioningRunIds: nextIgnoredRunIds,
|
||||
};
|
||||
});
|
||||
|
||||
// Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed.
|
||||
|
|
@ -1313,7 +1384,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
updatedAt: floor,
|
||||
},
|
||||
},
|
||||
activeProvisioningRunId: pendingRunId,
|
||||
currentProvisioningRunIdByTeam: {
|
||||
...state.currentProvisioningRunIdByTeam,
|
||||
[request.teamName]: pendingRunId,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
if (typeof api.teams.createTeam !== 'function') {
|
||||
|
|
@ -1340,9 +1414,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}));
|
||||
}
|
||||
|
||||
set({
|
||||
activeProvisioningRunId: response.runId,
|
||||
provisioningError: null,
|
||||
set((state) => {
|
||||
const nextRuns = { ...state.provisioningRuns };
|
||||
const pendingRun = nextRuns[pendingRunId];
|
||||
if (pendingRun) {
|
||||
delete nextRuns[pendingRunId];
|
||||
nextRuns[response.runId] = { ...pendingRun, runId: response.runId };
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: {
|
||||
...state.currentProvisioningRunIdByTeam,
|
||||
[request.teamName]: response.runId,
|
||||
},
|
||||
currentRuntimeRunIdByTeam: {
|
||||
...state.currentRuntimeRunIdByTeam,
|
||||
[request.teamName]: response.runId,
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
await get().getProvisioningStatus(response.runId);
|
||||
|
|
@ -1352,13 +1441,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
void pollProvisioningStatus(get, response.runId);
|
||||
return response.runId;
|
||||
} catch (error) {
|
||||
set({
|
||||
provisioningError:
|
||||
error instanceof IpcError
|
||||
const message =
|
||||
error instanceof IpcError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create team',
|
||||
: 'Failed to create team';
|
||||
set((state) => {
|
||||
const nextRuns = { ...state.provisioningRuns };
|
||||
delete nextRuns[pendingRunId];
|
||||
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
|
||||
if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
|
||||
delete nextCurrentRunIdByTeam[request.teamName];
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
|
||||
provisioningErrorByTeam: {
|
||||
...state.provisioningErrorByTeam,
|
||||
[request.teamName]: message,
|
||||
},
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -1385,7 +1488,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
delete cleaned[runId];
|
||||
}
|
||||
}
|
||||
return { provisioningError: null, provisioningRuns: cleaned };
|
||||
const nextErrors = { ...state.provisioningErrorByTeam };
|
||||
delete nextErrors[request.teamName];
|
||||
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
|
||||
delete nextSpawnStatuses[request.teamName];
|
||||
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
||||
delete nextRuntimeRunIdByTeam[request.teamName];
|
||||
const nextIgnoredRunIds = Object.fromEntries(
|
||||
Object.entries(state.ignoredProvisioningRunIds).filter(
|
||||
([, teamName]) => teamName !== request.teamName
|
||||
)
|
||||
);
|
||||
return {
|
||||
provisioningRuns: cleaned,
|
||||
provisioningErrorByTeam: nextErrors,
|
||||
memberSpawnStatusesByTeam: nextSpawnStatuses,
|
||||
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
||||
ignoredProvisioningRunIds: nextIgnoredRunIds,
|
||||
};
|
||||
});
|
||||
|
||||
// Optimistic progress entry: ensures banner shows even if IPC progress is delayed/missed.
|
||||
|
|
@ -1402,7 +1522,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
updatedAt: floor,
|
||||
},
|
||||
},
|
||||
activeProvisioningRunId: pendingRunId,
|
||||
currentProvisioningRunIdByTeam: {
|
||||
...state.currentProvisioningRunIdByTeam,
|
||||
[request.teamName]: pendingRunId,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
||||
|
|
@ -1432,9 +1555,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
});
|
||||
}
|
||||
|
||||
set({
|
||||
activeProvisioningRunId: response.runId,
|
||||
provisioningError: null,
|
||||
set((state) => {
|
||||
const nextRuns = { ...state.provisioningRuns };
|
||||
const pendingRun = nextRuns[pendingRunId];
|
||||
if (pendingRun) {
|
||||
delete nextRuns[pendingRunId];
|
||||
nextRuns[response.runId] = { ...pendingRun, runId: response.runId };
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: {
|
||||
...state.currentProvisioningRunIdByTeam,
|
||||
[request.teamName]: response.runId,
|
||||
},
|
||||
currentRuntimeRunIdByTeam: {
|
||||
...state.currentRuntimeRunIdByTeam,
|
||||
[request.teamName]: response.runId,
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
await get().getProvisioningStatus(response.runId);
|
||||
|
|
@ -1444,13 +1582,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
void pollProvisioningStatus(get, response.runId);
|
||||
return response.runId;
|
||||
} catch (error) {
|
||||
set({
|
||||
provisioningError:
|
||||
error instanceof IpcError
|
||||
const message =
|
||||
error instanceof IpcError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to launch team',
|
||||
: 'Failed to launch team';
|
||||
set((state) => {
|
||||
const nextRuns = { ...state.provisioningRuns };
|
||||
delete nextRuns[pendingRunId];
|
||||
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
|
||||
if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
|
||||
delete nextCurrentRunIdByTeam[request.teamName];
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
|
||||
provisioningErrorByTeam: {
|
||||
...state.provisioningErrorByTeam,
|
||||
[request.teamName]: message,
|
||||
},
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -1464,43 +1616,139 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
return progress;
|
||||
},
|
||||
|
||||
clearMissingProvisioningRun: (runId: string) => {
|
||||
set((state) => {
|
||||
const existing = state.provisioningRuns[runId];
|
||||
if (!existing) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextRuns = { ...state.provisioningRuns };
|
||||
delete nextRuns[runId];
|
||||
|
||||
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
|
||||
const isCanonicalRun = nextCurrentRunIdByTeam[existing.teamName] === runId;
|
||||
if (isCanonicalRun) {
|
||||
delete nextCurrentRunIdByTeam[existing.teamName];
|
||||
}
|
||||
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
||||
if (nextRuntimeRunIdByTeam[existing.teamName] === runId) {
|
||||
delete nextRuntimeRunIdByTeam[existing.teamName];
|
||||
}
|
||||
const nextIgnoredRunIds = {
|
||||
...state.ignoredProvisioningRunIds,
|
||||
[runId]: existing.teamName,
|
||||
};
|
||||
|
||||
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
|
||||
if (isCanonicalRun) {
|
||||
delete nextSpawnStatuses[existing.teamName];
|
||||
}
|
||||
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
|
||||
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
||||
memberSpawnStatusesByTeam: nextSpawnStatuses,
|
||||
ignoredProvisioningRunIds: nextIgnoredRunIds,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
cancelProvisioning: async (runId: string) => {
|
||||
await unwrapIpc('team:cancelProvisioning', () => api.teams.cancelProvisioning(runId));
|
||||
},
|
||||
|
||||
onProvisioningProgress: (progress: TeamProvisioningProgress) => {
|
||||
if (get().ignoredProvisioningRunIds[progress.runId] === progress.teamName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const floor = get().provisioningStartedAtFloorByTeam[progress.teamName];
|
||||
if (floor && progress.startedAt < floor) {
|
||||
// Ignore late progress from a previous run (common after stop→launch).
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRunId = get().currentProvisioningRunIdByTeam[progress.teamName];
|
||||
const existingProgress = get().provisioningRuns[progress.runId];
|
||||
const isDuplicateProgress =
|
||||
existingProgress?.updatedAt === progress.updatedAt &&
|
||||
existingProgress?.state === progress.state &&
|
||||
existingProgress?.message === progress.message &&
|
||||
existingProgress?.error === progress.error &&
|
||||
existingProgress?.pid === progress.pid;
|
||||
if (isDuplicateProgress && currentRunId === progress.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
|
||||
const previousCurrentRunId = nextCurrentRunIdByTeam[progress.teamName];
|
||||
let isCanonicalRun = false;
|
||||
if (!previousCurrentRunId || previousCurrentRunId === progress.runId) {
|
||||
nextCurrentRunIdByTeam[progress.teamName] = progress.runId;
|
||||
isCanonicalRun = true;
|
||||
} else if (
|
||||
isPendingProvisioningRunId(previousCurrentRunId) &&
|
||||
!isPendingProvisioningRunId(progress.runId)
|
||||
) {
|
||||
delete nextRuns[previousCurrentRunId];
|
||||
nextCurrentRunIdByTeam[progress.teamName] = progress.runId;
|
||||
isCanonicalRun = true;
|
||||
}
|
||||
if (!previousCurrentRunId) {
|
||||
isCanonicalRun = true;
|
||||
}
|
||||
if (!isCanonicalRun) {
|
||||
if (!(progress.runId in state.provisioningRuns)) {
|
||||
return {};
|
||||
}
|
||||
const nextRuns = { ...state.provisioningRuns };
|
||||
delete nextRuns[progress.runId];
|
||||
return { provisioningRuns: nextRuns };
|
||||
}
|
||||
|
||||
const nextRuns: Record<string, TeamProvisioningProgress> = {
|
||||
...state.provisioningRuns,
|
||||
[progress.runId]: progress,
|
||||
};
|
||||
// When real progress arrives, drop any pending placeholder runs for this team.
|
||||
if (!isPendingProvisioningRunId(progress.runId)) {
|
||||
for (const [runId, run] of Object.entries(nextRuns)) {
|
||||
if (isPendingProvisioningRunId(runId) && run.teamName === progress.teamName) {
|
||||
delete nextRuns[runId];
|
||||
}
|
||||
for (const [runId, run] of Object.entries(nextRuns)) {
|
||||
if (runId !== progress.runId && run.teamName === progress.teamName) {
|
||||
delete nextRuns[runId];
|
||||
}
|
||||
}
|
||||
|
||||
const nextErrors = { ...state.provisioningErrorByTeam };
|
||||
if (progress.state === 'failed') {
|
||||
nextErrors[progress.teamName] = progress.error ?? progress.message;
|
||||
} else {
|
||||
delete nextErrors[progress.teamName];
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
activeProvisioningRunId: progress.runId,
|
||||
provisioningError: progress.state === 'failed' ? (progress.error ?? null) : null,
|
||||
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
|
||||
currentRuntimeRunIdByTeam: {
|
||||
...state.currentRuntimeRunIdByTeam,
|
||||
[progress.teamName]: progress.runId,
|
||||
},
|
||||
provisioningErrorByTeam: nextErrors,
|
||||
};
|
||||
});
|
||||
|
||||
if (progress.state === 'ready' || progress.state === 'disconnected') {
|
||||
const isCanonicalRun =
|
||||
get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId;
|
||||
|
||||
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
|
||||
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
|
||||
set((prev) => {
|
||||
const next = { ...prev.memberSpawnStatusesByTeam };
|
||||
delete next[progress.teamName];
|
||||
return { memberSpawnStatusesByTeam: next };
|
||||
});
|
||||
}
|
||||
|
||||
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
|
||||
void get().fetchTeams();
|
||||
// If the user already opened the team tab, reload team data now that
|
||||
// config.json is guaranteed to exist.
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ import type {
|
|||
CrossTeamSendResult,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ReplaceMembersRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
|
|
@ -483,9 +483,9 @@ export interface TeamsAPI {
|
|||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
|
||||
getMemberSpawnStatuses: (teamName: string) => Promise<Record<string, MemberSpawnStatusEntry>>;
|
||||
getLeadActivity: (teamName: string) => Promise<LeadActivitySnapshot>;
|
||||
getLeadContext: (teamName: string) => Promise<LeadContextUsageSnapshot>;
|
||||
getMemberSpawnStatuses: (teamName: string) => Promise<MemberSpawnStatusesSnapshot>;
|
||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||
|
|
|
|||
|
|
@ -427,6 +427,11 @@ export interface CreateTaskRequest {
|
|||
|
||||
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
export interface LeadActivitySnapshot {
|
||||
state: LeadActivityState;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
export interface LeadContextUsage {
|
||||
/** Total tokens currently in context (input + cache_creation + cache_read) */
|
||||
currentTokens: number;
|
||||
|
|
@ -438,6 +443,16 @@ export interface LeadContextUsage {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LeadContextUsageSnapshot {
|
||||
usage: LeadContextUsage | null;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
export interface MemberSpawnStatusesSnapshot {
|
||||
statuses: Record<string, MemberSpawnStatusEntry>;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
type:
|
||||
| 'config'
|
||||
|
|
@ -449,6 +464,7 @@ export interface TeamChangeEvent {
|
|||
| 'process'
|
||||
| 'member-spawn';
|
||||
teamName: string;
|
||||
runId?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
||||
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
||||
(service as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
|
||||
teamName,
|
||||
runId
|
||||
);
|
||||
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, run);
|
||||
|
||||
return run;
|
||||
|
|
|
|||
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;
|
||||
|
||||
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
||||
(service as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
|
||||
teamName,
|
||||
runId
|
||||
);
|
||||
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, {
|
||||
runId,
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { createTeamSlice } from '../../../src/renderer/store/slices/teamSlice';
|
||||
import {
|
||||
createTeamSlice,
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
} from '../../../src/renderer/store/slices/teamSlice';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getData: vi.fn(),
|
||||
createTeam: vi.fn(),
|
||||
getProvisioningStatus: vi.fn(),
|
||||
getMemberSpawnStatuses: vi.fn(),
|
||||
cancelProvisioning: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
requestReview: vi.fn(),
|
||||
|
|
@ -23,6 +27,7 @@ vi.mock('@renderer/api', () => ({
|
|||
getData: hoisted.getData,
|
||||
createTeam: hoisted.createTeam,
|
||||
getProvisioningStatus: hoisted.getProvisioningStatus,
|
||||
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
|
||||
cancelProvisioning: hoisted.cancelProvisioning,
|
||||
sendMessage: hoisted.sendMessage,
|
||||
requestReview: hoisted.requestReview,
|
||||
|
|
@ -97,6 +102,7 @@ describe('teamSlice actions', () => {
|
|||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
|
||||
hoisted.cancelProvisioning.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
|
@ -366,4 +372,245 @@ describe('teamSlice actions', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provisioning run scoping', () => {
|
||||
it('rolls back optimistic pending run on early createTeam failure', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.createTeam.mockRejectedValue(new Error('create failed'));
|
||||
|
||||
await expect(
|
||||
store.getState().createTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
members: [],
|
||||
})
|
||||
).rejects.toThrow('create failed');
|
||||
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
|
||||
expect(Object.values(store.getState().provisioningRuns)).toHaveLength(0);
|
||||
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
|
||||
});
|
||||
|
||||
it('keeps the current run pinned when stale progress from another run arrives', () => {
|
||||
const store = createSliceStore();
|
||||
const startedAt = '2026-03-12T10:00:00.000Z';
|
||||
|
||||
store.getState().onProvisioningProgress({
|
||||
runId: 'run-current',
|
||||
teamName: 'my-team',
|
||||
state: 'spawning',
|
||||
message: 'Current run',
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
});
|
||||
|
||||
store.getState().onProvisioningProgress({
|
||||
runId: 'run-stale',
|
||||
teamName: 'my-team',
|
||||
state: 'failed',
|
||||
message: 'Stale failure',
|
||||
error: 'stale',
|
||||
startedAt: '2026-03-12T10:00:01.000Z',
|
||||
updatedAt: '2026-03-12T10:00:01.000Z',
|
||||
});
|
||||
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
|
||||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-current');
|
||||
expect(store.getState().provisioningErrorByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().provisioningRuns['run-stale']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears orphaned runs when polling reports Unknown runId', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
provisioningRuns: {
|
||||
'pending:my-team:1': {
|
||||
runId: 'pending:my-team:1',
|
||||
teamName: 'my-team',
|
||||
state: 'spawning',
|
||||
message: 'Launching',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
currentProvisioningRunIdByTeam: {
|
||||
'my-team': 'pending:my-team:1',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': {
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.getState().clearMissingProvisioningRun('pending:my-team:1');
|
||||
|
||||
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team');
|
||||
});
|
||||
|
||||
it('does not resurrect a cleared missing run when late progress arrives', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
provisioningRuns: {
|
||||
'pending:my-team:1': {
|
||||
runId: 'pending:my-team:1',
|
||||
teamName: 'my-team',
|
||||
state: 'spawning',
|
||||
message: 'Launching',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
currentProvisioningRunIdByTeam: {
|
||||
'my-team': 'pending:my-team:1',
|
||||
},
|
||||
});
|
||||
|
||||
store.getState().clearMissingProvisioningRun('pending:my-team:1');
|
||||
store.getState().onProvisioningProgress({
|
||||
runId: 'pending:my-team:1',
|
||||
teamName: 'my-team',
|
||||
state: 'monitoring',
|
||||
message: 'Late zombie progress',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
updatedAt: '2026-03-12T10:00:02.000Z',
|
||||
});
|
||||
|
||||
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps runtime run id separate from provisioning run id when fetching spawn statuses', async () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
currentProvisioningRunIdByTeam: {
|
||||
'my-team': 'provisioning-run',
|
||||
},
|
||||
});
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue({
|
||||
runId: 'runtime-run',
|
||||
statuses: {
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
},
|
||||
});
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('provisioning-run');
|
||||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('runtime-run');
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
provisioningRuns: {
|
||||
'run-current': {
|
||||
runId: 'run-current',
|
||||
teamName: 'my-team',
|
||||
state: 'monitoring',
|
||||
message: 'Current run',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
},
|
||||
'run-stale': {
|
||||
runId: 'run-stale',
|
||||
teamName: 'my-team',
|
||||
state: 'failed',
|
||||
message: 'Stale run',
|
||||
startedAt: '2026-03-12T10:00:01.000Z',
|
||||
updatedAt: '2026-03-12T10:00:01.000Z',
|
||||
},
|
||||
},
|
||||
currentProvisioningRunIdByTeam: {
|
||||
'my-team': 'run-current',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': {
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.getState().clearMissingProvisioningRun('run-stale');
|
||||
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the terminal canonical run pinned and does not fall back to other team runs', () => {
|
||||
const store = createSliceStore();
|
||||
const startedAt = '2026-03-12T10:00:00.000Z';
|
||||
|
||||
store.getState().onProvisioningProgress({
|
||||
runId: 'run-current',
|
||||
teamName: 'my-team',
|
||||
state: 'monitoring',
|
||||
message: 'Current run',
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
});
|
||||
|
||||
store.getState().onProvisioningProgress({
|
||||
runId: 'run-current',
|
||||
teamName: 'my-team',
|
||||
state: 'disconnected',
|
||||
message: 'Disconnected',
|
||||
startedAt,
|
||||
updatedAt: '2026-03-12T10:00:01.000Z',
|
||||
});
|
||||
|
||||
store.setState((state: ReturnType<typeof store.getState>) => ({
|
||||
provisioningRuns: {
|
||||
...state.provisioningRuns,
|
||||
'run-stale': {
|
||||
runId: 'run-stale',
|
||||
teamName: 'my-team',
|
||||
state: 'failed',
|
||||
message: 'Stale run',
|
||||
startedAt: '2026-03-12T10:00:02.000Z',
|
||||
updatedAt: '2026-03-12T10:00:02.000Z',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-current');
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
||||
expect(getCurrentProvisioningProgressForTeam(store.getState(), 'my-team')).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: 'run-current',
|
||||
state: 'disconnected',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fall back to a team-wide latest run when no current run is pinned', () => {
|
||||
expect(
|
||||
getCurrentProvisioningProgressForTeam(
|
||||
{
|
||||
currentProvisioningRunIdByTeam: {},
|
||||
provisioningRuns: {
|
||||
'run-stale': {
|
||||
runId: 'run-stale',
|
||||
teamName: 'my-team',
|
||||
state: 'failed',
|
||||
message: 'Stale run',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
updatedAt: '2026-03-12T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
'my-team'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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