From 30fb2501d37a4412a54368c05d7e6de3a3fac962 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 15:23:41 +0300 Subject: [PATCH] Persist launch-state and duplicate no-op handling --- src/main/services/team/TeamConfigReader.ts | 78 ++-- .../services/team/TeamLaunchStateEvaluator.ts | 426 +++++++++++++++++ .../services/team/TeamLaunchStateStore.ts | 56 +++ .../services/team/TeamProvisioningService.ts | 442 ++++++++++++------ src/main/workers/team-fs-worker.ts | 72 +-- .../components/team/TeamDetailView.tsx | 15 +- src/renderer/components/team/TeamListView.tsx | 35 +- .../team/TeamProvisioningBanner.tsx | 71 +-- src/renderer/store/index.ts | 18 + src/renderer/store/slices/teamSlice.ts | 33 +- src/shared/types/team.ts | 62 +++ src/shared/utils/toolSummary.ts | 46 ++ 12 files changed, 1102 insertions(+), 252 deletions(-) create mode 100644 src/main/services/team/TeamLaunchStateEvaluator.ts create mode 100644 src/main/services/team/TeamLaunchStateStore.ts diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 7e440103..58276f94 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -12,6 +12,7 @@ import * as path from 'path'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; +import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; @@ -27,16 +28,20 @@ const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; -interface PartialLaunchStateSummary { - partialLaunchFailure: true; - expectedMemberCount: number; - confirmedMemberCount: number; - missingMembers: string[]; +interface LaunchStateSummary { + partialLaunchFailure?: true; + expectedMemberCount?: number; + confirmedMemberCount?: number; + missingMembers?: string[]; + teamLaunchState?: TeamSummary['teamLaunchState']; + launchUpdatedAt?: string; + confirmedCount?: number; + pendingCount?: number; + failedCount?: number; + runtimeAlivePendingCount?: number; } -async function readPartialLaunchStateSummary( - teamDir: string -): Promise { +async function readLaunchStateSummary(teamDir: string): Promise { const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE); try { const stat = await fs.promises.stat(launchStatePath); @@ -44,38 +49,31 @@ async function readPartialLaunchStateSummary( return null; } const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS); - const parsed = JSON.parse(raw) as { - state?: unknown; - expectedMembers?: unknown; - confirmedMembers?: unknown; - missingMembers?: unknown; - }; - if (parsed.state !== 'partial_launch_failure') { - return null; - } - const expectedMembers = Array.isArray(parsed.expectedMembers) - ? parsed.expectedMembers.filter( - (name): name is string => typeof name === 'string' && name.trim().length > 0 - ) - : []; - const confirmedMembers = Array.isArray(parsed.confirmedMembers) - ? parsed.confirmedMembers.filter( - (name): name is string => typeof name === 'string' && name.trim().length > 0 - ) - : []; - const missingMembers = Array.isArray(parsed.missingMembers) - ? parsed.missingMembers.filter( - (name): name is string => typeof name === 'string' && name.trim().length > 0 - ) - : []; - if (expectedMembers.length === 0 || missingMembers.length === 0) { + const snapshot = normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw)); + if (!snapshot) { return null; } + const missingMembers = snapshot.expectedMembers.filter((name) => { + const member = snapshot.members[name]; + return member?.launchState === 'failed_to_start'; + }); return { - partialLaunchFailure: true, - expectedMemberCount: expectedMembers.length, - confirmedMemberCount: confirmedMembers.length, - missingMembers, + ...(snapshot.teamLaunchState === 'partial_failure' + ? { partialLaunchFailure: true as const } + : {}), + ...(snapshot.expectedMembers.length > 0 + ? { expectedMemberCount: snapshot.expectedMembers.length } + : {}), + ...(snapshot.summary.confirmedCount > 0 + ? { confirmedMemberCount: snapshot.summary.confirmedCount } + : {}), + ...(missingMembers.length > 0 ? { missingMembers } : {}), + teamLaunchState: snapshot.teamLaunchState, + launchUpdatedAt: snapshot.updatedAt, + confirmedCount: snapshot.summary.confirmedCount, + pendingCount: snapshot.summary.pendingCount, + failedCount: snapshot.summary.failedCount, + runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, }; } catch { return null; @@ -340,8 +338,8 @@ export class TeamConfigReader { } const members = Array.from(memberMap.values()); - const partialLaunchState = - (await readPartialLaunchStateSummary(teamDir)) ?? + const launchStateSummary = + (await readLaunchStateSummary(teamDir)) ?? (() => { if ( !leadSessionId || @@ -377,7 +375,7 @@ export class TeamConfigReader { ...(projectPathHistory ? { projectPathHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}), ...(deletedAt ? { deletedAt } : {}), - ...(partialLaunchState ?? {}), + ...(launchStateSummary ?? {}), }; return summary; } catch { diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts new file mode 100644 index 00000000..99f8035b --- /dev/null +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -0,0 +1,426 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + MemberLaunchState, + MemberSpawnLivenessSource, + MemberSpawnStatusEntry, + PersistedTeamLaunchMemberSources, + PersistedTeamLaunchMemberState, + PersistedTeamLaunchPhase, + PersistedTeamLaunchSnapshot, + PersistedTeamLaunchSummary, + TeamLaunchAggregateState, +} from '@shared/types'; + +interface LegacyPartialLaunchStateFile { + version?: unknown; + state?: unknown; + updatedAt?: unknown; + leadSessionId?: unknown; + expectedMembers?: unknown; + confirmedMembers?: unknown; + missingMembers?: unknown; +} + +type RuntimeMemberSpawnState = Pick< + MemberSpawnStatusEntry, + | 'launchState' + | 'status' + | 'error' + | 'hardFailureReason' + | 'livenessSource' + | 'agentToolAccepted' + | 'runtimeAlive' + | 'bootstrapConfirmed' + | 'hardFailure' + | 'firstSpawnAcceptedAt' + | 'lastHeartbeatAt' + | 'updatedAt' +>; + +function normalizeMemberName(name: string): string { + return name.trim(); +} + +function buildDiagnostics( + member: Pick< + PersistedTeamLaunchMemberState, + 'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' | 'sources' + > +): string[] { + const diagnostics: string[] = []; + if (member.agentToolAccepted) diagnostics.push('spawn accepted'); + if (member.runtimeAlive) diagnostics.push('runtime alive'); + if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); + if (member.runtimeAlive && !member.bootstrapConfirmed) diagnostics.push('waiting for bootstrap'); + if (member.hardFailureReason) + diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); + if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); + if (member.sources?.configDrift) diagnostics.push('config drift detected'); + return diagnostics; +} + +export function deriveTeamLaunchAggregateState( + summary: PersistedTeamLaunchSummary +): TeamLaunchAggregateState { + if (summary.failedCount > 0) { + return 'partial_failure'; + } + if (summary.pendingCount > 0) { + return 'partial_pending'; + } + return 'clean_success'; +} + +export function summarizePersistedLaunchMembers( + expectedMembers: readonly string[], + members: Record +): PersistedTeamLaunchSummary { + let confirmedCount = 0; + let pendingCount = 0; + let failedCount = 0; + let runtimeAlivePendingCount = 0; + const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean); + + for (const memberName of normalizedExpected) { + const entry = members[memberName]; + if (!entry) { + pendingCount += 1; + continue; + } + if (entry.launchState === 'confirmed_alive') { + confirmedCount += 1; + continue; + } + if (entry.launchState === 'failed_to_start') { + failedCount += 1; + continue; + } + pendingCount += 1; + if (entry.runtimeAlive) { + runtimeAlivePendingCount += 1; + } + } + + return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; +} + +function deriveMemberLaunchState( + member: Pick< + PersistedTeamLaunchMemberState, + 'hardFailure' | 'bootstrapConfirmed' | 'runtimeAlive' | 'agentToolAccepted' + > +): MemberLaunchState { + if (member.hardFailure) { + return 'failed_to_start'; + } + if (member.bootstrapConfirmed) { + return 'confirmed_alive'; + } + if (member.runtimeAlive || member.agentToolAccepted) { + return 'runtime_pending_bootstrap'; + } + return 'starting'; +} + +function toBoolean(value: unknown): boolean { + return value === true; +} + +function normalizeSources(value: unknown): PersistedTeamLaunchMemberSources | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const source = value as Record; + const normalized: PersistedTeamLaunchMemberSources = { + inboxHeartbeat: toBoolean(source.inboxHeartbeat), + nativeHeartbeat: toBoolean(source.nativeHeartbeat), + processAlive: toBoolean(source.processAlive), + configRegistered: toBoolean(source.configRegistered), + configDrift: toBoolean(source.configDrift), + hardFailureSignal: toBoolean(source.hardFailureSignal), + duplicateRespawnBlocked: toBoolean(source.duplicateRespawnBlocked), + }; + return Object.values(normalized).some(Boolean) ? normalized : undefined; +} + +function normalizePersistedMemberState( + memberName: string, + value: unknown, + updatedAtFallback: string +): PersistedTeamLaunchMemberState | null { + if (!value || typeof value !== 'object') { + return null; + } + const parsed = value as Record; + const normalizedName = normalizeMemberName(memberName); + if (!normalizedName || normalizedName === 'user' || isLeadMember({ name: normalizedName })) { + return null; + } + const next: PersistedTeamLaunchMemberState = { + name: normalizedName, + launchState: 'starting', + agentToolAccepted: toBoolean(parsed.agentToolAccepted), + runtimeAlive: toBoolean(parsed.runtimeAlive), + bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed), + hardFailure: toBoolean(parsed.hardFailure), + hardFailureReason: + typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 + ? parsed.hardFailureReason.trim() + : undefined, + firstSpawnAcceptedAt: + typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined, + lastHeartbeatAt: + typeof parsed.lastHeartbeatAt === 'string' ? parsed.lastHeartbeatAt : undefined, + lastRuntimeAliveAt: + typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined, + lastEvaluatedAt: + typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback, + sources: normalizeSources(parsed.sources), + diagnostics: Array.isArray(parsed.diagnostics) + ? parsed.diagnostics.filter( + (item): item is string => typeof item === 'string' && item.trim().length > 0 + ) + : undefined, + }; + const launchState = + parsed.launchState === 'starting' || + parsed.launchState === 'runtime_pending_bootstrap' || + parsed.launchState === 'confirmed_alive' || + parsed.launchState === 'failed_to_start' + ? parsed.launchState + : deriveMemberLaunchState(next); + next.launchState = launchState; + next.diagnostics = next.diagnostics?.length ? next.diagnostics : buildDiagnostics(next); + return next; +} + +export function createPersistedLaunchSnapshot(params: { + teamName: string; + expectedMembers: readonly string[]; + leadSessionId?: string; + launchPhase?: PersistedTeamLaunchPhase; + members?: Record; + updatedAt?: string; +}): PersistedTeamLaunchSnapshot { + const updatedAt = params.updatedAt ?? new Date().toISOString(); + const expectedMembers = Array.from( + new Set( + params.expectedMembers + .map(normalizeMemberName) + .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) + ) + ); + const members = params.members ?? {}; + const summary = summarizePersistedLaunchMembers(expectedMembers, members); + return { + version: 2, + teamName: params.teamName, + updatedAt, + ...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}), + launchPhase: params.launchPhase ?? 'active', + expectedMembers, + members, + summary, + teamLaunchState: deriveTeamLaunchAggregateState(summary), + }; +} + +export function snapshotFromRuntimeMemberStatuses(params: { + teamName: string; + expectedMembers: readonly string[]; + leadSessionId?: string; + launchPhase?: PersistedTeamLaunchPhase; + statuses: Record; + updatedAt?: string; +}): PersistedTeamLaunchSnapshot { + const updatedAt = params.updatedAt ?? new Date().toISOString(); + const members: Record = {}; + + for (const expected of params.expectedMembers) { + const name = normalizeMemberName(expected); + if (!name || name === 'user' || isLeadMember({ name })) continue; + const runtime = params.statuses[name]; + const sources: PersistedTeamLaunchMemberSources = {}; + if (runtime?.livenessSource === 'heartbeat') { + sources.nativeHeartbeat = true; + sources.inboxHeartbeat = true; + } + if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) { + sources.processAlive = true; + } + const entry: PersistedTeamLaunchMemberState = { + name, + launchState: runtime?.launchState ?? 'starting', + agentToolAccepted: runtime?.agentToolAccepted === true, + runtimeAlive: runtime?.runtimeAlive === true, + bootstrapConfirmed: runtime?.bootstrapConfirmed === true, + hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', + hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, + firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, + lastHeartbeatAt: runtime?.lastHeartbeatAt, + lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined, + lastEvaluatedAt: runtime?.updatedAt ?? updatedAt, + sources: Object.values(sources).some(Boolean) ? sources : undefined, + diagnostics: undefined, + }; + entry.launchState = deriveMemberLaunchState(entry); + entry.diagnostics = buildDiagnostics(entry); + members[name] = entry; + } + + return createPersistedLaunchSnapshot({ + teamName: params.teamName, + expectedMembers: params.expectedMembers, + leadSessionId: params.leadSessionId, + launchPhase: params.launchPhase, + members, + updatedAt, + }); +} + +export function snapshotToMemberSpawnStatuses( + snapshot: PersistedTeamLaunchSnapshot | null +): Record { + if (!snapshot) return {}; + const statuses: Record = {}; + for (const memberName of snapshot.expectedMembers) { + const entry = snapshot.members[memberName]; + if (!entry) continue; + let status: MemberSpawnStatusEntry['status'] = 'offline'; + let livenessSource: MemberSpawnLivenessSource | undefined; + if (entry.launchState === 'failed_to_start') { + status = 'error'; + } else if (entry.launchState === 'confirmed_alive') { + status = 'online'; + livenessSource = 'heartbeat'; + } else if (entry.launchState === 'runtime_pending_bootstrap') { + status = entry.runtimeAlive ? 'online' : 'waiting'; + livenessSource = entry.runtimeAlive ? 'process' : undefined; + } else { + status = entry.agentToolAccepted ? 'waiting' : 'spawning'; + } + statuses[memberName] = { + status, + launchState: entry.launchState, + error: entry.hardFailure ? entry.hardFailureReason : undefined, + hardFailureReason: entry.hardFailureReason, + livenessSource, + agentToolAccepted: entry.agentToolAccepted, + runtimeAlive: entry.runtimeAlive, + bootstrapConfirmed: entry.bootstrapConfirmed, + hardFailure: entry.hardFailure, + firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, + lastHeartbeatAt: entry.lastHeartbeatAt, + updatedAt: entry.lastEvaluatedAt, + }; + } + return statuses; +} + +export function normalizePersistedLaunchSnapshot( + teamName: string, + parsed: unknown +): PersistedTeamLaunchSnapshot | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const maybeLegacy = parsed as LegacyPartialLaunchStateFile; + if (maybeLegacy.state === 'partial_launch_failure') { + const expectedMembers = Array.isArray(maybeLegacy.expectedMembers) + ? maybeLegacy.expectedMembers.filter( + (name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0 + ) + : []; + const confirmedMembers = Array.isArray(maybeLegacy.confirmedMembers) + ? maybeLegacy.confirmedMembers.filter( + (name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0 + ) + : []; + const missingMembers = Array.isArray(maybeLegacy.missingMembers) + ? maybeLegacy.missingMembers.filter( + (name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0 + ) + : []; + if (expectedMembers.length === 0 || missingMembers.length === 0) { + return null; + } + const updatedAt = + typeof maybeLegacy.updatedAt === 'string' ? maybeLegacy.updatedAt : new Date().toISOString(); + const members: Record = {}; + for (const name of expectedMembers) { + const failed = missingMembers.includes(name); + const confirmed = confirmedMembers.includes(name); + const entry: PersistedTeamLaunchMemberState = { + name, + launchState: failed ? 'failed_to_start' : confirmed ? 'confirmed_alive' : 'starting', + agentToolAccepted: true, + runtimeAlive: confirmed, + bootstrapConfirmed: confirmed, + hardFailure: failed, + hardFailureReason: failed + ? 'Legacy partial launch marker reported teammate missing.' + : undefined, + lastEvaluatedAt: updatedAt, + diagnostics: undefined, + }; + entry.diagnostics = buildDiagnostics(entry); + members[name] = entry; + } + return createPersistedLaunchSnapshot({ + teamName, + expectedMembers, + leadSessionId: + typeof maybeLegacy.leadSessionId === 'string' && maybeLegacy.leadSessionId.trim().length > 0 + ? maybeLegacy.leadSessionId.trim() + : undefined, + launchPhase: 'finished', + members, + updatedAt, + }); + } + + const record = parsed as Record; + if (record.version !== 2) { + return null; + } + const expectedMembers = Array.isArray(record.expectedMembers) + ? record.expectedMembers.filter( + (name): name is string => typeof name === 'string' && normalizeMemberName(name).length > 0 + ) + : []; + const updatedAt = + typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0 + ? record.updatedAt + : new Date().toISOString(); + const normalizedMembers: Record = {}; + const rawMembers = + record.members && typeof record.members === 'object' + ? (record.members as Record) + : {}; + for (const [memberName, value] of Object.entries(rawMembers)) { + const normalized = normalizePersistedMemberState(memberName, value, updatedAt); + if (!normalized) continue; + normalizedMembers[normalized.name] = normalized; + } + return createPersistedLaunchSnapshot({ + teamName: + typeof record.teamName === 'string' && record.teamName.trim().length > 0 + ? record.teamName.trim() + : teamName, + expectedMembers, + leadSessionId: + typeof record.leadSessionId === 'string' && record.leadSessionId.trim().length > 0 + ? record.leadSessionId.trim() + : undefined, + launchPhase: + record.launchPhase === 'active' || + record.launchPhase === 'finished' || + record.launchPhase === 'reconciled' + ? record.launchPhase + : 'finished', + members: normalizedMembers, + updatedAt, + }); +} diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts new file mode 100644 index 00000000..0f154c5b --- /dev/null +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -0,0 +1,56 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; +import { atomicWriteAsync } from './atomicWrite'; + +import type { PersistedTeamLaunchSnapshot } from '@shared/types'; + +const logger = createLogger('Service:TeamLaunchStateStore'); +const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +const MAX_LAUNCH_STATE_BYTES = 256 * 1024; + +export function getTeamLaunchStatePath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_STATE_FILE); +} + +export class TeamLaunchStateStore { + async read(teamName: string): Promise { + const targetPath = getTeamLaunchStatePath(teamName); + try { + const stat = await fs.promises.stat(targetPath); + if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) { + return null; + } + const raw = await fs.promises.readFile(targetPath, 'utf8'); + return normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw)); + } catch { + return null; + } + } + + async write(teamName: string, snapshot: PersistedTeamLaunchSnapshot): Promise { + try { + await atomicWriteAsync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify(snapshot, null, 2)}\n` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to persist launch-state: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + async clear(teamName: string): Promise { + try { + await fs.promises.rm(getTeamLaunchStatePath(teamName), { force: true }); + } catch { + // best-effort + } + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index bffa2cfb..822a857e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -49,6 +49,7 @@ import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/ut import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, + parseAgentToolResultStatus, extractToolResultPreview, formatToolSummaryFromCalls, } from '@shared/utils/toolSummary'; @@ -71,6 +72,12 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; +import { TeamLaunchStateStore } from './TeamLaunchStateStore'; +import { + createPersistedLaunchSnapshot, + snapshotFromRuntimeMemberStatuses, + snapshotToMemberSpawnStatuses, +} from './TeamLaunchStateEvaluator'; import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv, @@ -110,9 +117,12 @@ import type { MemberSpawnStatus, MemberSpawnLivenessSource, MemberSpawnStatusEntry, + PersistedTeamLaunchPhase, + PersistedTeamLaunchSummary, TeamChangeEvent, TeamCreateRequest, TeamCreateResponse, + TeamLaunchAggregateState, TeamLaunchRequest, TeamLaunchResponse, TeamProvisioningPrepareResult, @@ -154,7 +164,6 @@ const STALL_WARNING_THRESHOLD_MS = 20_000; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; -const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_send', 'cross_team_list_targets', @@ -383,16 +392,6 @@ type ValidConfigProbeResult = | { ok: true; location: TeamsBaseLocation; configPath: string } | { ok: false }; -interface PartialLaunchStateFile { - version: 1; - state: 'partial_launch_failure'; - updatedAt: string; - leadSessionId?: string; - expectedMembers: string[]; - confirmedMembers: string[]; - missingMembers: string[]; -} - function getTeamsBasePathsToProbe(): { location: TeamsBaseLocation; basePath: string }[] { const configured = getTeamsBasePath(); const defaultBase = path.join(getAutoDetectedClaudeBasePath(), 'teams'); @@ -436,10 +435,6 @@ function looksLikeClaudeStdoutJsonFragment(text: string): boolean { ); } -function getTeamLaunchStatePath(teamName: string): string { - return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_STATE_FILE); -} - interface ProvisioningRun { runId: string; teamName: string; @@ -649,19 +644,18 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { } function deriveMemberLaunchState(entry: { - status: MemberSpawnStatus; agentToolAccepted?: boolean; runtimeAlive?: boolean; bootstrapConfirmed?: boolean; hardFailure?: boolean; }): MemberLaunchState { - if (entry.hardFailure || entry.status === 'error') { + if (entry.hardFailure) { return 'failed_to_start'; } if (entry.bootstrapConfirmed) { return 'confirmed_alive'; } - if (entry.runtimeAlive || entry.agentToolAccepted || entry.status === 'waiting') { + if (entry.runtimeAlive || entry.agentToolAccepted) { return 'runtime_pending_bootstrap'; } return 'starting'; @@ -861,6 +855,20 @@ function buildTeammateAgentBlockReminder(): string { ].join('\n'); } +function extractHeartbeatTimestamp(text: string, fallback?: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed) return fallback?.trim() || undefined; + try { + const parsed = JSON.parse(trimmed) as { timestamp?: unknown }; + if (typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0) { + return parsed.timestamp.trim(); + } + } catch { + // Best-effort only. Non-JSON teammate messages still use the inbox timestamp fallback. + } + return fallback?.trim() || undefined; +} + function extractBootstrapFailureReason(text: string): string | null { const trimmed = text.trim(); if (!trimmed) return null; @@ -2026,6 +2034,7 @@ export class TeamProvisioningService { string, NativeSameTeamFingerprint[] >(); + private readonly launchStateStore = new TeamLaunchStateStore(); private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; @@ -2659,7 +2668,14 @@ export class TeamProvisioningService { this.setMemberSpawnStatus(run, from, 'error', reason); continue; } - this.setMemberSpawnStatus(run, from, 'online', undefined, 'heartbeat'); + this.setMemberSpawnStatus( + run, + from, + 'online', + undefined, + 'heartbeat', + extractHeartbeatTimestamp(message.text, message.timestamp) + ); } } @@ -3011,10 +3027,22 @@ export class TeamProvisioningService { if (isError) { const resultPreview = extractToolResultPreview(resultContent); this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview); - } else { + } else if (active.toolName === 'Agent') { + const parsedStatus = parseAgentToolResultStatus(resultContent); + if (parsedStatus?.status === 'duplicate_skipped') { + const detail = + parsedStatus.reason === 'already_running' + ? 'duplicate spawn skipped - already running' + : 'duplicate spawn skipped - bootstrap pending'; + this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail); + return; + } + // Agent tool_result only confirms that the runtime accepted the spawn. // The teammate becomes truly "online" only after the first inbox heartbeat. this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + } else { + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); } } } @@ -3089,7 +3117,8 @@ export class TeamProvisioningService { memberName: string, status: MemberSpawnStatus, error?: string, - livenessSource?: MemberSpawnLivenessSource + livenessSource?: MemberSpawnLivenessSource, + heartbeatAt?: string ): void { const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); const updatedAt = nowIso(); @@ -3115,7 +3144,7 @@ export class TeamProvisioningService { next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; if (livenessSource === 'heartbeat') { next.bootstrapConfirmed = true; - next.lastHeartbeatAt = updatedAt; + next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt; } next.hardFailure = false; next.error = undefined; @@ -3158,7 +3187,7 @@ export class TeamProvisioningService { memberName, 'spawn accepted, waiting for bootstrap' ); - } else if (status === 'online' && livenessSource === 'heartbeat') { + } else if (status === 'online' && livenessSource === 'heartbeat' && !prev.bootstrapConfirmed) { this.appendMemberBootstrapDiagnostic( run, memberName, @@ -3184,38 +3213,67 @@ export class TeamProvisioningService { runId: run.runId, detail: memberName, }); + if (run.isLaunch) { + void this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); + } } /** * Get current member spawn statuses for a team. * Returns a map of memberName → MemberSpawnStatusEntry. */ - getMemberSpawnStatuses(teamName: string): { + async getMemberSpawnStatuses(teamName: string): Promise<{ statuses: Record; runId: string | null; - } { + teamLaunchState?: TeamLaunchAggregateState; + launchPhase?: PersistedTeamLaunchPhase; + expectedMembers?: string[]; + updatedAt?: string; + summary?: PersistedTeamLaunchSummary; + source?: 'live' | 'persisted' | 'merged'; + }> { const runId = this.getTrackedRunId(teamName); - if (!runId) return { statuses: {}, runId: null }; - const run = this.runs.get(runId); - if (!run) return { statuses: {}, runId: null }; - const result: Record = {}; - for (const [name, entry] of run.memberSpawnStatuses) { - result[name] = { - status: entry.status, - launchState: entry.launchState, - error: entry.error, - hardFailureReason: entry.hardFailureReason, - livenessSource: entry.livenessSource, - agentToolAccepted: entry.agentToolAccepted, - runtimeAlive: entry.runtimeAlive, - bootstrapConfirmed: entry.bootstrapConfirmed, - hardFailure: entry.hardFailure, - firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, - lastHeartbeatAt: entry.lastHeartbeatAt, - updatedAt: entry.updatedAt, - }; + if (!runId) { + return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => ({ + statuses, + runId: null, + teamLaunchState: snapshot?.teamLaunchState, + launchPhase: snapshot?.launchPhase, + expectedMembers: snapshot?.expectedMembers, + updatedAt: snapshot?.updatedAt, + summary: snapshot?.summary, + source: snapshot ? 'persisted' : 'persisted', + })); } - return { statuses: result, runId }; + const run = this.runs.get(runId); + if (!run) { + return { statuses: {}, runId: null, source: 'persisted' }; + } + + await this.refreshMemberSpawnStatusesFromLeadInbox(run); + await this.auditMemberSpawnStatuses(run); + await this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); + + const persisted = await this.launchStateStore.read(teamName); + const liveSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName: run.teamName, + expectedMembers: run.expectedMembers, + leadSessionId: run.detectedSessionId ?? undefined, + launchPhase: run.provisioningComplete ? 'finished' : 'active', + statuses: this.buildRuntimeSpawnStatusRecord(run), + }); + const snapshot = persisted ?? liveSnapshot; + const statuses = snapshotToMemberSpawnStatuses(snapshot); + return { + statuses, + runId, + teamLaunchState: snapshot.teamLaunchState, + launchPhase: snapshot.launchPhase, + expectedMembers: snapshot.expectedMembers, + updatedAt: snapshot.updatedAt, + summary: snapshot.summary, + source: persisted ? 'merged' : 'live', + }; } private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string { @@ -4290,7 +4348,7 @@ export class TeamProvisioningService { this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); - await this.clearPartialLaunchState(request.teamName); + await this.clearPersistedLaunchState(request.teamName); const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs); const promptSize = getPromptSizeSummary(prompt); @@ -5886,6 +5944,19 @@ export class TeamProvisioningService { } // Only track spawns for this team if (teamName !== run.teamName) continue; + const existing = run.memberSpawnStatuses.get(memberName); + if ( + existing && + !existing.hardFailure && + (existing.bootstrapConfirmed || existing.runtimeAlive || existing.agentToolAccepted) + ) { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'respawn blocked as duplicate — teammate already alive or bootstrap pending' + ); + continue; + } this.setMemberSpawnStatus(run, memberName, 'spawning'); const toolUseId = typeof part.id === 'string' ? part.id.trim() : ''; if (toolUseId) { @@ -5940,6 +6011,8 @@ export class TeamProvisioningService { return; } + const liveAgentNames = this.getLiveTeamAgentNames(run.teamName); + // Flag any expected member not found in config.json (excluding the lead) for (const expected of run.expectedMembers) { const current = run.memberSpawnStatuses.get(expected); @@ -5957,10 +6030,8 @@ export class TeamProvisioningService { }); const runtimeAlive = - matchedRuntimeNames.length > 0 && - matchedRuntimeNames.some((runtimeName) => - this.hasLiveTeamAgentProcess(run.teamName, runtimeName) - ); + liveAgentNames.has(expected) || + matchedRuntimeNames.some((runtimeName) => liveAgentNames.has(runtimeName)); // A teammate may intentionally stay silent after bootstrap. If Claude Code // registered the runtime and the OS process is still alive, treat it as @@ -6004,8 +6075,12 @@ export class TeamProvisioningService { } private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { + return this.getLiveTeamAgentNames(teamName).has(memberName); + } + + private getLiveTeamAgentNames(teamName: string): Set { if (process.platform === 'win32') { - return false; + return new Set(); } let output = ''; @@ -6015,24 +6090,26 @@ export class TeamProvisioningService { stdio: ['ignore', 'pipe', 'ignore'], }); } catch { - return false; + return new Set(); } const teamMarker = `--team-name ${teamName}`; - const memberMarker = `--agent-id ${memberName}@${teamName}`; - - return output.split('\n').some((line) => { + const names = new Set(); + for (const line of output.split('\n')) { const trimmed = line.trim(); - return trimmed.includes(teamMarker) && trimmed.includes(memberMarker); - }); + if (!trimmed.includes(teamMarker)) continue; + const match = trimmed.match(/--agent-id\s+([^\s@]+)@/); + if (!match) continue; + const agentName = match[1]?.trim(); + if (agentName) { + names.add(agentName); + } + } + return names; } - private async clearPartialLaunchState(teamName: string): Promise { - try { - await fs.promises.rm(getTeamLaunchStatePath(teamName), { force: true }); - } catch { - // best-effort - } + private async clearPersistedLaunchState(teamName: string): Promise { + await this.launchStateStore.clear(teamName); } private getFailedSpawnMembers( @@ -6076,95 +6153,190 @@ export class TeamProvisioningService { return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; } - private async persistPartialLaunchState(run: ProvisioningRun): Promise { + private buildRuntimeSpawnStatusRecord( + run: ProvisioningRun + ): Record { + const statuses: Record = {}; + for (const expected of run.expectedMembers) { + statuses[expected] = + run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry(); + } + return statuses; + } + + private async persistLaunchStateSnapshot( + run: ProvisioningRun, + launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete + ? 'finished' + : 'active' + ): Promise { if (!run.isLaunch || !run.expectedMembers || run.expectedMembers.length === 0) { if (run.isLaunch) { - await this.clearPartialLaunchState(run.teamName); + await this.clearPersistedLaunchState(run.teamName); } return; } - const expectedMembers = Array.from( - new Set( - run.expectedMembers - .map((name) => name.trim()) - .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) - ) - ); - if (expectedMembers.length === 0) { - await this.clearPartialLaunchState(run.teamName); + const snapshot = snapshotFromRuntimeMemberStatuses({ + teamName: run.teamName, + expectedMembers: run.expectedMembers, + leadSessionId: run.detectedSessionId ?? undefined, + launchPhase, + statuses: this.buildRuntimeSpawnStatusRecord(run), + }); + + if (snapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { + await this.clearPersistedLaunchState(run.teamName); return; } - const configPath = path.join(getTeamsBasePath(), run.teamName, 'config.json'); - let registeredMembers = new Set(); - let leadSessionId: string | undefined; + await this.launchStateStore.write(run.teamName, snapshot); + } + + private async reconcilePersistedLaunchState(teamName: string): Promise<{ + snapshot: ReturnType | null; + statuses: Record; + }> { + const persisted = await this.launchStateStore.read(teamName); + if (!persisted) { + return { snapshot: null, statuses: {} }; + } + + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + let configMembers = new Set(); + let leadName = 'team-lead'; try { const raw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, maxBytes: TEAM_CONFIG_MAX_BYTES, }); - if (!raw) { - await this.clearPartialLaunchState(run.teamName); - return; + if (raw) { + const config = JSON.parse(raw) as { + members?: { name?: string; agentType?: string }[]; + }; + leadName = config.members?.find((member) => isLeadMember(member))?.name?.trim() || leadName; + configMembers = new Set( + (config.members ?? []) + .map((member) => (typeof member?.name === 'string' ? member.name.trim() : '')) + .filter((name) => name.length > 0 && !isLeadMember({ name })) + ); } - const config = JSON.parse(raw) as { - leadSessionId?: unknown; - members?: { name?: unknown }[]; - }; - leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : undefined; - registeredMembers = new Set( - (config.members ?? []) - .map((member) => (typeof member?.name === 'string' ? member.name.trim() : '')) - .filter((name) => name.length > 0 && !isLeadMember({ name })) - ); } catch { - await this.clearPartialLaunchState(run.teamName); - return; + // best-effort } - const inboxNames = await this.inboxReader - .listInboxNames(run.teamName) - .catch(() => [] as string[]); - const confirmedMembers = Array.from( - new Set( - [...registeredMembers, ...inboxNames] - .map((name) => name.trim()) - .filter((name) => expectedMembers.includes(name)) - ) - ); - const missingMembers = expectedMembers.filter((name) => !confirmedMembers.includes(name)); - - if (missingMembers.length === 0 || confirmedMembers.length === 0) { - await this.clearPartialLaunchState(run.teamName); - return; - } - - const payload: PartialLaunchStateFile = { - version: 1, - state: 'partial_launch_failure', - updatedAt: new Date().toISOString(), - ...(leadSessionId ? { leadSessionId } : {}), - expectedMembers, - confirmedMembers, - missingMembers, - }; - + let leadInboxMessages: Awaited> = []; try { - await atomicWriteAsync( - getTeamLaunchStatePath(run.teamName), - JSON.stringify(payload, null, 2) - ); - } catch (error) { - logger.warn( - `[${run.teamName}] Failed to persist partial launch state: ${ - error instanceof Error ? error.message : String(error) - }` - ); + leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + } catch { + // best-effort } + + const liveAgentNames = this.getLiveTeamAgentNames(teamName); + const nextMembers = { ...persisted.members }; + const now = nowIso(); + for (const expected of persisted.expectedMembers) { + const current = nextMembers[expected] ?? { + name: expected, + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: now, + }; + const matchedRuntimeNames = [...configMembers].filter((name) => { + if (name === expected) return true; + const parsed = parseNumericSuffixName(name); + return parsed !== null && parsed.suffix >= 2 && parsed.base === expected; + }); + const runtimeAlive = + liveAgentNames.has(expected) || + matchedRuntimeNames.some((runtimeName) => liveAgentNames.has(runtimeName)); + const heartbeatMessage = leadInboxMessages.find((message) => { + if (typeof message.from !== 'string' || message.from.trim() !== expected) return false; + if (typeof message.text !== 'string' || message.text.trim().length === 0) return false; + const firstAcceptedAt = current.firstSpawnAcceptedAt + ? Date.parse(current.firstSpawnAcceptedAt) + : NaN; + const messageTs = Date.parse(message.timestamp); + if ( + Number.isFinite(firstAcceptedAt) && + Number.isFinite(messageTs) && + messageTs < firstAcceptedAt + ) { + return false; + } + return true; + }); + const heartbeatReason = heartbeatMessage + ? extractBootstrapFailureReason(heartbeatMessage.text) + : null; + current.runtimeAlive = runtimeAlive; + current.lastRuntimeAliveAt = runtimeAlive ? now : current.lastRuntimeAliveAt; + current.sources = { + ...(current.sources ?? {}), + processAlive: runtimeAlive || undefined, + configRegistered: matchedRuntimeNames.length > 0 || undefined, + configDrift: + heartbeatMessage != null && matchedRuntimeNames.length === 0 + ? true + : current.sources?.configDrift, + inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat, + }; + if (heartbeatReason) { + current.hardFailure = true; + current.hardFailureReason = heartbeatReason; + current.sources.hardFailureSignal = true; + } else if (heartbeatMessage) { + current.bootstrapConfirmed = true; + current.lastHeartbeatAt = heartbeatMessage.timestamp; + current.hardFailure = false; + current.hardFailureReason = undefined; + } + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const graceExpired = + current.agentToolAccepted === true && + Number.isFinite(acceptedAtMs) && + Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; + if ( + !current.bootstrapConfirmed && + !current.runtimeAlive && + !current.hardFailure && + graceExpired + ) { + current.hardFailure = true; + current.hardFailureReason = + current.hardFailureReason ?? 'Teammate did not join within the launch grace window.'; + } + current.launchState = deriveMemberLaunchState(current); + current.lastEvaluatedAt = now; + nextMembers[expected] = { + ...current, + diagnostics: undefined, + }; + } + + const reconciled = createPersistedLaunchSnapshot({ + teamName, + expectedMembers: persisted.expectedMembers, + leadSessionId: persisted.leadSessionId, + launchPhase: persisted.launchPhase === 'active' ? 'active' : 'reconciled', + members: nextMembers, + updatedAt: now, + }); + + if (reconciled.teamLaunchState === 'clean_success') { + await this.clearPersistedLaunchState(teamName); + return { snapshot: null, statuses: {} }; + } + + await this.launchStateStore.write(teamName, reconciled); + return { + snapshot: reconciled, + statuses: snapshotToMemberSpawnStatuses(reconciled), + }; } private captureSendMessages(run: ProvisioningRun, content: Record[]): void { @@ -8411,7 +8583,7 @@ export class TeamProvisioningService { // Audit: flag any expected member not registered in config.json after launch. await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.auditMemberSpawnStatuses(run); - await this.persistPartialLaunchState(run); + await this.persistLaunchStateSnapshot(run, 'finished'); const failedSpawnMembers = this.getFailedSpawnMembers(run); const launchSummary = this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; @@ -8570,7 +8742,7 @@ export class TeamProvisioningService { // Audit: flag any expected member not registered in config.json after provisioning. await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.auditMemberSpawnStatuses(run); - await this.clearPartialLaunchState(run.teamName); + await this.persistLaunchStateSnapshot(run, 'finished'); const failedSpawnMembers = this.getFailedSpawnMembers(run); const launchSummary = this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; @@ -8941,7 +9113,7 @@ export class TeamProvisioningService { */ private cleanupRun(run: ProvisioningRun): void { if (run.isLaunch && !run.provisioningComplete) { - void this.persistPartialLaunchState(run); + void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); this.setLeadActivity(run, 'offline'); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 52697cf4..e5517368 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; import { parentPort } from 'node:worker_threads'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; interface ListTeamsPayload { teamsDir: string; @@ -319,48 +320,49 @@ function dropCliProvisionerMembers( } } -async function readPartialLaunchState( +async function readLaunchState( teamsDir: string, teamName: string ): Promise<{ - partialLaunchFailure: true; - expectedMemberCount: number; - confirmedMemberCount: number; - missingMembers: string[]; + partialLaunchFailure?: true; + expectedMemberCount?: number; + confirmedMemberCount?: number; + missingMembers?: string[]; + teamLaunchState?: string; + launchUpdatedAt?: string; + confirmedCount?: number; + pendingCount?: number; + failedCount?: number; + runtimeAlivePendingCount?: number; } | null> { const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE); try { const stat = await fs.promises.stat(launchStatePath); if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null; const raw = await fs.promises.readFile(launchStatePath, 'utf8'); - const parsed = JSON.parse(raw) as { - state?: unknown; - expectedMembers?: unknown; - confirmedMembers?: unknown; - missingMembers?: unknown; - }; - if (parsed.state !== 'partial_launch_failure') return null; - const expectedMembers = Array.isArray(parsed.expectedMembers) - ? parsed.expectedMembers.filter( - (name): name is string => typeof name === 'string' && name.trim().length > 0 - ) - : []; - const confirmedMembers = Array.isArray(parsed.confirmedMembers) - ? parsed.confirmedMembers.filter( - (name): name is string => typeof name === 'string' && name.trim().length > 0 - ) - : []; - const missingMembers = Array.isArray(parsed.missingMembers) - ? parsed.missingMembers.filter( - (name): name is string => typeof name === 'string' && name.trim().length > 0 - ) - : []; - if (expectedMembers.length === 0 || missingMembers.length === 0) return null; + const snapshot = normalizePersistedLaunchSnapshot(teamName, JSON.parse(raw)); + if (!snapshot) return null; + const missingMembers = snapshot.expectedMembers.filter((name) => { + const member = snapshot.members[name]; + return member?.launchState === 'failed_to_start'; + }); return { - partialLaunchFailure: true, - expectedMemberCount: expectedMembers.length, - confirmedMemberCount: confirmedMembers.length, - missingMembers, + ...(snapshot.teamLaunchState === 'partial_failure' + ? { partialLaunchFailure: true as const } + : {}), + ...(snapshot.expectedMembers.length > 0 + ? { expectedMemberCount: snapshot.expectedMembers.length } + : {}), + ...(snapshot.summary.confirmedCount > 0 + ? { confirmedMemberCount: snapshot.summary.confirmedCount } + : {}), + ...(missingMembers.length > 0 ? { missingMembers } : {}), + teamLaunchState: snapshot.teamLaunchState, + launchUpdatedAt: snapshot.updatedAt, + confirmedCount: snapshot.summary.confirmedCount, + pendingCount: snapshot.summary.pendingCount, + failedCount: snapshot.summary.failedCount, + runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, }; } catch { return null; @@ -591,8 +593,8 @@ async function listTeams( dropCliProvisionerMembers(memberMap); const members = Array.from(memberMap.values()); - const partialLaunchState = - (await readPartialLaunchState(payload.teamsDir, teamName)) ?? + const launchStateSummary = + (await readLaunchState(payload.teamsDir, teamName)) ?? (() => { if ( !leadSessionId || @@ -628,7 +630,7 @@ async function listTeams( ...(projectPathHistory ? { projectPathHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}), ...(deletedAt ? { deletedAt } : {}), - ...(partialLaunchState ?? {}), + ...(launchStateSummary ?? {}), }; const ms = nowMs() - t0; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 595ad1df..e63a2d6d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1557,11 +1557,16 @@ export const TeamDetailView = ({ > - {currentTeamSummary?.partialLaunchFailure - ? currentTeamSummary.missingMembers?.length - ? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join` - : 'Last launch failed partway' - : 'Team is offline'} + {currentTeamSummary?.teamLaunchState === 'partial_pending' + ? currentTeamSummary.runtimeAlivePendingCount && + currentTeamSummary.runtimeAlivePendingCount > 0 + ? `Last launch is still reconciling — ${currentTeamSummary.confirmedCount ?? 0}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.memberCount} teammates confirmed alive, ${currentTeamSummary.runtimeAlivePendingCount} runtime${currentTeamSummary.runtimeAlivePendingCount === 1 ? '' : 's'} pending bootstrap` + : 'Last launch is still reconciling' + : currentTeamSummary?.partialLaunchFailure + ? currentTeamSummary.missingMembers?.length + ? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join` + : 'Last launch failed partway' + : 'Team is offline'}