diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 9e596794..6e5d8d6f 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1240,6 +1240,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ () => buildTeamAgentRuntimeMap(runtimeSnapshot?.members), [runtimeSnapshot?.members] ); + const runtimeEntries = runtimeSnapshot?.members; const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId; const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { @@ -1250,9 +1251,10 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ members: props.members, memberSpawnStatuses, memberSpawnSnapshot, + memberRuntimeEntries: runtimeEntries, }) ).hasMembersStillJoining; - }, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members]); + }, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members, runtimeEntries]); return ( ({ @@ -1338,6 +1341,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( s.teamAgentRuntimeByTeam[teamName]?.runId ?? s.memberSpawnSnapshotsByTeam[teamName]?.runId ?? getCurrentProvisioningProgressForTeam(s, teamName)?.runId, + runtimeEntries: s.teamAgentRuntimeByTeam[teamName]?.members, runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined, })) ); @@ -1350,9 +1354,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( members: launchMembers, memberSpawnStatuses, memberSpawnSnapshot, + memberRuntimeEntries: runtimeEntries, }) ).hasMembersStillJoining; - }, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state]); + }, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state, runtimeEntries]); return ( | undefined; +type TeamAgentRuntimeEntryCollection = + | Record + | Map + | undefined; + function getSpawnEntry( memberSpawnStatuses: MemberSpawnStatusCollection, memberName: string @@ -52,6 +58,19 @@ function getSpawnEntry( 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; @@ -72,6 +91,16 @@ function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolea ); } +function isConfirmedSpawnEntry(entry: MemberSpawnStatusEntry): boolean { + return entry.launchState === 'confirmed_alive' || entry.bootstrapConfirmed === true; +} + +function runtimeEntryContradictsConfirmedJoin( + runtimeEntry: TeamAgentRuntimeEntry | undefined +): boolean { + return runtimeEntry?.alive === false; +} + function shouldPreferSnapshotEntryOverLive( liveEntry: MemberSpawnStatusEntry | undefined, snapshotEntry: MemberSpawnStatusEntry | undefined, @@ -98,6 +127,7 @@ function summarizeLiveLaunchJoinMilestones(params: { memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; memberSpawnSnapshotUpdatedAt?: string; + memberRuntimeEntries?: TeamAgentRuntimeEntryCollection; }): Omit & { observedTeammateCount: number; } { @@ -137,6 +167,13 @@ function summarizeLiveLaunchJoinMilestones(params: { skippedSpawnCount += 1; continue; } + if ( + isConfirmedSpawnEntry(entry) && + runtimeEntryContradictsConfirmedJoin(getRuntimeEntry(params.memberRuntimeEntries, memberName)) + ) { + pendingSpawnCount += 1; + continue; + } if (entry.launchState === 'confirmed_alive') { heartbeatConfirmedCount += 1; continue; @@ -172,6 +209,7 @@ export function getLaunchJoinMilestonesFromMembers({ members, memberSpawnStatuses, memberSpawnSnapshot, + memberRuntimeEntries, }: { members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; @@ -181,6 +219,7 @@ export function getLaunchJoinMilestonesFromMembers({ > & { statuses?: MemberSpawnStatusesSnapshot['statuses']; }; + memberRuntimeEntries?: TeamAgentRuntimeEntryCollection; }): LaunchJoinMilestones { const removedTeammateNameSet = new Set( members @@ -209,6 +248,7 @@ export function getLaunchJoinMilestonesFromMembers({ memberSpawnStatuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + memberRuntimeEntries, }); if (snapshotSummary) { diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts index 040b220a..3ca890e3 100644 --- a/src/renderer/components/team/useTeamProvisioningPresentation.ts +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -51,9 +51,10 @@ export function useTeamProvisioningPresentation(teamName: string): { members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, + memberRuntimeEntries: runtimeSnapshot?.members, t, }), - [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers, t] + [memberSpawnSnapshot, memberSpawnStatuses, progress, runtimeSnapshot?.members, teamMembers, t] ); const memberDiagnostics = useMemo( () => diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 7defd56f..f246a707 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -9,6 +9,7 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + TeamAgentRuntimeEntry, TeamProvisioningProgress, } from '@shared/types'; @@ -17,6 +18,11 @@ type MemberSpawnStatusCollection = | Map | undefined; +type TeamAgentRuntimeEntryCollection = + | Record + | Map + | undefined; + interface ProvisioningMemberLike { name: string; removedAt?: number; @@ -892,6 +898,7 @@ export function buildTeamProvisioningPresentation({ members, memberSpawnStatuses, memberSpawnSnapshot, + memberRuntimeEntries, t, }: { progress: TeamProvisioningProgress | null | undefined; @@ -903,6 +910,7 @@ export function buildTeamProvisioningPresentation({ > & { statuses?: MemberSpawnStatusesSnapshot['statuses']; }; + memberRuntimeEntries?: TeamAgentRuntimeEntryCollection; t?: unknown; }): TeamProvisioningPresentation | null { if (!progress) { @@ -934,6 +942,7 @@ export function buildTeamProvisioningPresentation({ members, memberSpawnStatuses, memberSpawnSnapshot, + memberRuntimeEntries, }); const failedSpawnDetails = getFailedSpawnDetails({ memberSpawnStatuses, diff --git a/test/renderer/components/team/provisioningSteps.test.ts b/test/renderer/components/team/provisioningSteps.test.ts index 4d8830bd..78d8158c 100644 --- a/test/renderer/components/team/provisioningSteps.test.ts +++ b/test/renderer/components/team/provisioningSteps.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from 'vitest'; - import { getLaunchJoinMilestonesFromMembers } from '@renderer/components/team/provisioningSteps'; +import { describe, expect, it } from 'vitest'; const members = [{ name: 'alice' }, { name: 'bob' }, { name: 'tom' }, { name: 'jane' }]; @@ -194,4 +193,54 @@ describe('getLaunchJoinMilestonesFromMembers', () => { expect(milestones.pendingSpawnCount).toBe(1); expect(milestones.expectedTeammateCount).toBe(4); }); + + it('does not count confirmed spawn as joined when runtime snapshot is unavailable', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses: { + alice: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + bob: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + updatedAt: '2026-04-24T12:00:01.000Z', + }, + tom: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + jane: { + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }, + memberRuntimeEntries: { + bob: { + memberName: 'bob', + alive: false, + restartable: true, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + updatedAt: '2026-04-24T12:00:02.000Z', + }, + }, + }); + + expect(milestones.heartbeatConfirmedCount).toBe(3); + expect(milestones.pendingSpawnCount).toBe(1); + expect(milestones.expectedTeammateCount).toBe(4); + }); }); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 9388bc7b..c5a40dae 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from 'vitest'; - import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; +import { describe, expect, it } from 'vitest'; describe('buildTeamProvisioningPresentation', () => { it('uses a lead-online compact detail for ready teams without teammates', () => { @@ -1607,6 +1606,72 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.currentStepIndex).toBe(4); }); + it('keeps ready launch in finishing state when runtime snapshot contradicts confirmed spawn', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-5b', + teamName: 'codex-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + bob: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + livenessSource: 'heartbeat', + bootstrapConfirmed: true, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + lastHeartbeatAt: '2026-04-13T10:00:07.000Z', + }, + }, + memberRuntimeEntries: { + bob: { + memberName: 'bob', + alive: false, + restartable: true, + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + updatedAt: '2026-04-13T10:00:08.000Z', + }, + }, + }); + + expect(presentation?.compactTitle).toBe('Finishing launch'); + expect(presentation?.compactDetail).toBe('1 teammate still joining'); + expect(presentation?.successMessage).toBe('Finishing launch'); + expect(presentation?.currentStepIndex).toBe(2); + }); + it('ignores removed teammates that still linger in persisted expectedMembers', () => { const presentation = buildTeamProvisioningPresentation({ progress: {