1818 */
1919
2020import { ECSClient , RunTaskCommand , DescribeTasksCommand , StopTaskCommand } from '@aws-sdk/client-ecs' ;
21+ import { S3Client , PutObjectCommand , DeleteObjectCommand } from '@aws-sdk/client-s3' ;
2122import type { ComputeStrategy , SessionHandle , SessionStatus } from '../compute-strategy' ;
2223import { logger } from '../logger' ;
2324import type { BlueprintConfig } from '../repo-config' ;
@@ -30,11 +31,60 @@ function getClient(): ECSClient {
3031 return sharedClient ;
3132}
3233
34+ let sharedS3Client : S3Client | undefined ;
35+ function getS3Client ( ) : S3Client {
36+ if ( ! sharedS3Client ) {
37+ sharedS3Client = new S3Client ( { } ) ;
38+ }
39+ return sharedS3Client ;
40+ }
41+
3342const ECS_CLUSTER_ARN = process . env . ECS_CLUSTER_ARN ;
3443const ECS_TASK_DEFINITION_ARN = process . env . ECS_TASK_DEFINITION_ARN ;
3544const ECS_SUBNETS = process . env . ECS_SUBNETS ;
3645const ECS_SECURITY_GROUP = process . env . ECS_SECURITY_GROUP ;
3746const ECS_CONTAINER_NAME = process . env . ECS_CONTAINER_NAME ?? 'AgentContainer' ;
47+ const ECS_PAYLOAD_BUCKET = process . env . ECS_PAYLOAD_BUCKET ;
48+
49+ /**
50+ * Inline-payload size (bytes) above which we warn that RunTask will likely
51+ * reject the call when no payload bucket is configured. ECS caps the TOTAL
52+ * containerOverrides blob at 8192 bytes; the other env vars + command consume
53+ * some of that, so 6 KB of payload is the practical danger line (#502).
54+ */
55+ const INLINE_PAYLOAD_WARN_BYTES = 6144 ;
56+
57+ /**
58+ * S3 object key for a task's ECS payload. One object per task under its own
59+ * task-id prefix; deleted by the orchestrator at finalize (see
60+ * ``deleteEcsPayload``), with the bucket's 1-day lifecycle rule as a backstop.
61+ */
62+ export function ecsPayloadKey ( taskId : string ) : string {
63+ return `${ taskId } /payload.json` ;
64+ }
65+
66+ /**
67+ * Delete a task's ECS payload object. Best-effort: a failed delete must never
68+ * fail the task — the bucket's 1-day lifecycle rule reaps it regardless. Called
69+ * from the orchestrator's ``finalize`` step once the task is terminal. No-ops
70+ * when the payload bucket isn't configured (AgentCore-only deployments).
71+ */
72+ export async function deleteEcsPayload ( taskId : string ) : Promise < void > {
73+ if ( ! ECS_PAYLOAD_BUCKET ) return ;
74+ try {
75+ await getS3Client ( ) . send ( new DeleteObjectCommand ( {
76+ Bucket : ECS_PAYLOAD_BUCKET ,
77+ Key : ecsPayloadKey ( taskId ) ,
78+ } ) ) ;
79+ logger . info ( 'Deleted ECS payload object' , { task_id : taskId } ) ;
80+ } catch ( err ) {
81+ // Non-fatal — the lifecycle rule is the backstop.
82+ logger . warn ( 'Failed to delete ECS payload object (non-fatal)' , {
83+ task_id : taskId ,
84+ error : err instanceof Error ? err . message : String ( err ) ,
85+ } ) ;
86+ }
87+ }
3888
3989export class EcsComputeStrategy implements ComputeStrategy {
4090 readonly type = 'ecs' ;
@@ -63,6 +113,37 @@ export class EcsComputeStrategy implements ComputeStrategy {
63113 // This avoids the server entirely and runs the agent in batch mode.
64114 const payloadJson = JSON . stringify ( payload ) ;
65115
116+ // #502: the payload (esp. hydrated_context) routinely exceeds the 8192-byte
117+ // cap that ECS RunTask enforces on the TOTAL containerOverrides blob, which
118+ // rejected the call with InvalidParameterException. Write the payload to S3
119+ // and pass only a small pointer (AGENT_PAYLOAD_S3_URI); the container fetches
120+ // it on boot. The inline AGENT_PAYLOAD remains as a fallback for small
121+ // payloads / deployments without a payload bucket configured.
122+ let payloadS3Uri : string | undefined ;
123+ if ( ECS_PAYLOAD_BUCKET ) {
124+ const key = ecsPayloadKey ( taskId ) ;
125+ await getS3Client ( ) . send ( new PutObjectCommand ( {
126+ Bucket : ECS_PAYLOAD_BUCKET ,
127+ Key : key ,
128+ Body : payloadJson ,
129+ ContentType : 'application/json' ,
130+ } ) ) ;
131+ payloadS3Uri = `s3://${ ECS_PAYLOAD_BUCKET } /${ key } ` ;
132+ logger . info ( 'Wrote ECS payload to S3' , {
133+ task_id : taskId ,
134+ bytes : payloadJson . length ,
135+ uri : payloadS3Uri ,
136+ } ) ;
137+ } else if ( payloadJson . length > INLINE_PAYLOAD_WARN_BYTES ) {
138+ // No bucket configured AND the payload is large enough that the inline
139+ // path will almost certainly blow the 8192-byte overrides cap. Surface a
140+ // clear cause rather than a raw InvalidParameterException from RunTask.
141+ logger . warn ( 'ECS payload is large but ECS_PAYLOAD_BUCKET is not set — RunTask may reject it (see #502)' , {
142+ task_id : taskId ,
143+ bytes : payloadJson . length ,
144+ } ) ;
145+ }
146+
66147 const containerEnv = [
67148 { name : 'TASK_ID' , value : taskId } ,
68149 { name : 'REPO_URL' , value : String ( payload . repo_url ?? '' ) } ,
@@ -73,26 +154,35 @@ export class EcsComputeStrategy implements ComputeStrategy {
73154 ...( blueprintConfig . model_id ? [ { name : 'ANTHROPIC_MODEL' , value : blueprintConfig . model_id } ] : [ ] ) ,
74155 ...( blueprintConfig . system_prompt_overrides ? [ { name : 'SYSTEM_PROMPT_OVERRIDES' , value : blueprintConfig . system_prompt_overrides } ] : [ ] ) ,
75156 { name : 'CLAUDE_CODE_USE_BEDROCK' , value : '1' } ,
76- // Full orchestrator payload as JSON — the Python wrapper reads this to
77- // call run_task() with all fields including hydrated_context.
78- { name : 'AGENT_PAYLOAD' , value : payloadJson } ,
157+ // #502: prefer the S3 pointer; fall back to the inline payload when no
158+ // bucket is configured (keeps small-payload / AgentCore-only deployments
159+ // working with no behavior change).
160+ ...( payloadS3Uri
161+ ? [ { name : 'AGENT_PAYLOAD_S3_URI' , value : payloadS3Uri } ]
162+ : [ { name : 'AGENT_PAYLOAD' , value : payloadJson } ] ) ,
79163 ...( payload . github_token_secret_arn
80164 ? [ { name : 'GITHUB_TOKEN_SECRET_ARN' , value : String ( payload . github_token_secret_arn ) } ]
81165 : [ ] ) ,
82166 ...( payload . memory_id ? [ { name : 'MEMORY_ID' , value : String ( payload . memory_id ) } ] : [ ] ) ,
83167 ] ;
84168
85169 // Override the container command to run a Python one-liner that:
86- // 1. Reads the AGENT_PAYLOAD env var (full orchestrator payload JSON)
87- // 2. Calls entrypoint.run_task() directly with all fields
88- // 3. Exits with code 0 on success, 1 on failure
170+ // 1. Loads the payload — from S3 (AGENT_PAYLOAD_S3_URI) when set, else the
171+ // inline AGENT_PAYLOAD env var (fallback).
172+ // 2. Calls entrypoint.run_task() directly with all fields.
173+ // 3. Exits with code 0 on success, 1 on failure.
89174 // This bypasses the uvicorn server entirely — no HTTP, no OTEL noise.
90175 const bootCommand = [
91176 'python' , '-c' ,
92177 'import json, os, sys; '
93178 + 'sys.path.insert(0, "/app/src"); '
94179 + 'from entrypoint import run_task; '
95- + 'p = json.loads(os.environ["AGENT_PAYLOAD"]); '
180+ + '_uri = os.environ.get("AGENT_PAYLOAD_S3_URI"); '
181+ + 'p = ('
182+ + 'json.loads(__import__("boto3").client("s3").get_object('
183+ + 'Bucket=_uri.split("/",3)[2], Key=_uri.split("/",3)[3])["Body"].read()) '
184+ + 'if _uri else json.loads(os.environ["AGENT_PAYLOAD"])'
185+ + '); '
96186 + 'r = run_task('
97187 + 'repo_url=p.get("repo_url",""), '
98188 + 'task_description=p.get("prompt",""), '
0 commit comments