diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index ee2bffc46c7..a787a5ec98d 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -119,6 +119,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" @@ -2544,6 +2546,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index d5bc95f4e60..4b1b1da7397 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -428,6 +428,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"missing-tool\":{}}" @@ -2943,6 +2945,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 0f839cfe986..6fd642433a4 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -70,6 +70,8 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"create-issue\":{\"max\":1},\"missing-tool\":{}}" @@ -2638,6 +2640,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index dddb4801cbf..5bad832fd33 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -122,6 +122,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" @@ -2721,6 +2723,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index 25dcaa2246d..38a889809bf 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -453,6 +453,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"missing-tool\":{}}" @@ -3051,6 +3053,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 4b92e64a784..ffe0dd5b5b5 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -436,6 +436,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":3,\"target\":\"*\"},\"add-labels\":{\"allowed\":[\"poetry\",\"creative\",\"automation\",\"ai-generated\",\"epic\",\"haiku\",\"sonnet\",\"limerick\"],\"max\":5},\"create-issue\":{\"max\":2},\"create-pull-request\":{},\"create-pull-request-review-comment\":{\"max\":2},\"missing-tool\":{},\"push-to-pull-request-branch\":{},\"update-issue\":{\"max\":2},\"upload-asset\":{}}" @@ -3121,6 +3123,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index f53ee7f28ab..85cee61d8d1 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -468,6 +468,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"missing-tool\":{}}" @@ -3056,6 +3058,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index b6391103354..ad483863f8c 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -289,6 +289,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-pull-request\":{},\"missing-tool\":{},\"push-to-pull-request-branch\":{}}" @@ -2886,6 +2888,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/docs/src/content/docs/reference/concurrency.md b/docs/src/content/docs/reference/concurrency.md index f95d255396f..7912a2c2dc6 100644 --- a/docs/src/content/docs/reference/concurrency.md +++ b/docs/src/content/docs/reference/concurrency.md @@ -1,65 +1,75 @@ --- title: Concurrency Control -description: Complete guide to concurrency control in GitHub Agentic Workflows, including max-concurrency configuration, global locks, and engine isolation. +description: Complete guide to concurrency control in GitHub Agentic Workflows, including agent job concurrency configuration and engine isolation. sidebar: order: 6 --- -GitHub Agentic Workflows provides sophisticated concurrency control to manage how many AI-powered workflows can run simultaneously. This helps prevent resource exhaustion, control costs, and ensure predictable workflow execution. +GitHub Agentic Workflows provides sophisticated concurrency control to manage how many AI-powered agent jobs can run simultaneously. This helps prevent resource exhaustion, control costs, and ensure predictable workflow execution. ## Overview -Concurrency control in GitHub Agentic Workflows uses a dual-level approach with different strategies at each level: +Concurrency control in GitHub Agentic Workflows uses a dual-level approach: - **Workflow-level concurrency**: Context-specific limiting based on workflow type (issue, PR, branch, etc.) -- **Agent concurrency (max-concurrency)**: Global limiting across all workflows using the same engine +- **Agent job concurrency**: Controls concurrent execution of agent jobs using the `engine.concurrency` field -This dual-level approach provides both fine-grained control per workflow and global resource management across all workflows. +This dual-level approach provides both fine-grained control per workflow and flexible resource management for AI execution. -## Max Concurrency Configuration +## Agent Job Concurrency Configuration -The `max-concurrency` option is configured under the `engine` section and controls how many agentic jobs can run concurrently across **all workflows** in your repository: +The `concurrency` field under the `engine` section controls concurrency for the agent job. It uses GitHub Actions concurrency syntax: ```yaml engine: id: claude - max-concurrency: 5 + concurrency: + group: "my-group-${{ github.workflow }}" + cancel-in-progress: true ``` -### Default Value +### Default Behavior -- **Default**: 3 concurrent slots (when not specified or set to 0) -- **Minimum**: 1 (sequential execution) -- **Disabled**: -1 (no agent concurrency limiting) -- **No maximum**: Set to any positive integer based on your needs +**Default:** Single job per engine across all workflows + +When no `engine.concurrency` is specified, the default pattern is: +```yaml +concurrency: + group: "gh-aw-{engine-id}" +``` + +This ensures only one agent job runs at a time for each engine across all workflows and refs, preventing resource exhaustion. ### Configuration Examples -**Sequential execution (one at a time):** +**Default (single job per engine):** ```yaml engine: - id: copilot - max-concurrency: 1 + id: claude + # No concurrency specified - uses gh-aw-claude ``` -**Moderate parallelism (default):** +**Per-workflow concurrency:** ```yaml engine: id: claude - # max-concurrency not specified, defaults to 3 + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" ``` -**High parallelism for busy repositories:** +**Per-branch concurrency with cancellation:** ```yaml engine: - id: claude - max-concurrency: 10 + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.ref }}" + cancel-in-progress: true ``` -**Disable agent concurrency limiting:** +**Simple string format:** ```yaml engine: id: claude - max-concurrency: -1 # No global limiting, only workflow-level concurrency applies + concurrency: "custom-group-${{ github.workflow }}" ``` ## How It Works @@ -95,26 +105,28 @@ concurrency: This ensures workflows operating on different issues, PRs, or branches can run concurrently without interfering with each other. -### Agent Concurrency (Max-Concurrency) +### Agent Job Concurrency -The agent concurrency uses **only** the engine ID and slot number for global limiting: +The agent job concurrency is configured via `engine.concurrency` and uses the specified pattern: +**Default pattern (single job per engine):** ```yaml jobs: agent: concurrency: - group: "gh-aw-{engine-id}-${{ github.run_id % max-concurrency }}" + group: "gh-aw-{engine-id}" ``` -**Example for Claude with max-concurrency of 5:** +**Custom pattern example:** ```yaml jobs: agent: concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" + group: "custom-${{ github.workflow }}" + cancel-in-progress: true ``` -This creates a global lock across **all workflows and refs** for each engine, preventing resource exhaustion from too many concurrent AI executions. +This controls concurrent execution of agent jobs across all workflows, preventing resource exhaustion from too many concurrent AI executions. ### Complete Example @@ -136,64 +148,55 @@ jobs: agent: runs-on: ubuntu-latest permissions: read-all - # Agent concurrency: Global max-concurrency limiting + # Agent concurrency: Default single job per engine concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" + group: "gh-aw-claude" steps: - name: Execute workflow ... ``` -### Slot Distribution - -Workflows are distributed across available slots using modulo arithmetic: -- `github.run_id % max-concurrency` calculates the slot number (0 to max-concurrency-1) -- Each slot can only run one workflow at a time -- Workflows are automatically assigned to the next available slot - -**Example with max-concurrency: 3** -- Run ID 1001 → Slot 2 (`1001 % 3 = 2`) -- Run ID 1002 → Slot 0 (`1002 % 3 = 0`) -- Run ID 1003 → Slot 1 (`1003 % 3 = 1`) -- Run ID 1004 → Slot 2 (`1004 % 3 = 2`) - ### Dual-Level Application The dual-level concurrency provides complementary control: 1. **Workflow-level**: Prevents conflicts between runs of the same workflow on different contexts (e.g., different issues or PRs) -2. **Agent concurrency**: Prevents resource exhaustion by limiting total concurrent AI executions across all workflows +2. **Agent job concurrency**: Controls concurrent execution of agent jobs based on configured pattern **Example scenario:** - 5 different issues trigger the same workflow - Workflow-level concurrency allows all 5 to start (different issue numbers) -- Agent concurrency with max-concurrency (e.g., 3) ensures only 3 AI jobs run simultaneously -- The other 2 workflows queue until slots become available +- Agent job concurrency with default `gh-aw-claude` means only 1 agent job runs at a time +- The other 4 workflows queue until the agent job completes This approach balances: - **Workflow isolation**: Different contexts don't block each other at the workflow level -- **Global resource management**: Total AI resource usage is controlled at the agent level +- **Resource management**: Agent job execution is controlled via concurrency configuration ## Global Lock Behavior -The **agent concurrency** (max-concurrency) uses **only** engine ID and slot number, creating a true global lock: +The **agent job concurrency** creates a lock based on the configured pattern: -### What's Included in Agent Concurrency +### What's Included in Default Agent Concurrency - ✅ Engine ID (`copilot`, `claude`, `codex`) -- ✅ Slot number (from `run_id % max-concurrency`) - ✅ `gh-aw-` prefix -### What's NOT Included in Agent Concurrency +### What's NOT Included in Default Agent Concurrency - ❌ Workflow name - ❌ Issue number - ❌ Pull request number - ❌ Branch/ref name - ❌ Event type -This ensures the max-concurrency limit applies **repository-wide** across all workflows and refs for each engine. +The default pattern `gh-aw-{engine-id}` ensures only one agent job runs per engine across **all workflows and refs**. -**Disabling Agent Concurrency**: -Set `max-concurrency: -1` to disable agent concurrency limiting entirely. When disabled, only workflow-level concurrency applies, and there is no global limit on concurrent AI executions across workflows. +You can customize this behavior by specifying a different `engine.concurrency` pattern: +```yaml +engine: + id: claude + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" # Per-workflow concurrency +``` ### Workflow-Level Concurrency Includes Context @@ -213,16 +216,16 @@ Different engines can run concurrently without interfering with each other: # Workflow A uses Copilot engine: id: copilot - max-concurrency: 3 + # Default: gh-aw-copilot # Workflow B uses Claude engine: id: claude - max-concurrency: 5 + # Default: gh-aw-claude ``` -- Copilot workflows have their own 3-slot concurrency pool -- Claude workflows have their own 5-slot concurrency pool +- Copilot agent jobs use the `gh-aw-copilot` concurrency group +- Claude agent jobs use the `gh-aw-claude` concurrency group - Both can run simultaneously without conflict ## Cancellation Behavior @@ -244,28 +247,28 @@ concurrency: ## Benefits ### Cost Control -- **Prevents runaway costs**: Limits the number of concurrent AI executions -- **Predictable spending**: Maximum concurrent workflows are known in advance -- **Flexible budgeting**: Adjust limits based on repository needs +- **Prevents runaway costs**: Controls concurrent AI job execution +- **Predictable resource usage**: Known concurrency patterns +- **Flexible configuration**: Customize per workflow or engine ### Resource Management -- **Prevents resource exhaustion**: Ensures system stability -- **Fair resource distribution**: Workflows queue when slots are full -- **Maintains throughput**: Multiple workflows can still run concurrently +- **Prevents resource exhaustion**: Ensures system stability with default single-job-per-engine pattern +- **Fair resource distribution**: Agent jobs queue when concurrency limit is reached +- **Maintains throughput**: Activation and other jobs continue running ### Engine Isolation -- **Independent limits**: Each engine has its own concurrency pool -- **No cross-engine interference**: Copilot workflows don't block Claude workflows -- **Flexible configuration**: Different limits for different engines +- **Independent limits**: Each engine has its own default concurrency group +- **No cross-engine interference**: Copilot agent jobs don't block Claude agent jobs +- **Flexible configuration**: Customize concurrency per engine ### Simplicity -- **Global lock**: Same limit across all workflows and refs -- **Automatic distribution**: No manual slot assignment needed +- **Default global lock**: Single job per engine by default +- **Standard GitHub Actions syntax**: Familiar concurrency configuration - **Consistent behavior**: Predictable execution patterns ## Custom Concurrency -You can override the automatic concurrency generation by specifying your own `concurrency` section in the frontmatter: +You can override the automatic concurrency generation by specifying your own `concurrency` section in the frontmatter (for workflow-level concurrency): ```yaml --- @@ -273,78 +276,93 @@ on: push concurrency: group: custom-group-${{ github.ref }} cancel-in-progress: true +engine: + id: claude + concurrency: # Agent job concurrency (separate from workflow concurrency) + group: "custom-agent-${{ github.workflow }}" tools: github: allowed: [list_issues] --- ``` -**Note**: Custom concurrency bypasses the max-concurrency limit and engine isolation features. +**Note**: Workflow-level concurrency and agent job concurrency are independent and can be configured separately. ## Best Practices -### Setting Max Concurrency +### Configuring Agent Concurrency -**Start conservative:** +**Default (recommended for most cases):** ```yaml engine: id: claude - max-concurrency: 1 # Start with sequential execution + # No concurrency specified - single job per engine ``` -**Increase as needed:** +**Allow per-workflow concurrency:** ```yaml engine: id: claude - max-concurrency: 5 # Increase after monitoring costs and performance + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" +``` + +**Per-branch concurrency for PR workflows:** +```yaml +engine: + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.ref }}" + cancel-in-progress: true ``` -### Different Limits for Different Engines +### Different Patterns for Different Engines -**Cost-sensitive engine:** +**Conservative engine (default):** ```yaml engine: - id: claude # Expensive model - max-concurrency: 2 + id: claude + # No concurrency - uses gh-aw-claude (single job) ``` -**Budget-friendly engine:** +**More permissive engine:** ```yaml engine: - id: copilot # More affordable - max-concurrency: 5 + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" # Per-workflow concurrency ``` ### Monitoring and Adjustment 1. **Monitor workflow execution**: Use GitHub Actions insights 2. **Track costs**: Review AI model usage and expenses -3. **Adjust limits**: Increase or decrease based on needs -4. **Test changes**: Validate new limits with test workflows +3. **Adjust patterns**: Change concurrency groups based on needs +4. **Test changes**: Validate new patterns with test workflows ## Troubleshooting -### Workflows Queuing +### Agent Jobs Queuing -**Symptom**: Workflows wait in queue instead of running +**Symptom**: Agent jobs wait in queue instead of running -**Cause**: All concurrency slots are full +**Cause**: Concurrency group is blocking (e.g., default single-job-per-engine pattern) **Solution**: -- Increase `max-concurrency` value -- Check for long-running workflows +- Customize `engine.concurrency` to allow more parallel execution +- Use per-workflow or per-branch patterns if appropriate - Consider using different engines for different workflows ### Too Many Concurrent Runs -**Symptom**: High costs or resource usage +**Symptom**: High costs or resource usage from concurrent agent jobs -**Cause**: `max-concurrency` set too high +**Cause**: Concurrency pattern allows too many parallel executions **Solution**: -- Decrease `max-concurrency` value +- Use more restrictive concurrency pattern (e.g., default `gh-aw-{engine-id}`) - Monitor usage patterns -- Set appropriate limits per engine +- Set appropriate patterns per engine ### Workflows Not Canceling diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 5d57903f20d..ebd831397f6 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -465,70 +465,83 @@ engine: 3. Helps prevent runaway chat loops and control costs 4. Only applies to engines that support turn limiting (currently Claude) -### Concurrency Limiting +### Agent Job Concurrency -The `max-concurrency` option limits how many agentic jobs can run concurrently across **all workflows** in your repository: +The `concurrency` field in the engine configuration controls how many agent jobs can run concurrently. It uses GitHub Actions concurrency syntax: ```yaml engine: id: claude - max-concurrency: 5 + concurrency: + group: "custom-group-${{ github.workflow }}" + cancel-in-progress: true ``` -**Default Value:** 3 (if not specified) +**Default Behavior:** Single job per engine across all workflows (group: `gh-aw-{engine-id}`) **How it works:** -- Uses GitHub Actions concurrency groups with slot distribution for global limiting -- Workflows are distributed across available slots using `github.run_id % max-concurrency` -- Each slot can only run one workflow at a time across ALL workflows and refs -- Includes engine ID in concurrency group for isolation between different engines -- Prevents resource exhaustion from too many concurrent AI executions +- Creates a concurrency group for the agent job +- Default pattern: `gh-aw-{engine-id}` ensures one job per engine across all workflows +- Supports full GitHub Actions concurrency syntax (group + optional cancel-in-progress) +- Different engines (claude, copilot, codex) can run concurrently without interfering -**Example configurations:** +**Simple string format:** +```yaml +engine: + id: claude + concurrency: "my-custom-group-${{ github.ref }}" +``` + +This is converted to: +```yaml +concurrency: + group: "my-custom-group-${{ github.ref }}" +``` +**Object format (full control):** ```yaml -# Allow up to 10 concurrent Claude workflows engine: id: claude - max-concurrency: 10 + concurrency: + group: "my-group-${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true ``` +**Example configurations:** + ```yaml -# Restrict to 1 workflow at a time (sequential execution) +# Default: single job per engine across all workflows engine: - id: copilot - max-concurrency: 1 + id: claude + # No concurrency specified, uses gh-aw-claude ``` ```yaml -# Use default of 3 concurrent workflows +# Allow multiple concurrent jobs with different workflow names engine: id: claude - # max-concurrency not specified, defaults to 3 + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" ``` -**Generated concurrency group pattern:** ```yaml -# At workflow level -concurrency: - group: "gh-aw-{engine-id}-${{ github.run_id % max-concurrency }}" +# Per-branch concurrency +engine: + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.ref }}" + cancel-in-progress: true +``` -# At job level (agentic job) +**Generated concurrency in agent job:** +```yaml jobs: agent: concurrency: - group: "gh-aw-{engine-id}-${{ github.run_id % max-concurrency }}" + group: "gh-aw-claude" # Default pattern ``` -Example for claude with max-concurrency of 5: -```yaml -concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" -``` - -The concurrency group uses **only** the engine ID and slot number (prefixed with "gh-aw-"), creating a global lock across all workflows and refs for that engine. This ensures the max-concurrency limit applies repository-wide. - -The concurrency is applied at **both workflow and job level** for maximum control. +The concurrency group applies **only** to the agent job (not the workflow level). This ensures concurrency control for AI execution while allowing activation jobs to run freely. ## Tools Configuration (`tools:`) @@ -577,30 +590,36 @@ timeout_minutes: 30 # Defaults to 15 minutes ## Concurrency Control (`concurrency:`) -GitHub Agentic Workflows automatically generates concurrency policies to limit concurrent execution across all workflows using the same engine. +GitHub Agentic Workflows automatically generates concurrency policies for the agent job to control concurrent execution. -See [Concurrency Control](/gh-aw/reference/concurrency/) for complete documentation on max-concurrency configuration, global locks, and engine isolation. +See [Concurrency Control](/gh-aw/reference/concurrency/) for complete documentation on agent concurrency configuration. **Quick reference:** -- Configure via `engine.max-concurrency` (default: 3) -- Creates global lock across all workflows and refs for each engine -- Applied at both workflow and job levels +- Configure via `engine.concurrency` field +- Default: Single job per engine across all workflows (group: `gh-aw-{engine-id}`) +- Applied at the agent job level only - Different engines can run concurrently without interfering +- Supports full GitHub Actions concurrency syntax **Example:** ```yaml engine: id: claude - max-concurrency: 5 + concurrency: + group: "custom-${{ github.workflow }}" + cancel-in-progress: true ``` -Generates: +Generates agent job concurrency: ```yaml -concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" +jobs: + agent: + concurrency: + group: "custom-${{ github.workflow }}" + cancel-in-progress: true ``` -You can override automatic concurrency by specifying a custom `concurrency` section in the frontmatter. +You can also override workflow-level concurrency by specifying a custom `concurrency` section in the frontmatter (separate from engine concurrency). ## Environment Variables (`env:`) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index e541c7cc260..7631ba345ec 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2264,10 +2264,30 @@ "type": "integer", "description": "Maximum number of chat iterations per run. Helps prevent runaway loops and control costs. Has sensible defaults and can typically be omitted." }, - "max-concurrency": { - "type": "integer", - "minimum": -1, - "description": "Maximum number of agentic jobs that can run concurrently across all workflows using this engine. Defaults to 3. Set to -1 to disable agent concurrency limiting. Uses GitHub Actions concurrency controls with slot-based distribution." + "concurrency": { + "oneOf": [ + { + "type": "string", + "description": "Simple concurrency group name. Gets converted to GitHub Actions concurrency format with the specified group." + }, + { + "type": "object", + "description": "GitHub Actions concurrency configuration for the agent job. Controls how many agent jobs can run concurrently.", + "properties": { + "group": { + "type": "string", + "description": "Concurrency group identifier. Use GitHub Actions expressions like ${{ github.workflow }} or ${{ github.ref }}. Defaults to 'gh-aw-{engine-id}' if not specified." + }, + "cancel-in-progress": { + "type": "boolean", + "description": "Whether to cancel in-progress runs of the same concurrency group. Defaults to false for agent jobs." + } + }, + "required": ["group"], + "additionalProperties": false + } + ], + "description": "Agent job concurrency configuration. Defaults to single job per engine across all workflows (group: 'gh-aw-{engine-id}'). Supports full GitHub Actions concurrency syntax." }, "user-agent": { "type": "string", diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 3985cc37d07..e3a3f76898f 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -66,6 +66,10 @@ type CodingAgentEngine interface { // GetVersionCommand returns the command to get the version of the agent (e.g., "copilot --version") // Returns empty string if the engine does not support version reporting GetVersionCommand() string + + // HasDefaultConcurrency returns true if this engine should have default concurrency mode enabled + // Default concurrency mode applies gh-aw-{engine-id} pattern when no custom concurrency is configured + HasDefaultConcurrency() bool } // ErrorPattern represents a regex pattern for extracting error information from logs @@ -93,6 +97,7 @@ type BaseEngine struct { supportsMaxTurns bool supportsWebFetch bool supportsWebSearch bool + hasDefaultConcurrency bool } func (e *BaseEngine) GetID() string { @@ -146,6 +151,11 @@ func (e *BaseEngine) GetVersionCommand() string { return "" } +// HasDefaultConcurrency returns the configured value for default concurrency mode +func (e *BaseEngine) HasDefaultConcurrency() bool { + return e.hasDefaultConcurrency +} + // EngineRegistry manages available agentic engines type EngineRegistry struct { engines map[string]CodingAgentEngine diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index f90827fcc0b..d440fb42768 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -26,10 +26,11 @@ func NewClaudeEngine() *ClaudeEngine { description: "Uses Claude Code with full MCP tool support and allow-listing", experimental: false, supportsToolsAllowlist: true, - supportsHTTPTransport: true, // Claude supports both stdio and HTTP transport - supportsMaxTurns: true, // Claude supports max-turns feature - supportsWebFetch: true, // Claude has built-in WebFetch support - supportsWebSearch: true, // Claude has built-in WebSearch support + supportsHTTPTransport: true, // Claude supports both stdio and HTTP transport + supportsMaxTurns: true, // Claude supports max-turns feature + supportsWebFetch: true, // Claude has built-in WebFetch support + supportsWebSearch: true, // Claude has built-in WebSearch support + hasDefaultConcurrency: false, // Claude does NOT have default concurrency enabled }, } } diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 26e3b374977..75f984072ea 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -51,6 +51,7 @@ func NewCodexEngine() *CodexEngine { supportsMaxTurns: false, // Codex does not support max-turns feature supportsWebFetch: false, // Codex does not have built-in web-fetch support supportsWebSearch: true, // Codex has built-in web-search support + hasDefaultConcurrency: false, // Codex does NOT have default concurrency enabled }, } } diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 6aa08d1f496..58e0a7fdfac 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -118,7 +118,7 @@ type WorkflowData struct { On string Permissions string Network string // top-level network permissions configuration - Concurrency string + Concurrency string // workflow-level concurrency configuration RunName string Env string If string @@ -2016,8 +2016,8 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( } } - // Generate agent concurrency for max-concurrency feature - //agentConcurrency := GenerateJobConcurrencyConfig(data) + // Generate agent concurrency configuration + agentConcurrency := GenerateJobConcurrencyConfig(data) job := &Job{ Name: constants.AgentJobName, @@ -2027,7 +2027,7 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( Container: c.indentYAMLLines(data.Container, " "), Services: c.indentYAMLLines(data.Services, " "), Permissions: c.indentYAMLLines(data.Permissions, " "), - Concurrency: "", // c.indentYAMLLines(agentConcurrency, " "), + Concurrency: c.indentYAMLLines(agentConcurrency, " "), Env: env, Steps: steps, Needs: depends, diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index 4a858383c5d..a8ec1f85451 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -29,37 +29,37 @@ func GenerateConcurrencyConfig(workflowData *WorkflowData, isCommandTrigger bool } // GenerateJobConcurrencyConfig generates the agent concurrency configuration -// for max-concurrency limiting across all workflows using the same engine +// for the agent job based on engine.concurrency field func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string { - // Check if max-concurrency is -1 (disabled) - if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxConcurrency == -1 { - return "" // Don't emit agent concurrency when disabled + // If concurrency is explicitly configured in engine, use it + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Concurrency != "" { + return workflowData.EngineConfig.Concurrency } - // Build agent concurrency for max-concurrency feature - // This uses ONLY engine ID (or custom concurrency-group) and run_id slot for global limiting - var keys []string + // Check if the engine has default concurrency enabled + engineID := "" + if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { + engineID = workflowData.EngineConfig.ID + } - // Prepend with gh-aw- prefix - keys = append(keys, "gh-aw") + // Get the engine to check if default concurrency should be applied + registry := GetGlobalEngineRegistry() + engine, err := registry.GetEngine(engineID) - // Use custom concurrency-group if provided, otherwise use engine ID - if workflowData.EngineConfig != nil && workflowData.EngineConfig.ConcurrencyGroup != "" { - keys = append(keys, workflowData.EngineConfig.ConcurrencyGroup) - } else if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { - keys = append(keys, workflowData.EngineConfig.ID) + // If engine not found or doesn't have default concurrency, return empty string (no concurrency) + if err != nil || !engine.HasDefaultConcurrency() { + return "" } - // Add max-concurrency slot to the group - maxConcurrency := 3 // default value - if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxConcurrency > 0 { - maxConcurrency = workflowData.EngineConfig.MaxConcurrency - } + // Default behavior: single job per engine across all workflows + // Pattern: gh-aw-{engine-id} + var keys []string + + // Prepend with gh-aw- prefix + keys = append(keys, "gh-aw") - // Add a slot number based on run_id to distribute workflows across concurrency slots - // This implements a simple round-robin distribution using modulo - slotKey := fmt.Sprintf("${{ github.run_id %% %d }}", maxConcurrency) - keys = append(keys, slotKey) + // Use engine ID for isolation between different engines + keys = append(keys, engineID) groupValue := strings.Join(keys, "-") diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index 71ca797fd51..3ee2b3270ce 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -307,7 +307,7 @@ func TestGenerateConcurrencyConfig(t *testing.T) { } } -// TestGenerateJobConcurrencyConfig tests the job-level concurrency configuration for max-concurrency +// TestGenerateJobConcurrencyConfig tests the job-level concurrency configuration for agent jobs func TestGenerateJobConcurrencyConfig(t *testing.T) { tests := []struct { name string @@ -316,48 +316,57 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { description string }{ { - name: "Default max-concurrency (3) with copilot engine", + name: "Default concurrency with copilot engine", workflowData: &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, }, expected: `concurrency: - group: "gh-aw-copilot-${{ github.run_id % 3 }}"`, - description: "Should use default max-concurrency of 3 with copilot engine ID", + group: "gh-aw-copilot"`, + description: "Copilot should use default pattern gh-aw-{engine-id} (has default concurrency enabled)", }, { - name: "Custom max-concurrency value should be used", + name: "No default concurrency with claude engine", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "claude", MaxConcurrency: 5}, + EngineConfig: &EngineConfig{ID: "claude"}, }, - expected: `concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}"`, - description: "Custom max-concurrency should use specified value instead of default", + expected: "", + description: "Claude should NOT have default concurrency (returns empty string)", }, { - name: "Zero max-concurrency should use default (3)", + name: "Custom concurrency string (simple group)", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "copilot", MaxConcurrency: 0}, // 0 means use default + EngineConfig: &EngineConfig{ + ID: "claude", + Concurrency: `concurrency: + group: "custom-group-${{ github.ref }}"`, + }, }, expected: `concurrency: - group: "gh-aw-copilot-${{ github.run_id % 3 }}"`, - description: "Zero max-concurrency should default to 3", + group: "custom-group-${{ github.ref }}"`, + description: "Should use custom concurrency when specified", }, { - name: "Different engine ID should be included in concurrency group", + name: "Custom concurrency with cancel-in-progress", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "codex"}, + EngineConfig: &EngineConfig{ + ID: "copilot", + Concurrency: `concurrency: + group: "custom-group" + cancel-in-progress: true`, + }, }, expected: `concurrency: - group: "gh-aw-codex-${{ github.run_id % 3 }}"`, - description: "Different engine IDs should be included in concurrency group for isolation", + group: "custom-group" + cancel-in-progress: true`, + description: "Should preserve cancel-in-progress when specified", }, { - name: "Max-concurrency -1 should disable agent concurrency", + name: "No default concurrency with codex engine", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "claude", MaxConcurrency: -1}, + EngineConfig: &EngineConfig{ID: "codex"}, }, expected: "", - description: "Max-concurrency -1 should return empty string (no agent concurrency)", + description: "Codex should NOT have default concurrency (returns empty string)", }, } diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index e6f2c1e6e9e..5b8cb11d186 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -29,6 +29,7 @@ func NewCopilotEngine() *CopilotEngine { supportsMaxTurns: false, // Copilot CLI does not support max-turns feature yet supportsWebFetch: false, // Copilot CLI does not have built-in web-fetch support supportsWebSearch: false, // Copilot CLI does not have built-in web-search support + hasDefaultConcurrency: true, // Copilot HAS default concurrency enabled }, } } diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 30e75f9c8a4..1f38e1f9d02 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -23,6 +23,7 @@ func NewCustomEngine() *CustomEngine { supportsMaxTurns: true, // Custom engine supports max-turns for consistency supportsWebFetch: false, // Custom engine does not have built-in web-fetch support supportsWebSearch: false, // Custom engine does not have built-in web-search support + hasDefaultConcurrency: false, // Custom engine does NOT have default concurrency enabled }, } } diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 90f15e7ea42..b311fa811da 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -7,17 +7,16 @@ import ( // EngineConfig represents the parsed engine configuration type EngineConfig struct { - ID string - Version string - Model string - MaxTurns string - MaxConcurrency int - ConcurrencyGroup string - UserAgent string - Env map[string]string - Steps []map[string]any - ErrorPatterns []ErrorPattern - Config string + ID string + Version string + Model string + MaxTurns string + Concurrency string // Agent job-level concurrency configuration (YAML format) + UserAgent string + Env map[string]string + Steps []map[string]any + ErrorPatterns []ErrorPattern + Config string } // NetworkPermissions represents network access permissions @@ -76,21 +75,29 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } - // Extract optional 'max-concurrency' field - if maxConcurrency, hasMaxConcurrency := engineObj["max-concurrency"]; hasMaxConcurrency { - if maxConcurrencyInt, ok := maxConcurrency.(int); ok { - config.MaxConcurrency = maxConcurrencyInt - } else if maxConcurrencyFloat, ok := maxConcurrency.(float64); ok { - config.MaxConcurrency = int(maxConcurrencyFloat) - } else if maxConcurrencyUint64, ok := maxConcurrency.(uint64); ok { - config.MaxConcurrency = int(maxConcurrencyUint64) - } - } - - // Extract optional 'concurrency-group' field - if concurrencyGroup, hasConcurrencyGroup := engineObj["concurrency-group"]; hasConcurrencyGroup { - if concurrencyGroupStr, ok := concurrencyGroup.(string); ok { - config.ConcurrencyGroup = concurrencyGroupStr + // Extract optional 'concurrency' field (string or object format) + if concurrency, hasConcurrency := engineObj["concurrency"]; hasConcurrency { + if concurrencyStr, ok := concurrency.(string); ok { + // Simple string format (group name) + config.Concurrency = fmt.Sprintf("concurrency:\n group: \"%s\"", concurrencyStr) + } else if concurrencyObj, ok := concurrency.(map[string]any); ok { + // Object format with group and optional cancel-in-progress + var parts []string + if group, hasGroup := concurrencyObj["group"]; hasGroup { + if groupStr, ok := group.(string); ok { + parts = append(parts, fmt.Sprintf("concurrency:\n group: \"%s\"", groupStr)) + } + } + if cancel, hasCancel := concurrencyObj["cancel-in-progress"]; hasCancel { + if cancelBool, ok := cancel.(bool); ok && cancelBool { + if len(parts) > 0 { + parts[0] += "\n cancel-in-progress: true" + } + } + } + if len(parts) > 0 { + config.Concurrency = parts[0] + } } } diff --git a/pkg/workflow/engine_concurrency_integration_test.go b/pkg/workflow/engine_concurrency_integration_test.go new file mode 100644 index 00000000000..b258fd9986d --- /dev/null +++ b/pkg/workflow/engine_concurrency_integration_test.go @@ -0,0 +1,144 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEngineConcurrencyIntegration(t *testing.T) { + tests := []struct { + name string + markdown string + expectedInJob string + notExpectedInJob string + description string + }{ + { + name: "Copilot engine has default concurrency", + markdown: `--- +on: push +engine: + id: copilot +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, + expectedInJob: `concurrency: + group: "gh-aw-copilot"`, + description: "Copilot should use default pattern gh-aw-{engine-id}", + }, + { + name: "Claude engine does NOT have default concurrency", + markdown: `--- +on: push +engine: + id: claude +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, + notExpectedInJob: `concurrency:`, + description: "Claude should NOT have default concurrency in agent job", + }, + { + name: "Custom concurrency with string format", + markdown: `--- +on: push +engine: + id: claude + concurrency: "custom-${{ github.ref }}" +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, + expectedInJob: `concurrency: + group: "custom-${{ github.ref }}"`, + description: "Should use custom concurrency group from string format", + }, + { + name: "Custom concurrency with object format", + markdown: `--- +on: push +engine: + id: claude + concurrency: + group: "my-group-${{ github.workflow }}" + cancel-in-progress: true +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, + expectedInJob: `concurrency: + group: "my-group-${{ github.workflow }}" + cancel-in-progress: true`, + description: "Should use custom concurrency with cancel-in-progress", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory and file + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + if err := os.WriteFile(workflowPath, []byte(tt.markdown), 0644); err != nil { + t.Fatalf("Failed to write test workflow: %v", err) + } + + // Compile workflow + compiler := NewCompiler(false, "", "test") + err := compiler.CompileWorkflow(workflowPath) + if err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + // Check if expected concurrency is in the job section + if tt.expectedInJob != "" && !strings.Contains(string(lockContent), tt.expectedInJob) { + t.Errorf("Compiled workflow doesn't contain expected concurrency\nExpected to find:\n%s\n\nFull output:\n%s", + tt.expectedInJob, string(lockContent)) + } + + // Check that notExpectedInJob is NOT in the agent job section + if tt.notExpectedInJob != "" { + // Extract agent job section + agentJobStart := strings.Index(string(lockContent), "agent:") + if agentJobStart == -1 { + t.Fatalf("Could not find agent job in compiled workflow") + } + // Find the next job (or end of file) + nextJobStart := strings.Index(string(lockContent)[agentJobStart+10:], "\n ") + agentJobSection := "" + if nextJobStart == -1 { + agentJobSection = string(lockContent)[agentJobStart:] + } else { + agentJobSection = string(lockContent)[agentJobStart : agentJobStart+10+nextJobStart] + } + + if strings.Contains(agentJobSection, tt.notExpectedInJob) { + t.Errorf("Compiled workflow contains unexpected content in agent job\nDid not expect to find:\n%s\n\nAgent job section:\n%s", + tt.notExpectedInJob, agentJobSection) + } + } + }) + } +} diff --git a/pkg/workflow/engine_concurrency_test.go b/pkg/workflow/engine_concurrency_test.go new file mode 100644 index 00000000000..c2ced91fcb0 --- /dev/null +++ b/pkg/workflow/engine_concurrency_test.go @@ -0,0 +1,94 @@ +package workflow + +import ( + "testing" +) + +func TestExtractEngineConcurrencyField(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + frontmatter map[string]any + expectedConcurrency string + description string + }{ + { + name: "Simple string concurrency (just group name)", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": "custom-group-${{ github.ref }}", + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group-${{ github.ref }}\"", + description: "String concurrency should be converted to proper YAML format", + }, + { + name: "Object format concurrency with group only", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": map[string]any{ + "group": "custom-group", + }, + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group\"", + description: "Object with group only should generate proper YAML", + }, + { + name: "Object format concurrency with group and cancel-in-progress", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": map[string]any{ + "group": "custom-group", + "cancel-in-progress": true, + }, + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group\"\n cancel-in-progress: true", + description: "Object with cancel-in-progress should include it in YAML", + }, + { + name: "Object format concurrency with cancel-in-progress false", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": map[string]any{ + "group": "custom-group", + "cancel-in-progress": false, + }, + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group\"", + description: "Object with cancel-in-progress false should not include it", + }, + { + name: "No concurrency field", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + }, + }, + expectedConcurrency: "", + description: "Missing concurrency field should return empty string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, config := compiler.ExtractEngineConfig(tt.frontmatter) + + if config == nil { + t.Fatalf("Expected config to be non-nil") + } + + if config.Concurrency != tt.expectedConcurrency { + t.Errorf("ExtractEngineConfig() failed for %s\nExpected:\n%s\nGot:\n%s", + tt.description, tt.expectedConcurrency, config.Concurrency) + } + }) + } +} diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index 60c98434ca7..9ad0116d7e8 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -87,11 +87,15 @@ func (c *Compiler) buildThreatDetectionJob(data *WorkflowData, mainJobName strin // Build steps using a more structured approach steps := c.buildThreatDetectionSteps(data, mainJobName) + // Generate agent concurrency configuration (same as main agent job) + agentConcurrency := GenerateJobConcurrencyConfig(data) + job := &Job{ Name: constants.DetectionJobName, If: "", RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions: read-all", + Concurrency: c.indentYAMLLines(agentConcurrency, " "), TimeoutMinutes: 10, Steps: steps, Needs: []string{mainJobName},