@@ -95,9 +95,9 @@ export class EcsService {
9595 * AWS_REGION, MARATHON_MATCH_API_URL, REVIEW_TYPE_ID, and Auth0 M2M settings
9696 * used by the runner to refresh tokens during long scoring tasks. Optional
9797 * ECS_SCORER_MAX_CONCURRENT_TASKS controls the global pending/running scorer
98- * task cap and defaults to 20.
99- * @throws Error when required ENV vars are missing, token fetch fails, the scorer task cap is reached,
100- * or ECS launch/cancellation fails.
98+ * task cap when the role can list active ECS tasks and defaults to 20.
99+ * @throws Error when required ENV vars are missing, token fetch fails, the scorer task cap is reached
100+ * after active task lookup succeeds, or ECS launch/cancellation fails.
101101 */
102102 async launchScorerTask (
103103 challengeId : string ,
@@ -130,53 +130,58 @@ export class EcsService {
130130
131131 try {
132132 return await this . runWithScorerLaunchLock ( async ( ) => {
133- const activeTasks = await this . listActiveScorerTasks (
133+ const activeTasks = await this . listActiveScorerTasksIfPermitted (
134134 cluster ,
135135 taskDefinitionName ,
136136 containerName ,
137137 ) ;
138- const launchableActiveTasks =
139- await this . stopSupersededMemberScorerTasks ( cluster , activeTasks , {
138+ const launchableActiveTasks = activeTasks
139+ ? await this . stopSupersededMemberScorerTasks ( cluster , activeTasks , {
140+ challengeId,
141+ submissionId,
142+ memberId,
143+ } )
144+ : null ;
145+ let maxConcurrentScorerTasks : number | null = null ;
146+
147+ if ( launchableActiveTasks ) {
148+ const duplicateTask = this . findDuplicateActiveScorerTask (
149+ launchableActiveTasks ,
140150 challengeId ,
141151 submissionId ,
142- memberId,
143- } ) ;
144- const duplicateTask = this . findDuplicateActiveScorerTask (
145- launchableActiveTasks ,
146- challengeId ,
147- submissionId ,
148- scoringPhase . configType ,
149- ) ;
150-
151- if ( duplicateTask ) {
152- const launchResult = await this . buildLaunchResultFromActiveTask (
153- duplicateTask ,
154- taskDefinition ,
155- containerName ,
152+ scoringPhase . configType ,
156153 ) ;
157- await this . persistSubmissionRunnerLogMapping ( {
158- challengeId,
159- submissionId,
160- scoringPhase,
161- launchResult,
162- } ) ;
163- this . logger . log ( {
164- message : 'Skipped duplicate ECS scorer task launch' ,
165- challengeId,
166- submissionId,
167- memberId : memberId ?? null ,
168- phaseConfigType : scoringPhase . configType ,
169- taskArn : launchResult . taskArn ,
170- taskId : launchResult . taskId ,
171- } ) ;
172- return launchResult ;
173- }
174154
175- const maxConcurrentScorerTasks = this . getMaxConcurrentScorerTasks ( ) ;
176- if ( launchableActiveTasks . length >= maxConcurrentScorerTasks ) {
177- throw new Error (
178- `ECS scorer task concurrency limit reached (${ launchableActiveTasks . length } /${ maxConcurrentScorerTasks } ). Deferring submission ${ submissionId } to Kafka retry/back-pressure instead of launching another task.` ,
179- ) ;
155+ if ( duplicateTask ) {
156+ const launchResult = await this . buildLaunchResultFromActiveTask (
157+ duplicateTask ,
158+ taskDefinition ,
159+ containerName ,
160+ ) ;
161+ await this . persistSubmissionRunnerLogMapping ( {
162+ challengeId,
163+ submissionId,
164+ scoringPhase,
165+ launchResult,
166+ } ) ;
167+ this . logger . log ( {
168+ message : 'Skipped duplicate ECS scorer task launch' ,
169+ challengeId,
170+ submissionId,
171+ memberId : memberId ?? null ,
172+ phaseConfigType : scoringPhase . configType ,
173+ taskArn : launchResult . taskArn ,
174+ taskId : launchResult . taskId ,
175+ } ) ;
176+ return launchResult ;
177+ }
178+
179+ maxConcurrentScorerTasks = this . getMaxConcurrentScorerTasks ( ) ;
180+ if ( launchableActiveTasks . length >= maxConcurrentScorerTasks ) {
181+ throw new Error (
182+ `ECS scorer task concurrency limit reached (${ launchableActiveTasks . length } /${ maxConcurrentScorerTasks } ). Deferring submission ${ submissionId } to Kafka retry/back-pressure instead of launching another task.` ,
183+ ) ;
184+ }
180185 }
181186
182187 const token = await this . getM2MTokenForScorerLaunch ( ) ;
@@ -300,7 +305,8 @@ export class EcsService {
300305 logStreamPrefix : logStreamPrefix ?? null ,
301306 logStreamName : logStreamName ?? null ,
302307 cloudWatchLogsConsoleUrl : cloudWatchLogsConsoleUrl ?? null ,
303- activeScorerTaskCountBeforeLaunch : launchableActiveTasks . length ,
308+ activeScorerTaskCountBeforeLaunch :
309+ launchableActiveTasks ?. length ?? null ,
304310 maxConcurrentScorerTasks,
305311 } ) ;
306312
@@ -522,6 +528,43 @@ export class EcsService {
522528 return parsedLimit ;
523529 }
524530
531+ /**
532+ * Lists active scorer tasks when ECS permissions allow it; otherwise logs and
533+ * returns null so scorer dispatch can continue through RunTask.
534+ * @param cluster ECS cluster name or ARN.
535+ * @param taskDefinitionName Task definition family configured for scoring.
536+ * @param containerName Runner container name whose overrides identify scorer tasks.
537+ * @returns Active scorer tasks, or null when listing/describing active tasks is not authorized.
538+ * @throws Error for non-permission ECS lookup failures.
539+ */
540+ private async listActiveScorerTasksIfPermitted (
541+ cluster : string ,
542+ taskDefinitionName : string ,
543+ containerName : string ,
544+ ) : Promise < ActiveScorerTask [ ] | null > {
545+ try {
546+ return await this . listActiveScorerTasks (
547+ cluster ,
548+ taskDefinitionName ,
549+ containerName ,
550+ ) ;
551+ } catch ( error ) {
552+ if ( ! this . isEcsPermissionError ( error ) ) {
553+ throw error ;
554+ }
555+
556+ this . logger . warn ( {
557+ message :
558+ 'Unable to inspect active ECS scorer tasks; launching without duplicate or concurrency checks.' ,
559+ cluster,
560+ taskDefinitionName,
561+ containerName,
562+ error : error instanceof Error ? error . message : String ( error ) ,
563+ } ) ;
564+ return null ;
565+ }
566+ }
567+
525568 /**
526569 * Lists active scorer tasks for the configured task family and container.
527570 * @param cluster ECS cluster name or ARN.
@@ -586,6 +629,40 @@ export class EcsService {
586629 return activeTasks ;
587630 }
588631
632+ /**
633+ * Detects AWS authorization failures for optional ECS inspection calls.
634+ * @param error Error thrown by the AWS SDK.
635+ * @returns True when the error represents a denied ECS action.
636+ * Used by scorer launches to fall back to direct RunTask dispatch when active
637+ * task inspection is not permitted in an environment.
638+ */
639+ private isEcsPermissionError ( error : unknown ) : boolean {
640+ const typedError = error as {
641+ name ?: unknown ;
642+ Code ?: unknown ;
643+ code ?: unknown ;
644+ message ?: unknown ;
645+ } ;
646+ const values = [
647+ typedError . name ,
648+ typedError . Code ,
649+ typedError . code ,
650+ typedError . message ,
651+ ]
652+ . map ( ( value ) =>
653+ typeof value === 'string' ? value . trim ( ) . toLowerCase ( ) : '' ,
654+ )
655+ . filter ( Boolean ) ;
656+
657+ return values . some (
658+ ( value ) =>
659+ value . includes ( 'accessdenied' ) ||
660+ value . includes ( 'access denied' ) ||
661+ value . includes ( 'not authorized' ) ||
662+ value . includes ( 'unauthorized' ) ,
663+ ) ;
664+ }
665+
589666 /**
590667 * Converts an ECS task description into active scorer task metadata.
591668 * @param cluster ECS cluster used for the task.
0 commit comments