From c748f6ea305f892c6e73f1cfb9ea2a51357ea39f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 28 Jul 2026 19:44:38 +0000
Subject: [PATCH 1/3] Initial plan


From 5114decb98ca882607dfcb1a2995dea686e28252 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 28 Jul 2026 19:59:06 +0000
Subject: [PATCH 2/3] feat: parallelize forecast usage-artifact downloads (8
 concurrent by default)

Fixes the serial artifact-download bottleneck in `gh aw forecast --days 30`
that caused the Daily Spending Forecast workflow to time out after 45 minutes.

Changes:
- Add `parallelLoadRunAICs` to forecast_compute.go: fetches AIC values for all
  completed runs concurrently using a semaphore (default: 8 goroutines).
- Update `forecastWorkflow` to call `parallelLoadRunAICs` instead of the
  previous serial `forecastLoadCachedRunAIC` loop.
- Add `DownloadConcurrency int` field to `ForecastConfig` and expose it via a
  new `--concurrency` CLI flag (0 = use default of 8).
- Add tests: parallel returns all values, respects cancellation, handles empty
  input, and verifies the new CLI flag is registered."

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 pkg/cli/forecast_command.go | 24 ++++++++-----
 pkg/cli/forecast_compute.go | 72 ++++++++++++++++++++++++++++++++++++-
 pkg/cli/forecast_test.go    | 72 +++++++++++++++++++++++++++++++++++++
 3 files changed, 158 insertions(+), 10 deletions(-)

diff --git a/pkg/cli/forecast_command.go b/pkg/cli/forecast_command.go
index 573c76d07e6..9aa7a145832 100644
--- a/pkg/cli/forecast_command.go
+++ b/pkg/cli/forecast_command.go
@@ -29,6 +29,9 @@ type ForecastConfig struct {
 	// TimeoutMinutes gracefully cancels forecast computation after the configured
 	// number of minutes. Zero disables timeout.
 	TimeoutMinutes int
+	// DownloadConcurrency is the maximum number of usage-artifact downloads to run in
+	// parallel. Zero or negative uses the default (defaultForecastDownloadConcurrency).
+	DownloadConcurrency int
 }
 
 // NewForecastCommand creates the forecast command.
@@ -84,20 +87,22 @@ Backtesting (--eval):
 			sampleSize, _ := cmd.Flags().GetInt("sample")
 			evalMode, _ := cmd.Flags().GetBool("eval")
 			timeoutMinutes, _ := cmd.Flags().GetInt("timeout")
+			downloadConcurrency, _ := cmd.Flags().GetInt("concurrency")
 
 			forecastRunLog.Printf("Forecast command invoked: workflow_count=%d, days=%d, period=%s, sample_size=%d, eval=%v, timeout_minutes=%d, json=%v, repo=%q",
 				len(args), days, period, sampleSize, evalMode, timeoutMinutes, jsonOutput, repoOverride)
 
 			config := ForecastConfig{
-				WorkflowIDs:    args,
-				Days:           days,
-				Period:         period,
-				JSONOutput:     jsonOutput,
-				Verbose:        verbose,
-				RepoOverride:   repoOverride,
-				SampleSize:     sampleSize,
-				EvalMode:       evalMode,
-				TimeoutMinutes: timeoutMinutes,
+				WorkflowIDs:         args,
+				Days:                days,
+				Period:              period,
+				JSONOutput:          jsonOutput,
+				Verbose:             verbose,
+				RepoOverride:        repoOverride,
+				SampleSize:          sampleSize,
+				EvalMode:            evalMode,
+				TimeoutMinutes:      timeoutMinutes,
+				DownloadConcurrency: downloadConcurrency,
 			}
 
 			return RunForecast(config)
@@ -109,6 +114,7 @@ Backtesting (--eval):
 	cmd.Flags().Int("sample", 100, "Maximum number of completed runs to sample per workflow")
 	cmd.Flags().Bool("eval", false, "Evaluate forecast quality against past data (backtesting mode)")
 	cmd.Flags().Int("timeout", 0, "Gracefully stop forecast computation after this many minutes (0 = no timeout)")
+	cmd.Flags().Int("concurrency", 0, "Maximum number of concurrent usage-artifact downloads (0 = use default)")
 	addRepoFlag(cmd)
 	addJSONFlag(cmd)
 
diff --git a/pkg/cli/forecast_compute.go b/pkg/cli/forecast_compute.go
index 0c17d99f036..8162e4bc272 100644
--- a/pkg/cli/forecast_compute.go
+++ b/pkg/cli/forecast_compute.go
@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"slices"
 	"sort"
+	"sync"
 	"time"
 
 	"github.com/github/gh-aw/pkg/console"
@@ -19,6 +20,13 @@ import (
 	"github.com/github/gh-aw/pkg/workflow"
 )
 
+// defaultForecastDownloadConcurrency is the number of usage-artifact downloads that
+// run in parallel when DownloadConcurrency is not set (or is <= 0).
+// Increasing this value reduces wall-clock time for the download phase at the cost of
+// more simultaneous GitHub API requests; 8 is chosen as a balance between throughput
+// and rate-limit headroom.
+const defaultForecastDownloadConcurrency = 8
+
 var (
 	forecastLoadCachedRunAIC = loadCachedRunAIC
 	// forecastDownloadRunArtifacts uses a forecast-specific implementation that downloads
@@ -93,8 +101,11 @@ func forecastWorkflow(ctx context.Context, workflowName, startDate string, confi
 	aicObservations := make([]int, 0, len(completed))
 	samples := make([]ForecastRunSample, 0, len(completed))
 
+	// Download usage artifacts in parallel, then process results in run order.
+	aicMap := parallelLoadRunAICs(ctx, completed, config)
+
 	for _, r := range completed {
-		runAIC := forecastLoadCachedRunAIC(ctx, r.DatabaseID, config.Verbose)
+		runAIC := aicMap[r.DatabaseID]
 		if runAIC <= 0 {
 			forecastRunLog.Printf("Skipping run %d for %s: AIC=%.3f treated as missing data", r.DatabaseID, workflowName, runAIC)
 			continue
@@ -178,6 +189,65 @@ func forecastWorkflow(ctx context.Context, workflowName, startDate string, confi
 	return result, nil
 }
 
+// parallelLoadRunAICs fetches AIC data for all completed runs concurrently and returns
+// a map from DatabaseID to AIC value. Downloads run at most concurrency goroutines at a
+// time; when config.DownloadConcurrency is <= 0, defaultForecastDownloadConcurrency is
+// used. Runs whose AIC cannot be determined (download failure, no artifact, context
+// cancelled) are omitted from the map so the caller's existing zero-AIC guard takes effect.
+func parallelLoadRunAICs(ctx context.Context, runs []WorkflowRun, config ForecastConfig) map[int64]float64 {
+	n := len(runs)
+	if n == 0 {
+		return make(map[int64]float64)
+	}
+	concurrency := config.DownloadConcurrency
+	if concurrency <= 0 {
+		concurrency = defaultForecastDownloadConcurrency
+	}
+	if concurrency > n {
+		concurrency = n
+	}
+
+	type aicResult struct {
+		runID int64
+		aic   float64
+	}
+
+	sem := make(chan struct{}, concurrency)
+	resultsCh := make(chan aicResult, n)
+	var wg sync.WaitGroup
+
+	for _, r := range runs {
+		if ctx.Err() != nil {
+			break
+		}
+		wg.Add(1)
+		runID := r.DatabaseID
+		go func() {
+			defer wg.Done()
+			// Acquire semaphore slot; abort if context is cancelled while waiting.
+			select {
+			case sem <- struct{}{}:
+			case <-ctx.Done():
+				return
+			}
+			defer func() { <-sem }()
+			aic := forecastLoadCachedRunAIC(ctx, runID, config.Verbose)
+			resultsCh <- aicResult{runID: runID, aic: aic}
+		}()
+	}
+
+	go func() {
+		wg.Wait()
+		close(resultsCh)
+	}()
+
+	aicMap := make(map[int64]float64, n)
+	for r := range resultsCh {
+		aicMap[r.runID] = r.aic
+	}
+	return aicMap
+}
+
 // loadCachedRunAIC looks up a locally-cached RunSummary for the given
 // run ID and returns the TotalAIC from its TokenUsage summary.
 // Returns 0 when no cache exists or the cache does not contain AIC data.
diff --git a/pkg/cli/forecast_test.go b/pkg/cli/forecast_test.go
index 310e1cdefc8..6cd7d8f157c 100644
--- a/pkg/cli/forecast_test.go
+++ b/pkg/cli/forecast_test.go
@@ -463,3 +463,75 @@ func TestLoadCachedRunAIC_MissingUsageReturnsZero(t *testing.T) {
 	require.False(t, analyzeCalled)
 	require.Equal(t, []string{"usage"}, downloaded)
 }
+
+// ── parallelLoadRunAICs ───────────────────────────────────────────────────────
+
+// TestParallelLoadRunAICs_ReturnsAllAICValues verifies that parallelLoadRunAICs collects
+// AIC values for every run, regardless of concurrency level.
+func TestParallelLoadRunAICs_ReturnsAllAICValues(t *testing.T) {
+	originalLoadAIC := forecastLoadCachedRunAIC
+	t.Cleanup(func() { forecastLoadCachedRunAIC = originalLoadAIC })
+
+	wantAIC := map[int64]float64{
+		1: 1.0,
+		2: 2.0,
+		3: 3.0,
+		4: 4.0,
+		5: 5.0,
+	}
+	forecastLoadCachedRunAIC = func(_ context.Context, runID int64, _ bool) float64 {
+		return wantAIC[runID]
+	}
+
+	runs := []WorkflowRun{
+		{DatabaseID: 1, Status: "completed", Conclusion: "success"},
+		{DatabaseID: 2, Status: "completed", Conclusion: "success"},
+		{DatabaseID: 3, Status: "completed", Conclusion: "failure"},
+		{DatabaseID: 4, Status: "completed", Conclusion: "success"},
+		{DatabaseID: 5, Status: "completed", Conclusion: "success"},
+	}
+
+	got := parallelLoadRunAICs(context.Background(), runs, ForecastConfig{DownloadConcurrency: 3})
+	assert.Equal(t, wantAIC, got)
+}
+
+// TestParallelLoadRunAICs_RespectsContextCancellation verifies that parallelLoadRunAICs
+// stops issuing new downloads and returns promptly when the context is cancelled.
+func TestParallelLoadRunAICs_RespectsContextCancellation(t *testing.T) {
+	originalLoadAIC := forecastLoadCachedRunAIC
+	t.Cleanup(func() { forecastLoadCachedRunAIC = originalLoadAIC })
+
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel() // cancel immediately
+
+	forecastLoadCachedRunAIC = func(_ context.Context, runID int64, _ bool) float64 {
+		return float64(runID)
+	}
+
+	runs := []WorkflowRun{
+		{DatabaseID: 10, Status: "completed", Conclusion: "success"},
+		{DatabaseID: 11, Status: "completed", Conclusion: "success"},
+	}
+
+	// Should return quickly without panicking, regardless of which goroutines completed.
+	got := parallelLoadRunAICs(ctx, runs, ForecastConfig{DownloadConcurrency: 1})
+	assert.NotNil(t, got)
+}
+
+// TestParallelLoadRunAICs_EmptyRunsReturnsEmptyMap verifies the empty-input edge case.
+func TestParallelLoadRunAICs_EmptyRunsReturnsEmptyMap(t *testing.T) {
+	got := parallelLoadRunAICs(context.Background(), nil, ForecastConfig{})
+	assert.Empty(t, got)
+}
+
+// TestNewForecastCommand_ConcurrencyFlag verifies that the --concurrency flag is
+// registered with the expected default and usage text.
+func TestNewForecastCommand_ConcurrencyFlag(t *testing.T) {
+	cmd := NewForecastCommand()
+	require.NotNil(t, cmd)
+
+	flag := cmd.Flags().Lookup("concurrency")
+	require.NotNil(t, flag, "forecast command should register --concurrency")
+	assert.Equal(t, "0", flag.DefValue)
+	assert.Contains(t, flag.Usage, "concurrent")
+}

From 15b1238f93a92f255acec6554256abac937cd8d9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 28 Jul 2026 20:13:59 +0000
Subject: [PATCH 3/3] feat: add prefetch step to daily-spending-forecast
 workflow

Add a "Prefetch forecast usage artifacts" pre-agent step that runs
gh aw forecast with --concurrency 8 and --timeout 25 (discarding output)
before the main forecast step. Because forecastDownloadUsageArtifact skips
re-downloading artifacts already on disk, the main forecast step reads from
the local cache and completes in seconds.

Also adds --concurrency 8 to the main forecast step and reduces its --timeout
from 30 to 10 minutes since data is warm from the prefetch.

Recompiled daily-spending-forecast.lock.yml.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 .../workflows/daily-spending-forecast.lock.yml |  9 +++++++--
 .github/workflows/daily-spending-forecast.md   | 18 +++++++++++++++++-
 2 files changed, 24 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/daily-spending-forecast.lock.yml b/.github/workflows/daily-spending-forecast.lock.yml
index 0cd348248a6..4796ddc7182 100644
--- a/.github/workflows/daily-spending-forecast.lock.yml
+++ b/.github/workflows/daily-spending-forecast.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c71bf5e959f0797b0ab629ade2fdd7d3fc4f32fe0d41f2d14f595f810d2737be","body_hash":"429ef32d5c19c0bc94b1369c81a228592956d158987096905e18916b4b0146b4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"635c48b3b9e85b28d5c706b240132b62cb23cccc2ff989cd119ceea08ee1262b","body_hash":"429ef32d5c19c0bc94b1369c81a228592956d158987096905e18916b4b0146b4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}}
 # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.42","digest":"sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.42@sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]}
 # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
 #
@@ -497,12 +497,17 @@ jobs:
           set -euo pipefail
           make build
           "$GITHUB_WORKSPACE/gh-aw" --version
+      - continue-on-error: true
+        env:
+          REPOSITORY: ${{ github.repository }}
+        name: Prefetch forecast usage artifacts
+        run: "# Download usage artifacts for the last 30 days in parallel so the main\n# forecast step reads from the local cache and produces output quickly.\nDEBUG='*' \"$GITHUB_WORKSPACE/gh-aw\" forecast \\\n  --repo \"$REPOSITORY\" \\\n  --days 30 \\\n  --sample 100 \\\n  --concurrency 8 \\\n  --timeout 25 \\\n  --verbose \\\n  > /dev/null 2>&1 || true\n"
       - continue-on-error: true
         env:
           REPOSITORY: ${{ github.repository }}
         id: spending_forecast
         name: Run spending forecast
-        run: "set -uo pipefail\noutput_dir=\"/tmp/gh-aw/agent/spending-forecast\"\nmkdir -p \"$output_dir\"\n\nset +e\nDEBUG='*' \"$GITHUB_WORKSPACE/gh-aw\" forecast \\\n  --repo \"$REPOSITORY\" \\\n  --days 30 \\\n  --period month \\\n  --sample 100 \\\n  --timeout 30 \\\n  --verbose \\\n  --json \\\n  > >(tee \"$output_dir/forecast.json\") \\\n  2> >(tee \"$output_dir/forecast.stderr.log\" >&2)\nexit_code=$?\nwait\nset -e\n\n{\n  printf 'exit_code=%s\\n' \"$exit_code\"\n  printf 'repository=%s\\n' \"$REPOSITORY\"\n  printf 'generated_at=%s\\n' \"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\"\n} > \"$output_dir/forecast-metadata.txt\"\n\n{\n  echo \"===== STDERR =====\"\n  cat \"$output_dir/forecast.stderr.log\"\n  echo\n  echo \"===== STDOUT =====\"\n  cat \"$output_dir/forecast.json\"\n} > \"$output_dir/forecast.full.log\""
+        run: "set -uo pipefail\noutput_dir=\"/tmp/gh-aw/agent/spending-forecast\"\nmkdir -p \"$output_dir\"\n\nset +e\nDEBUG='*' \"$GITHUB_WORKSPACE/gh-aw\" forecast \\\n  --repo \"$REPOSITORY\" \\\n  --days 30 \\\n  --period month \\\n  --sample 100 \\\n  --concurrency 8 \\\n  --timeout 10 \\\n  --verbose \\\n  --json \\\n  > >(tee \"$output_dir/forecast.json\") \\\n  2> >(tee \"$output_dir/forecast.stderr.log\" >&2)\nexit_code=$?\nwait\nset -e\n\n{\n  printf 'exit_code=%s\\n' \"$exit_code\"\n  printf 'repository=%s\\n' \"$REPOSITORY\"\n  printf 'generated_at=%s\\n' \"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\"\n} > \"$output_dir/forecast-metadata.txt\"\n\n{\n  echo \"===== STDERR =====\"\n  cat \"$output_dir/forecast.stderr.log\"\n  echo\n  echo \"===== STDOUT =====\"\n  cat \"$output_dir/forecast.json\"\n} > \"$output_dir/forecast.full.log\""
 
       - name: Configure Git credentials
         env:
diff --git a/.github/workflows/daily-spending-forecast.md b/.github/workflows/daily-spending-forecast.md
index bdc7932e091..51d539f327c 100644
--- a/.github/workflows/daily-spending-forecast.md
+++ b/.github/workflows/daily-spending-forecast.md
@@ -21,6 +21,21 @@ steps:
       set -euo pipefail
       make build
       "$GITHUB_WORKSPACE/gh-aw" --version
+  - name: Prefetch forecast usage artifacts
+    continue-on-error: true
+    env:
+      REPOSITORY: ${{ github.repository }}
+    run: |
+      # Download usage artifacts for the last 30 days in parallel so the main
+      # forecast step reads from the local cache and produces output quickly.
+      DEBUG='*' "$GITHUB_WORKSPACE/gh-aw" forecast \
+        --repo "$REPOSITORY" \
+        --days 30 \
+        --sample 100 \
+        --concurrency 8 \
+        --timeout 25 \
+        --verbose \
+        > /dev/null 2>&1 || true
   - name: Run spending forecast
     id: spending_forecast
     continue-on-error: true
@@ -37,7 +52,8 @@ steps:
         --days 30 \
         --period month \
         --sample 100 \
-        --timeout 30 \
+        --concurrency 8 \
+        --timeout 10 \
         --verbose \
         --json \
         > >(tee "$output_dir/forecast.json") \
