From 7525d79ec186a37195498426fe21828ac42ab880 Mon Sep 17 00:00:00 2001 From: riddhibhagwat-db Date: Wed, 22 Jul 2026 18:08:17 +0000 Subject: [PATCH] AIR CLI: always upload requirements.yaml on the run submit path The AI Runtime launcher reads /requirements.yaml for every run. The Go CLI only uploaded it when a requirements file or inline dependencies were declared, so a run with no dependencies (e.g. a plain command) shipped no requirements.yaml and failed at launch with RUN_EXECUTION_ERROR. Jobs then retried, and the retries surfaced an unrelated PrincipalContext propagation error, masking the real cause. Always upload requirements.yaml: the declared file, a synthesized doc from inline dependencies, or an empty doc when neither is set. This matches the Python CLI, which synthesizes an empty requirements.yaml for the AI Runtime path. Co-authored-by: Isaac --- experimental/air/cmd/runupload.go | 18 +++++++++++------- experimental/air/cmd/runupload_test.go | 4 +++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/experimental/air/cmd/runupload.go b/experimental/air/cmd/runupload.go index fb9ca00b987..b02077c4f2d 100644 --- a/experimental/air/cmd/runupload.go +++ b/experimental/air/cmd/runupload.go @@ -72,6 +72,9 @@ func buildArtifacts(cfg *runConfig, configPath string) ([]uploadItem, error) { {commandScriptName, []byte(*cfg.Command)}, } + // The AI Runtime launcher always reads /requirements.yaml, so always + // upload one: the declared file, a synthesized one from inline dependencies, or + // an empty doc. Without it the task fails at launch. switch reqPath, ok := cfg.requirementsFile(); { case ok: // Resolve a relative requirements path against the config's directory. @@ -84,14 +87,15 @@ func buildArtifacts(cfg *runConfig, configPath string) ([]uploadItem, error) { } items = append(items, uploadItem{requirementsName, data}) default: - if deps, ok := cfg.inlineDependencies(); ok { - version, _ := cfg.runtimeVersion() - data, err := yaml.Marshal(requirementsDoc{Version: version, Dependencies: deps}) - if err != nil { - return nil, fmt.Errorf("failed to synthesize requirements.yaml: %w", err) - } - items = append(items, uploadItem{requirementsName, data}) + // deps is nil when no inline dependencies are set, which marshals to an + // empty dependency list. + deps, _ := cfg.inlineDependencies() + version, _ := cfg.runtimeVersion() + data, err := yaml.Marshal(requirementsDoc{Version: version, Dependencies: deps}) + if err != nil { + return nil, fmt.Errorf("failed to synthesize requirements.yaml: %w", err) } + items = append(items, uploadItem{requirementsName, data}) } if len(cfg.Parameters) > 0 { diff --git a/experimental/air/cmd/runupload_test.go b/experimental/air/cmd/runupload_test.go index 0c87524735d..eeb11460998 100644 --- a/experimental/air/cmd/runupload_test.go +++ b/experimental/air/cmd/runupload_test.go @@ -52,9 +52,11 @@ func TestBuildArtifacts_CommandAndConfig(t *testing.T) { items, err := buildArtifacts(cfg, path) require.NoError(t, err) - assert.Equal(t, []string{trainingConfigName, commandScriptName}, itemNames(items)) + // requirements.yaml is always uploaded; with no dependencies it is an empty doc. + assert.Equal(t, []string{trainingConfigName, commandScriptName, requirementsName}, itemNames(items)) assert.Equal(t, minimalConfig, string(items[0].data)) assert.Equal(t, "python train.py", string(items[1].data)) + assert.Equal(t, "dependencies: []\n", string(items[2].data)) } func TestBuildArtifacts_InlineRequirementsAndParameters(t *testing.T) {