Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/smoke-copilot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 37 additions & 5 deletions docs/src/content/docs/reference/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ This ensures workflows on different issues, PRs, or branches run concurrently wi

## Per-Engine Concurrency

The default per-engine pattern `gh-aw-{engine-id}` ensures only one agent job runs per engine across all workflows, preventing AI resource exhaustion. The group includes only the engine ID and `gh-aw-` prefix - workflow name, issue/PR numbers, and branches are excluded.
By default, no job-level concurrency is applied to agent jobs (`engine.concurrency: none` is the default). This allows multiple agent jobs — even across different workflows — to execute in parallel without any engine-level serialization.

To opt in to job-level concurrency and limit how many agent jobs run simultaneously, set `engine.concurrency` explicitly:

```yaml wrap
jobs:
agent:
concurrency:
group: "gh-aw-{engine-id}"
---
engine:
id: copilot
concurrency: # Limit to one agent job per engine across all workflows
group: "gh-aw-copilot-${{ github.workflow }}"
---
```

## Custom Concurrency
Expand All @@ -53,6 +57,34 @@ tools:
---
```

## Disabling Job-Level Concurrency

Since `engine.concurrency: none` is the default, no action is required to run workflow dispatches in parallel. The workflow-level `concurrency` block is sufficient for per-issue isolation:

```yaml wrap
---
on:
workflow_dispatch:
inputs:
issue_number:
required: true
concurrency:
group: issue-triage-${{ github.event.inputs.issue_number }}
cancel-in-progress: true
engine:
id: copilot
# No engine.concurrency needed - none is the default
---
```

You can also be explicit by setting `engine.concurrency: none`:

```yaml wrap
engine:
id: copilot
concurrency: none # Explicit opt-out of job-level concurrency
```

## Related Documentation

- [AI Engines](/gh-aw/reference/engines/) - Engine configuration and capabilities
Expand Down
4 changes: 2 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7438,7 +7438,7 @@
"oneOf": [
{
"type": "string",
"description": "Simple concurrency group name. Gets converted to GitHub Actions concurrency format with the specified group."
"description": "Simple concurrency group name, or 'none' to disable the default job-level concurrency. Use 'none' when the workflow-level concurrency already provides the desired isolation (e.g., per-issue dispatch workflows). Gets converted to GitHub Actions concurrency format with the specified group."
},
{
"type": "object",
Expand All @@ -7457,7 +7457,7 @@
"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."
"description": "Agent job concurrency configuration. Defaults to single job per engine across all workflows (group: 'gh-aw-{engine-id}'). Set to 'none' to disable the default job-level concurrency and rely solely on workflow-level concurrency. Supports full GitHub Actions concurrency syntax."
},
"user-agent": {
"type": "string",
Expand Down
67 changes: 11 additions & 56 deletions pkg/workflow/concurrency.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,71 +38,26 @@ func GenerateConcurrencyConfig(workflowData *WorkflowData, isCommandTrigger bool
}

// GenerateJobConcurrencyConfig generates the agent concurrency configuration
// for the agent job based on engine.concurrency field
// for the agent job based on engine.concurrency field.
// By default, no job-level concurrency is applied (equivalent to engine.concurrency: none).
// Set engine.concurrency to a group name or object to opt in to job-level concurrency.
func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string {
concurrencyLog.Print("Generating job-level concurrency config")

// If concurrency is explicitly configured in engine, use it
if workflowData.EngineConfig != nil && workflowData.EngineConfig.Concurrency != "" {
// "none" is a special value to opt out of job-level concurrency (also the default)
if workflowData.EngineConfig.Concurrency == "none" {
concurrencyLog.Print("Engine concurrency set to none, skipping job concurrency")
return ""
}
concurrencyLog.Print("Using engine-configured concurrency")
return workflowData.EngineConfig.Concurrency
}

// Check if this workflow has special trigger handling (issues, PRs, discussions, push, command)
// For these cases, no default concurrency should be applied at agent level
if hasSpecialTriggers(workflowData) {
concurrencyLog.Print("Workflow has special triggers, skipping default job concurrency")
return ""
}

// For generic triggers like workflow_dispatch, apply default concurrency
// Pattern: gh-aw-{engine-id}-${{ github.workflow }}
engineID := ""
if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" {
engineID = workflowData.EngineConfig.ID
}

if engineID == "" {
// If no engine ID is available, skip default concurrency
return ""
}

// Build the default concurrency configuration
groupValue := fmt.Sprintf("gh-aw-%s-${{ github.workflow }}", engineID)
concurrencyConfig := fmt.Sprintf("concurrency:\n group: \"%s\"", groupValue)

return concurrencyConfig
}

// hasSpecialTriggers checks if the workflow has special trigger types that require
// workflow-level concurrency handling (issues, PRs, discussions, push, command)
func hasSpecialTriggers(workflowData *WorkflowData) bool {
// Check for specific trigger types that have special concurrency handling
on := workflowData.On

// Check for issue-related triggers
if isIssueWorkflow(on) {
return true
}

// Check for pull request triggers
if isPullRequestWorkflow(on) {
return true
}

// Check for discussion triggers
if isDiscussionWorkflow(on) {
return true
}

// Check for push triggers
if isPushWorkflow(on) {
return true
}

// If none of the special triggers are detected, return false
// This means workflow_dispatch and other generic triggers will get default concurrency
return false
// Default: no job-level concurrency
concurrencyLog.Print("No engine concurrency configured, skipping job concurrency (default)")
return ""
}

// isPullRequestWorkflow checks if a workflow's "on" section contains pull_request triggers
Expand Down
30 changes: 18 additions & 12 deletions pkg/workflow/concurrency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,24 +316,22 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) {
description string
}{
{
name: "Default concurrency for workflow_dispatch with copilot engine",
name: "No default concurrency for workflow_dispatch with copilot engine",
workflowData: &WorkflowData{
On: "on:\n workflow_dispatch:",
EngineConfig: &EngineConfig{ID: "copilot"},
},
expected: `concurrency:
group: "gh-aw-copilot-${{ github.workflow }}"`,
description: "Copilot with workflow_dispatch should get default concurrency",
expected: "",
description: "Copilot with workflow_dispatch should NOT get default job-level concurrency",
},
{
name: "Default concurrency for workflow_dispatch with claude engine",
name: "No default concurrency for workflow_dispatch with claude engine",
workflowData: &WorkflowData{
On: "on:\n workflow_dispatch:",
EngineConfig: &EngineConfig{ID: "claude"},
},
expected: `concurrency:
group: "gh-aw-claude-${{ github.workflow }}"`,
description: "Claude with workflow_dispatch should get default concurrency",
expected: "",
description: "Claude with workflow_dispatch should NOT get default job-level concurrency",
},
{
name: "No default concurrency for push workflows",
Expand Down Expand Up @@ -382,14 +380,22 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) {
description: "Should preserve cancel-in-progress when specified",
},
{
name: "Default concurrency for schedule with codex engine",
name: "No default concurrency for schedule with codex engine",
workflowData: &WorkflowData{
On: "on:\n schedule:\n - cron: '0 0 * * *'",
EngineConfig: &EngineConfig{ID: "codex"},
},
expected: `concurrency:
group: "gh-aw-codex-${{ github.workflow }}"`,
description: "Codex with schedule should get default concurrency",
expected: "",
description: "Codex with schedule should NOT get default job-level concurrency",
},
{
name: "No concurrency when engine.concurrency is set to none",
workflowData: &WorkflowData{
On: "on:\n workflow_dispatch:",
EngineConfig: &EngineConfig{ID: "copilot", Concurrency: "none"},
},
expected: "",
description: "engine.concurrency: none should disable default job-level concurrency",
},
}

Expand Down
9 changes: 7 additions & 2 deletions pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,13 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng
// 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)
if concurrencyStr == "none" {
// Special value to opt out of default job-level concurrency
config.Concurrency = "none"
} else {
// 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
Expand Down