diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index f4944494..ba8906cb 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -54,12 +54,15 @@ function getSpawnEntry( function summarizeLiveLaunchJoinMilestones(params: { teammateNames: readonly string[]; memberSpawnStatuses?: MemberSpawnStatusCollection; -}): Omit { +}): Omit & { + observedTeammateCount: number; +} { const { teammateNames, memberSpawnStatuses } = params; let heartbeatConfirmedCount = 0; let processOnlyAliveCount = 0; let pendingSpawnCount = 0; let failedSpawnCount = 0; + let observedTeammateCount = 0; for (const memberName of teammateNames) { const entry = getSpawnEntry(memberSpawnStatuses, memberName); @@ -67,6 +70,7 @@ function summarizeLiveLaunchJoinMilestones(params: { pendingSpawnCount += 1; continue; } + observedTeammateCount += 1; if (entry.launchState === 'failed_to_start') { failedSpawnCount += 1; continue; @@ -96,6 +100,7 @@ function summarizeLiveLaunchJoinMilestones(params: { processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + observedTeammateCount, }; } @@ -106,20 +111,28 @@ export function getLaunchJoinMilestonesFromMembers({ }: { members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; - memberSpawnSnapshot?: Pick; + memberSpawnSnapshot?: Pick & { + statuses?: MemberSpawnStatusesSnapshot['statuses']; + }; }): 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 activeTeammateNameSet = new Set(activeTeammateNames); + const snapshotExpectedNames = memberSpawnSnapshot?.expectedMembers ?? []; + const snapshotStatusNames = Object.keys(memberSpawnSnapshot?.statuses ?? {}); const teammateNames = - memberSpawnSnapshot?.expectedMembers?.length && memberSpawnSnapshot.expectedMembers.length > 0 + snapshotExpectedNames.length > 0 || snapshotStatusNames.length > 0 ? Array.from( - new Set([ - ...memberSpawnSnapshot.expectedMembers.filter((memberName) => - activeTeammateNameSet.has(memberName) - ), - ...activeTeammateNames, - ]) + new Set([...snapshotExpectedNames, ...snapshotStatusNames, ...activeTeammateNames]) + ).filter( + (memberName) => + memberName.trim().length > 0 && + !isLeadMember({ name: memberName }) && + !removedTeammateNameSet.has(memberName) ) : activeTeammateNames; const expectedTeammateCount = teammateNames.length; @@ -155,6 +168,7 @@ export function getLaunchJoinMilestonesFromMembers({ liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount || liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount || (snapshotMilestones.failedSpawnCount === 0 && + liveSummary.observedTeammateCount > 0 && liveSummary.pendingSpawnCount > snapshotMilestones.pendingSpawnCount) || liveAccountedFor > snapshotAccountedFor; diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index d6ff7de6..1698a896 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -460,4 +460,55 @@ describe('TeamProvisioningBanner launch-step alignment', () => { await Promise.resolve(); }); }); + + it('trusts persisted snapshot member statuses even when expectedMembers and team cache are stale', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.selectedTeamData.members = [{ name: 'team-lead', agentType: 'team-lead' }]; + storeState.teamDataCacheByName['northstar-core'] = { + members: [...storeState.selectedTeamData.members], + }; + storeState.memberSpawnStatusesByTeam['northstar-core'] = {}; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { + runId: 'run-1', + expectedMembers: [], + statuses: { + alice: { + status: 'online', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + source: 'persisted', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + const block = host.querySelector('[data-testid="progress-block"]'); + expect(block?.getAttribute('data-current-step-index')).toBe('2'); + expect(block?.textContent).toContain('Finishing launch'); + expect(block?.textContent).toContain('1 teammate still joining'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); });