import { isLeadMember } from '@shared/utils/leadDetection'; import { hasUnsafeProvisionedButNotAliveRuntimeEvidence, hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, isBootstrapConfirmedProvisionedButNotAliveFailure, mentionsProcessTableUnavailable, } from '@shared/utils/teamLaunchFailureReason'; import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, TeamAgentRuntimeEntry, TeamProvisioningProgress, } from '@shared/types'; interface LaunchJoinMemberLike { name: string; removedAt?: number; } /** Display steps for the provisioning stepper (0-indexed). */ export const DISPLAY_STEPS = [ { key: 'starting', labelKey: 'provisioning.steps.starting' }, { key: 'configuring', labelKey: 'provisioning.steps.configuring' }, { key: 'assembling', labelKey: 'provisioning.steps.assembling' }, { key: 'finalizing', labelKey: 'provisioning.steps.finalizing' }, ] as const; export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length; export interface LaunchJoinMilestones { expectedTeammateCount: number; heartbeatConfirmedCount: number; processOnlyAliveCount: number; pendingSpawnCount: number; failedSpawnCount: number; skippedSpawnCount: number; } type DisplayStepMilestones = LaunchJoinMilestones & { progress: Pick; }; type MemberSpawnStatusCollection = | Record | Map | undefined; type TeamAgentRuntimeEntryCollection = | Record | Map | undefined; function getSpawnEntry( memberSpawnStatuses: MemberSpawnStatusCollection, memberName: string ): MemberSpawnStatusEntry | undefined { if (!memberSpawnStatuses) { return undefined; } if (memberSpawnStatuses instanceof Map) { return memberSpawnStatuses.get(memberName); } return memberSpawnStatuses[memberName]; } function getRuntimeEntry( memberRuntimeEntries: TeamAgentRuntimeEntryCollection, memberName: string ): TeamAgentRuntimeEntry | undefined { if (!memberRuntimeEntries) { return undefined; } if (memberRuntimeEntries instanceof Map) { return memberRuntimeEntries.get(memberName); } return memberRuntimeEntries[memberName]; } function parseStatusUpdatedAtMs(value: string | undefined): number | null { if (!value) { return null; } const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { if (isBootstrapConfirmedProvisionedButNotAliveFailure(entry)) { return hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry); } return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; } function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean { return ( entry.runtimeAlive === true && entry.livenessKind === 'runtime_process' && entry.bootstrapStalled !== true ); } function isConfirmedSpawnEntry(entry: MemberSpawnStatusEntry): boolean { if (isBootstrapConfirmedProvisionedButNotAliveFailure(entry)) { return !isFailedSpawnEntry(entry); } return entry.launchState === 'confirmed_alive' || entry.bootstrapConfirmed === true; } function spawnEntryContradictsConfirmedJoin(entry: MemberSpawnStatusEntry): boolean { if (!isConfirmedSpawnEntry(entry) || entry.runtimeAlive !== false) { return false; } if (entry.runtimeDiagnosticSeverity === 'error') { return true; } if ( entry.livenessKind === 'not_found' || entry.livenessKind === 'shell_only' || entry.livenessKind === 'permission_blocked' || entry.livenessKind === 'runtime_process_candidate' ) { return true; } const hasProcessTableUnavailableMarker = mentionsProcessTableUnavailable(entry.runtimeDiagnostic) || mentionsProcessTableUnavailable(entry.hardFailureReason) || mentionsProcessTableUnavailable(entry.error); if (!entry.livenessKind) { return !hasProcessTableUnavailableMarker; } if (entry.livenessKind !== 'registered_only' && entry.livenessKind !== 'stale_metadata') { return false; } return !hasProcessTableUnavailableMarker; } function runtimeEntryContradictsConfirmedJoin( entry: MemberSpawnStatusEntry, runtimeEntry: TeamAgentRuntimeEntry | undefined ): boolean { if (runtimeEntry?.alive !== false || runtimeEntry.livenessKind === 'confirmed_bootstrap') { return false; } if ( isBootstrapConfirmedProvisionedButNotAliveFailure(entry) && !hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry) && !hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(entry, runtimeEntry) && (runtimeEntry.livenessKind == null || runtimeEntry.livenessKind === 'registered_only' || runtimeEntry.livenessKind === 'stale_metadata') && (mentionsProcessTableUnavailable(runtimeEntry.runtimeDiagnostic) || mentionsProcessTableUnavailable(entry.runtimeDiagnostic) || mentionsProcessTableUnavailable(entry.hardFailureReason) || mentionsProcessTableUnavailable(entry.error)) ) { return false; } return true; } function shouldPreferSnapshotEntryOverLive( liveEntry: MemberSpawnStatusEntry | undefined, snapshotEntry: MemberSpawnStatusEntry | undefined, snapshotUpdatedAt: string | undefined ): boolean { if (!liveEntry || !snapshotEntry) { return false; } if (!isFailedSpawnEntry(liveEntry) || isFailedSpawnEntry(snapshotEntry)) { return false; } const liveUpdatedAtMs = parseStatusUpdatedAtMs(liveEntry.updatedAt); const snapshotUpdatedAtMs = parseStatusUpdatedAtMs(snapshotEntry.updatedAt) ?? parseStatusUpdatedAtMs(snapshotUpdatedAt); return ( snapshotUpdatedAtMs != null && (liveUpdatedAtMs == null || snapshotUpdatedAtMs >= liveUpdatedAtMs) ); } function summarizeLiveLaunchJoinMilestones(params: { teammateNames: readonly string[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; memberSpawnSnapshotUpdatedAt?: string; memberRuntimeEntries?: TeamAgentRuntimeEntryCollection; }): Omit & { observedTeammateCount: number; } { const { teammateNames, memberSpawnStatuses, memberSpawnSnapshotStatuses, memberSpawnSnapshotUpdatedAt, } = params; let heartbeatConfirmedCount = 0; let processOnlyAliveCount = 0; let pendingSpawnCount = 0; let failedSpawnCount = 0; let skippedSpawnCount = 0; let observedTeammateCount = 0; for (const memberName of teammateNames) { const liveEntry = getSpawnEntry(memberSpawnStatuses, memberName); const snapshotEntry = memberSpawnSnapshotStatuses?.[memberName]; const entry = shouldPreferSnapshotEntryOverLive( liveEntry, snapshotEntry, memberSpawnSnapshotUpdatedAt ) ? snapshotEntry : liveEntry; if (!entry) { pendingSpawnCount += 1; continue; } observedTeammateCount += 1; if (isFailedSpawnEntry(entry)) { failedSpawnCount += 1; continue; } if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { skippedSpawnCount += 1; continue; } if (spawnEntryContradictsConfirmedJoin(entry)) { pendingSpawnCount += 1; continue; } if ( isConfirmedSpawnEntry(entry) && runtimeEntryContradictsConfirmedJoin( entry, getRuntimeEntry(params.memberRuntimeEntries, memberName) ) ) { pendingSpawnCount += 1; continue; } if (isConfirmedSpawnEntry(entry)) { heartbeatConfirmedCount += 1; continue; } if (entry.launchState === 'runtime_pending_permission') { pendingSpawnCount += 1; continue; } if (entry.launchState === 'runtime_pending_bootstrap') { if (isStrongRuntimeProcessSpawnEntry(entry)) { processOnlyAliveCount += 1; } else { pendingSpawnCount += 1; } continue; } if (entry.launchState === 'starting') { pendingSpawnCount += 1; } } return { heartbeatConfirmedCount, processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, skippedSpawnCount, observedTeammateCount, }; } export function getLaunchJoinMilestonesFromMembers({ members, memberSpawnStatuses, memberSpawnSnapshot, memberRuntimeEntries, }: { members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshot?: Pick< MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary' | 'updatedAt' > & { statuses?: MemberSpawnStatusesSnapshot['statuses']; }; memberRuntimeEntries?: TeamAgentRuntimeEntryCollection; }): LaunchJoinMilestones { const removedTeammateNameSet = new Set( members .filter((member) => member.removedAt && !isLeadMember(member)) .map((member) => member.name) ); const teammates = members.filter((member) => !member.removedAt && !isLeadMember(member)); const activeTeammateNames = teammates.map((member) => member.name); const snapshotExpectedNames = memberSpawnSnapshot?.expectedMembers ?? []; const snapshotStatusNames = Object.keys(memberSpawnSnapshot?.statuses ?? {}); const teammateNames = snapshotExpectedNames.length > 0 || snapshotStatusNames.length > 0 ? Array.from( new Set([...snapshotExpectedNames, ...snapshotStatusNames, ...activeTeammateNames]) ).filter( (memberName) => memberName.trim().length > 0 && !isLeadMember({ name: memberName }) && !removedTeammateNameSet.has(memberName) ) : activeTeammateNames; const expectedTeammateCount = teammateNames.length; const snapshotSummary = memberSpawnSnapshot?.summary; const liveSummary = summarizeLiveLaunchJoinMilestones({ teammateNames, memberSpawnStatuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, memberRuntimeEntries, }); if (snapshotSummary) { const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0; const snapshotMilestones = { expectedTeammateCount, heartbeatConfirmedCount: snapshotSummary.confirmedCount, processOnlyAliveCount: snapshotProcessOnlyAliveCount, pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount), failedSpawnCount: snapshotSummary.failedCount, skippedSpawnCount: snapshotSummary.skippedCount ?? 0, }; const snapshotAccountedFor = snapshotMilestones.heartbeatConfirmedCount + snapshotMilestones.processOnlyAliveCount + snapshotMilestones.failedSpawnCount + snapshotMilestones.skippedSpawnCount; const liveAccountedFor = liveSummary.heartbeatConfirmedCount + liveSummary.processOnlyAliveCount + liveSummary.failedSpawnCount + liveSummary.skippedSpawnCount; const liveSummaryContradictsCleanSnapshot = snapshotMilestones.pendingSpawnCount === 0 && snapshotMilestones.failedSpawnCount === 0 && snapshotMilestones.skippedSpawnCount === 0 && liveSummary.observedTeammateCount > 0 && (liveSummary.pendingSpawnCount > 0 || liveSummary.failedSpawnCount > 0 || liveSummary.skippedSpawnCount > 0); const liveSummaryIsMoreAdvanced = liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount || liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount || liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount || liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount || (snapshotMilestones.failedSpawnCount === 0 && liveSummary.observedTeammateCount > 0 && liveSummary.pendingSpawnCount > snapshotMilestones.pendingSpawnCount) || liveAccountedFor > snapshotAccountedFor; return liveSummaryIsMoreAdvanced || liveSummaryContradictsCleanSnapshot ? { expectedTeammateCount, ...liveSummary, } : snapshotMilestones; } return { expectedTeammateCount, ...liveSummary, }; } export function getLaunchJoinState({ expectedTeammateCount, heartbeatConfirmedCount, processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, skippedSpawnCount, }: LaunchJoinMilestones): { allTeammatesConfirmedAlive: boolean; hasMembersStillJoining: boolean; remainingJoinCount: number; } { const allTeammatesConfirmedAlive = expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0 && heartbeatConfirmedCount >= expectedTeammateCount; const remainingJoinCount = expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0 ? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount) : 0; const hasMembersStillJoining = expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0 && remainingJoinCount > 0 && (processOnlyAliveCount > 0 || pendingSpawnCount > 0); return { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, }; } /** * Maps launch progress to the visible stepper milestone. * * The renderer intentionally derives these steps from observable launch evidence * instead of raw backend phase names. The backend can move through * validating/spawning/configuring very quickly, but the UI milestones should * reflect what the user can actually observe: * - Starting: waiting for a real CLI/runtime process * - Team setup: process exists, but config is not readable yet * - Members joining: config is ready, but teammate runtimes are still attaching * - Finalizing: teammate runtimes are attached and bootstrap/contact is settling * * Returns DISPLAY_COMPLETE_STEP_INDEX for 'ready', -1 for failed/cancelled. */ export function getDisplayStepIndex({ progress, expectedTeammateCount, heartbeatConfirmedCount, processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, skippedSpawnCount, }: DisplayStepMilestones): number { switch (progress.state) { case 'ready': return DISPLAY_COMPLETE_STEP_INDEX; case 'failed': case 'disconnected': case 'cancelled': return -1; default: break; } if (!progress.pid) { return 0; } if (progress.configReady !== true) { return 1; } if (expectedTeammateCount <= 0) { return 3; } if (failedSpawnCount > 0) { return 2; } if (skippedSpawnCount > 0) { return 2; } const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount; if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) { return 2; } return 3; }