@@ -33,23 +33,23 @@ process.env.ECR_IMAGE_URI = ECR_IMAGE;
3333const mockEc2Send = jest . fn ( ) ;
3434jest . mock ( '@aws-sdk/client-ec2' , ( ) => ( {
3535 EC2Client : jest . fn ( ( ) => ( { send : mockEc2Send } ) ) ,
36- DescribeInstancesCommand : jest . fn ( ( input : unknown ) => ( { _type : 'DescribeInstances' , input } ) ) ,
37- CreateTagsCommand : jest . fn ( ( input : unknown ) => ( { _type : 'CreateTags' , input } ) ) ,
38- DeleteTagsCommand : jest . fn ( ( input : unknown ) => ( { _type : 'DeleteTags' , input } ) ) ,
36+ DescribeInstancesCommand : jest . fn ( ( input ) => ( { _type : 'DescribeInstances' , input } ) ) ,
37+ CreateTagsCommand : jest . fn ( ( input ) => ( { _type : 'CreateTags' , input } ) ) ,
38+ DeleteTagsCommand : jest . fn ( ( input ) => ( { _type : 'DeleteTags' , input } ) ) ,
3939} ) ) ;
4040
4141const mockSsmSend = jest . fn ( ) ;
4242jest . mock ( '@aws-sdk/client-ssm' , ( ) => ( {
4343 SSMClient : jest . fn ( ( ) => ( { send : mockSsmSend } ) ) ,
44- SendCommandCommand : jest . fn ( ( input : unknown ) => ( { _type : 'SendCommand' , input } ) ) ,
45- GetCommandInvocationCommand : jest . fn ( ( input : unknown ) => ( { _type : 'GetCommandInvocation' , input } ) ) ,
46- CancelCommandCommand : jest . fn ( ( input : unknown ) => ( { _type : 'CancelCommand' , input } ) ) ,
44+ SendCommandCommand : jest . fn ( ( input ) => ( { _type : 'SendCommand' , input } ) ) ,
45+ GetCommandInvocationCommand : jest . fn ( ( input ) => ( { _type : 'GetCommandInvocation' , input } ) ) ,
46+ CancelCommandCommand : jest . fn ( ( input ) => ( { _type : 'CancelCommand' , input } ) ) ,
4747} ) ) ;
4848
4949const mockS3Send = jest . fn ( ) ;
5050jest . mock ( '@aws-sdk/client-s3' , ( ) => ( {
5151 S3Client : jest . fn ( ( ) => ( { send : mockS3Send } ) ) ,
52- PutObjectCommand : jest . fn ( ( input : unknown ) => ( { _type : 'PutObject' , input } ) ) ,
52+ PutObjectCommand : jest . fn ( ( input ) => ( { _type : 'PutObject' , input } ) ) ,
5353} ) ) ;
5454
5555import { Ec2ComputeStrategy } from '../../../../src/handlers/shared/strategies/ec2-strategy' ;
@@ -65,7 +65,7 @@ describe('Ec2ComputeStrategy', () => {
6565 } ) ;
6666
6767 describe ( 'startSession' , ( ) => {
68- test ( 'finds idle instance, tags as busy, uploads to S3, sends SSM command, returns handle' , async ( ) => {
68+ test ( 'finds idle instance, tags as busy, verifies claim, uploads to S3, sends SSM command, returns handle' , async ( ) => {
6969 // S3 upload
7070 mockS3Send . mockResolvedValueOnce ( { } ) ;
7171 // DescribeInstances — return one idle instance
@@ -74,6 +74,10 @@ describe('Ec2ComputeStrategy', () => {
7474 } ) ;
7575 // CreateTags (mark busy)
7676 mockEc2Send . mockResolvedValueOnce ( { } ) ;
77+ // DescribeInstances — verify claim (tag matches our task-id)
78+ mockEc2Send . mockResolvedValueOnce ( {
79+ Reservations : [ { Instances : [ { InstanceId : INSTANCE_ID , Tags : [ { Key : 'bgagent:task-id' , Value : 'TASK001' } ] } ] } ] ,
80+ } ) ;
7781 // SSM SendCommand
7882 mockSsmSend . mockResolvedValueOnce ( {
7983 Command : { CommandId : COMMAND_ID } ,
@@ -98,8 +102,8 @@ describe('Ec2ComputeStrategy', () => {
98102 expect ( s3Call . input . Bucket ) . toBe ( PAYLOAD_BUCKET ) ;
99103 expect ( s3Call . input . Key ) . toBe ( 'tasks/TASK001/payload.json' ) ;
100104
101- // Verify DescribeInstances filter
102- expect ( mockEc2Send ) . toHaveBeenCalledTimes ( 2 ) ;
105+ // Verify EC2 calls: DescribeInstances (find idle), CreateTags (claim), DescribeInstances (verify)
106+ expect ( mockEc2Send ) . toHaveBeenCalledTimes ( 3 ) ;
103107 const describeCall = mockEc2Send . mock . calls [ 0 ] [ 0 ] ;
104108 expect ( describeCall . input . Filters ) . toEqual ( expect . arrayContaining ( [
105109 expect . objectContaining ( { Name : `tag:${ FLEET_TAG_KEY } ` , Values : [ FLEET_TAG_VALUE ] } ) ,
@@ -123,6 +127,43 @@ describe('Ec2ComputeStrategy', () => {
123127 expect ( ssmCall . input . TimeoutSeconds ) . toBe ( 32400 ) ;
124128 } ) ;
125129
130+ test ( 'tries next candidate when race is lost on first instance' , async ( ) => {
131+ const INSTANCE_ID_2 = 'i-0987654321fedcba0' ;
132+ // S3 upload
133+ mockS3Send . mockResolvedValueOnce ( { } ) ;
134+ // DescribeInstances — return two idle instances
135+ mockEc2Send . mockResolvedValueOnce ( {
136+ Reservations : [ { Instances : [ { InstanceId : INSTANCE_ID } , { InstanceId : INSTANCE_ID_2 } ] } ] ,
137+ } ) ;
138+ // CreateTags on first instance
139+ mockEc2Send . mockResolvedValueOnce ( { } ) ;
140+ // Verify first instance — another task claimed it
141+ mockEc2Send . mockResolvedValueOnce ( {
142+ Reservations : [ { Instances : [ { InstanceId : INSTANCE_ID , Tags : [ { Key : 'bgagent:task-id' , Value : 'OTHER_TASK' } ] } ] } ] ,
143+ } ) ;
144+ // CreateTags on second instance
145+ mockEc2Send . mockResolvedValueOnce ( { } ) ;
146+ // Verify second instance — our task-id stuck
147+ mockEc2Send . mockResolvedValueOnce ( {
148+ Reservations : [ { Instances : [ { InstanceId : INSTANCE_ID_2 , Tags : [ { Key : 'bgagent:task-id' , Value : 'TASK001' } ] } ] } ] ,
149+ } ) ;
150+ // SSM SendCommand
151+ mockSsmSend . mockResolvedValueOnce ( {
152+ Command : { CommandId : COMMAND_ID } ,
153+ } ) ;
154+
155+ const strategy = new Ec2ComputeStrategy ( ) ;
156+ const handle = await strategy . startSession ( {
157+ taskId : 'TASK001' ,
158+ payload : { repo_url : 'org/repo' } ,
159+ blueprintConfig : { compute_type : 'ec2' , runtime_arn : '' } ,
160+ } ) ;
161+
162+ const ec2Handle = handle as Extract < typeof handle , { strategyType : 'ec2' } > ;
163+ expect ( ec2Handle . instanceId ) . toBe ( INSTANCE_ID_2 ) ;
164+ expect ( mockEc2Send ) . toHaveBeenCalledTimes ( 5 ) ; // describe + 2*(tag + verify)
165+ } ) ;
166+
126167 test ( 'throws when no idle instances available' , async ( ) => {
127168 // S3 upload
128169 mockS3Send . mockResolvedValueOnce ( { } ) ;
@@ -148,6 +189,10 @@ describe('Ec2ComputeStrategy', () => {
148189 } ) ;
149190 // CreateTags
150191 mockEc2Send . mockResolvedValueOnce ( { } ) ;
192+ // DescribeInstances — verify claim
193+ mockEc2Send . mockResolvedValueOnce ( {
194+ Reservations : [ { Instances : [ { InstanceId : INSTANCE_ID , Tags : [ { Key : 'bgagent:task-id' , Value : 'TASK001' } ] } ] } ] ,
195+ } ) ;
151196 // SSM SendCommand — return no CommandId
152197 mockSsmSend . mockResolvedValueOnce ( { Command : { } } ) ;
153198
@@ -226,12 +271,12 @@ describe('Ec2ComputeStrategy', () => {
226271 expect ( result ) . toEqual ( { status : 'failed' , error : 'Command timed out' } ) ;
227272 } ) ;
228273
229- test ( 'returns failed for Cancelling status' , async ( ) => {
274+ test ( 'returns running for Cancelling status (transient) ' , async ( ) => {
230275 mockSsmSend . mockResolvedValueOnce ( { Status : 'Cancelling' , StatusDetails : 'Command is being cancelled' } ) ;
231276
232277 const strategy = new Ec2ComputeStrategy ( ) ;
233278 const result = await strategy . pollSession ( makeHandle ( ) ) ;
234- expect ( result ) . toEqual ( { status : 'failed' , error : 'Command is being cancelled ' } ) ;
279+ expect ( result ) . toEqual ( { status : 'running ' } ) ;
235280 } ) ;
236281
237282 test ( 'returns running for unknown status (default case)' , async ( ) => {
0 commit comments