@@ -17,11 +17,13 @@ import {
1717} from "./test-parallel-utils.mjs" ;
1818import {
1919 dedupeFilesPreserveOrder ,
20+ loadChannelTimingManifest ,
2021 loadUnitMemoryHotspotManifest ,
2122 loadTestRunnerBehavior ,
2223 loadUnitTimingManifest ,
2324 selectUnitHeavyFileGroups ,
2425 packFilesByDuration ,
26+ packFilesByDurationWithBaseLoads ,
2527} from "./test-runner-manifest.mjs" ;
2628
2729// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
@@ -312,6 +314,7 @@ const inferTarget = (fileFilter) => {
312314 return { owner : "base" , isolated } ;
313315} ;
314316const unitTimingManifest = loadUnitTimingManifest ( ) ;
317+ const channelTimingManifest = loadChannelTimingManifest ( ) ;
315318const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest ( ) ;
316319const parseEnvNumber = ( name , fallback ) => {
317320 const parsed = Number . parseInt ( process . env [ name ] ?? "" , 10 ) ;
@@ -385,6 +388,26 @@ const unitFastExcludedFiles = [
385388] ;
386389const estimateUnitDurationMs = ( file ) =>
387390 unitTimingManifest . files [ file ] ?. durationMs ?? unitTimingManifest . defaultDurationMs ;
391+ const estimateChannelDurationMs = ( file ) =>
392+ channelTimingManifest . files [ file ] ?. durationMs ?? channelTimingManifest . defaultDurationMs ;
393+ const resolveEntryTimingEstimator = ( entry ) => {
394+ const configIndex = entry . args . findIndex ( ( arg ) => arg === "--config" ) ;
395+ const config = configIndex >= 0 ? ( entry . args [ configIndex + 1 ] ?? "" ) : "" ;
396+ if ( config === "vitest.unit.config.ts" ) {
397+ return estimateUnitDurationMs ;
398+ }
399+ if ( config === "vitest.channels.config.ts" ) {
400+ return estimateChannelDurationMs ;
401+ }
402+ return null ;
403+ } ;
404+ const estimateEntryFilesDurationMs = ( entry , files ) => {
405+ const estimateDurationMs = resolveEntryTimingEstimator ( entry ) ;
406+ if ( ! estimateDurationMs ) {
407+ return files . length * 1_000 ;
408+ }
409+ return files . reduce ( ( totalMs , file ) => totalMs + estimateDurationMs ( file ) , 0 ) ;
410+ } ;
388411const splitFilesByDurationBudget = ( files , targetDurationMs , estimateDurationMs ) => {
389412 if ( ! Number . isFinite ( targetDurationMs ) || targetDurationMs <= 0 || files . length <= 1 ) {
390413 return [ files ] ;
@@ -462,6 +485,11 @@ const unitFastEntries = unitFastBuckets.flatMap((files, index) => {
462485 . map ( ( batch , batchIndex ) => ( {
463486 name : recycledBatches . length === 1 ? laneName : `${ laneName } -batch-${ String ( batchIndex + 1 ) } ` ,
464487 serialPhase : "unit-fast" ,
488+ includeFiles : batch ,
489+ estimatedDurationMs : estimateEntryFilesDurationMs (
490+ { args : [ "vitest" , "run" , "--config" , "vitest.unit.config.ts" ] } ,
491+ batch ,
492+ ) ,
465493 env : {
466494 OPENCLAW_VITEST_INCLUDE_FILE : writeTempJsonArtifact (
467495 `vitest-unit-fast-include-${ String ( index + 1 ) } -${ String ( batchIndex + 1 ) } ` ,
@@ -564,6 +592,7 @@ const baseRuns = [
564592 ...extensionIsolatedEntries ,
565593 {
566594 name : "extensions" ,
595+ includeFiles : extensionSharedCandidateFiles ,
567596 env :
568597 extensionSharedCandidateFiles . length > 0
569598 ? {
@@ -585,6 +614,11 @@ const baseRuns = [
585614 } ) ) ,
586615 {
587616 name : "channels" ,
617+ includeFiles : channelSharedCandidateFiles ,
618+ estimatedDurationMs : estimateEntryFilesDurationMs (
619+ { args : [ "vitest" , "run" , "--config" , "vitest.channels.config.ts" ] } ,
620+ channelSharedCandidateFiles ,
621+ ) ,
588622 env :
589623 channelSharedCandidateFiles . length > 0
590624 ? {
@@ -772,6 +806,65 @@ const createPerFileTargetedEntry = (file) => {
772806 name : `${ formatPerFileEntryName ( owner , file ) } ${ target . isolated ? "-isolated" : "" } ` ,
773807 } ;
774808} ;
809+ const rebuildEntryArgsWithFilters = ( entryArgs , filters ) => {
810+ const baseArgs = entryArgs . slice ( 0 , 2 ) ;
811+ const { optionArgs } = parsePassthroughArgs ( entryArgs . slice ( 2 ) ) ;
812+ return [ ...baseArgs , ...optionArgs , ...filters ] ;
813+ } ;
814+ const createPinnedShardEntry = ( entry , files , fixedShardIndex ) => {
815+ const nextEntry = {
816+ ...entry ,
817+ name : `${ entry . name } -shard-${ String ( fixedShardIndex ) } ` ,
818+ fixedShardIndex,
819+ estimatedDurationMs : estimateEntryFilesDurationMs ( entry , files ) ,
820+ } ;
821+ if ( Array . isArray ( entry . includeFiles ) && entry . includeFiles . length > 0 ) {
822+ return {
823+ ...nextEntry ,
824+ includeFiles : files ,
825+ env : {
826+ ...entry . env ,
827+ OPENCLAW_VITEST_INCLUDE_FILE : writeTempJsonArtifact (
828+ `${ sanitizeArtifactName ( entry . name ) } -shard-${ String ( fixedShardIndex ) } -include` ,
829+ files ,
830+ ) ,
831+ } ,
832+ args : rebuildEntryArgsWithFilters ( entry . args , [ ] ) ,
833+ } ;
834+ }
835+ return {
836+ ...nextEntry ,
837+ args : rebuildEntryArgsWithFilters ( entry . args , files ) ,
838+ } ;
839+ } ;
840+ const expandEntryAcrossTopLevelShards = ( entry ) => {
841+ if ( configuredShardCount === null || shardCount <= 1 || entry . fixedShardIndex !== undefined ) {
842+ return [ entry ] ;
843+ }
844+ const estimateDurationMs = resolveEntryTimingEstimator ( entry ) ;
845+ if ( ! estimateDurationMs ) {
846+ return [ entry ] ;
847+ }
848+ const candidateFiles =
849+ Array . isArray ( entry . includeFiles ) && entry . includeFiles . length > 0
850+ ? entry . includeFiles
851+ : getExplicitEntryFilters ( entry . args ) ;
852+ if ( candidateFiles . length <= 1 ) {
853+ return [ entry ] ;
854+ }
855+ const effectiveShardCount = Math . min ( shardCount , Math . max ( 1 , candidateFiles . length - 1 ) ) ;
856+ if ( effectiveShardCount <= 1 ) {
857+ return [ entry ] ;
858+ }
859+ const buckets = packFilesByDurationWithBaseLoads (
860+ candidateFiles ,
861+ effectiveShardCount ,
862+ estimateDurationMs ,
863+ ) ;
864+ return buckets . flatMap ( ( files , bucketIndex ) =>
865+ files . length > 0 ? [ createPinnedShardEntry ( entry , files , bucketIndex + 1 ) ] : [ ] ,
866+ ) ;
867+ } ;
775868const targetedEntries = ( ( ) => {
776869 if ( passthroughFileFilters . length === 0 ) {
777870 return [ ] ;
@@ -815,7 +908,13 @@ const targetedEntries = (() => {
815908 return [ createTargetedEntry ( owner , false , uniqueFilters ) ] ;
816909 } ) . flat ( ) ;
817910} ) ( ) ;
911+ if ( configuredShardCount !== null && shardCount > 1 ) {
912+ runs = runs . flatMap ( ( entry ) => expandEntryAcrossTopLevelShards ( entry ) ) ;
913+ }
818914const estimateTopLevelEntryDurationMs = ( entry ) => {
915+ if ( Number . isFinite ( entry . estimatedDurationMs ) && entry . estimatedDurationMs > 0 ) {
916+ return entry . estimatedDurationMs ;
917+ }
819918 const filters = getExplicitEntryFilters ( entry . args ) ;
820919 if ( filters . length === 0 ) {
821920 return unitTimingManifest . defaultDurationMs ;
@@ -841,6 +940,9 @@ const topLevelSingleShardAssignments = (() => {
841940 // Single-file and other non-shardable explicit lanes would otherwise run on
842941 // every shard. Assign them to one top-level shard instead.
843942 const entriesNeedingAssignment = runs . filter ( ( entry ) => {
943+ if ( entry . fixedShardIndex !== undefined ) {
944+ return false ;
945+ }
844946 const explicitFilterCount = countExplicitEntryFilters ( entry . args ) ;
845947 if ( explicitFilterCount === null ) {
846948 return false ;
@@ -850,10 +952,22 @@ const topLevelSingleShardAssignments = (() => {
850952 } ) ;
851953
852954 const assignmentMap = new Map ( ) ;
853- const buckets = packFilesByDuration (
955+ const pinnedShardLoadsMs = Array . from ( { length : shardCount } , ( ) => 0 ) ;
956+ for ( const entry of runs ) {
957+ if ( entry . fixedShardIndex === undefined ) {
958+ continue ;
959+ }
960+ const shardArrayIndex = entry . fixedShardIndex - 1 ;
961+ if ( shardArrayIndex < 0 || shardArrayIndex >= pinnedShardLoadsMs . length ) {
962+ continue ;
963+ }
964+ pinnedShardLoadsMs [ shardArrayIndex ] += estimateTopLevelEntryDurationMs ( entry ) ;
965+ }
966+ const buckets = packFilesByDurationWithBaseLoads (
854967 entriesNeedingAssignment ,
855968 shardCount ,
856969 estimateTopLevelEntryDurationMs ,
970+ pinnedShardLoadsMs ,
857971 ) ;
858972 for ( const [ bucketIndex , bucket ] of buckets . entries ( ) ) {
859973 for ( const entry of bucket ) {
@@ -1363,6 +1477,12 @@ const runOnce = (entry, extraArgs = []) =>
13631477 } ) ;
13641478
13651479const run = async ( entry , extraArgs = [ ] ) => {
1480+ if ( entry . fixedShardIndex !== undefined ) {
1481+ if ( shardIndexOverride !== null && shardIndexOverride !== entry . fixedShardIndex ) {
1482+ return 0 ;
1483+ }
1484+ return runOnce ( entry , extraArgs ) ;
1485+ }
13661486 const explicitFilterCount = countExplicitEntryFilters ( entry . args ) ;
13671487 const topLevelAssignedShard = topLevelSingleShardAssignments . get ( entry ) ;
13681488 if ( topLevelAssignedShard !== undefined ) {
0 commit comments