From 8e0e3c99bea1ab65a535a00ccf5ffc623589da6c Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 21 Jun 2026 11:36:13 +0300 Subject: [PATCH 1/5] fix(dispatch): per-role two-layer concurrency for per-repo (#981) Remove the monolithic per-repo shim concurrency group and add matching cancel-in-progress groups on reusable-dispatch stage jobs plus all reusable-{stage}.yml workflows so roles dedupe independently. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/reusable-code.yml | 11 +- .github/workflows/reusable-dispatch.yml | 22 +++ .github/workflows/reusable-fix.yml | 18 ++- .github/workflows/reusable-prioritize.yml | 11 +- .github/workflows/reusable-retro.yml | 11 +- .github/workflows/reusable-review.yml | 11 +- .github/workflows/reusable-triage.yml | 11 +- .../templates/shim-per-repo.yaml | 7 +- internal/scaffold/scaffold_test.go | 15 +++ .../scaffold/workflow_call_alignment_test.go | 125 +++++++++++++++++- 10 files changed, 224 insertions(+), 18 deletions(-) diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index 6172e7be1..9a1d9b4ab 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -1,7 +1,14 @@ -# Reusable code agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable code agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). +# +# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch +# stage job). Latest code dispatch for an issue wins. name: Code Agent +concurrency: + group: fullsend-code-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-dispatch.yml b/.github/workflows/reusable-dispatch.yml index d669cec94..b3920d135 100644 --- a/.github/workflows/reusable-dispatch.yml +++ b/.github/workflows/reusable-dispatch.yml @@ -3,6 +3,10 @@ # workflow_call jobs. This is the per-repo equivalent of the per-org # dispatch.yml + thin caller pair. # +# Concurrency: each stage job declares a per-role cancel-in-progress group +# (mirrored on reusable-{stage}.yml). Roles operate independently — review +# dispatches do not cancel triage, code, fix, etc. +# # Flow: shim (per-repo) → reusable-dispatch.yml → reusable-{stage}.yml # Nesting: 3 levels of workflow_call (within GitHub's 4-level limit) # @@ -339,6 +343,9 @@ jobs: name: Triage needs: route if: needs.route.outputs.stage == 'triage' + concurrency: + group: fullsend-triage-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: true # @v0 is hardcoded — GHA does not support expressions in uses:. # fullsend_ai_ref controls the ref for composite actions inside stage workflows. uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0 @@ -359,6 +366,9 @@ jobs: name: Code needs: route if: needs.route.outputs.stage == 'code' + concurrency: + group: fullsend-code-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: true uses: fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0 with: event_type: ${{ github.event_name }} @@ -377,6 +387,9 @@ jobs: name: Review needs: route if: needs.route.outputs.stage == 'review' + concurrency: + group: fullsend-review-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true uses: fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0 with: event_type: ${{ github.event_name }} @@ -395,6 +408,9 @@ jobs: name: Fix needs: route if: needs.route.outputs.stage == 'fix' + concurrency: + group: fullsend-fix-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true uses: fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0 with: event_type: ${{ github.event_name }} @@ -414,6 +430,9 @@ jobs: name: Retro needs: route if: needs.route.outputs.stage == 'retro' + concurrency: + group: fullsend-retro-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true uses: fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0 with: event_type: ${{ github.event_name }} @@ -432,6 +451,9 @@ jobs: name: Prioritize needs: route if: needs.route.outputs.stage == 'prioritize' + concurrency: + group: fullsend-prioritize-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: true uses: fullsend-ai/fullsend/.github/workflows/reusable-prioritize.yml@v0 with: event_type: ${{ github.event_name }} diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index a42f9e378..2a3975500 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -1,7 +1,21 @@ -# Reusable fix agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable fix agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). +# +# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch +# stage job). A human /fs-fix cancels any running fix so the human's +# instruction takes immediate effect. Bot-triggered runs also cancel previous +# bot runs on the same PR. name: Fix Agent +concurrency: + group: >- + fullsend-fix-${{ inputs.source_repo }}-${{ + fromJSON(inputs.event_payload).pull_request.number + || fromJSON(inputs.event_payload).issue.number + || inputs.pr_number + }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-prioritize.yml b/.github/workflows/reusable-prioritize.yml index 8cfac73fb..a6a2126be 100644 --- a/.github/workflows/reusable-prioritize.yml +++ b/.github/workflows/reusable-prioritize.yml @@ -1,7 +1,14 @@ -# Reusable prioritize agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable prioritize agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). +# +# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch +# stage job). Latest prioritize dispatch for an issue wins. name: Prioritize Agent +concurrency: + group: fullsend-prioritize-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index 1111857a9..ce19c03f4 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -1,7 +1,14 @@ -# Reusable retro agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable retro agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). +# +# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch +# stage job). Latest retro dispatch for a PR/issue wins. name: Retro Agent +concurrency: + group: fullsend-retro-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index 2f3159fb1..56cdecaa0 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -1,7 +1,14 @@ -# Reusable review agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable review agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). +# +# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch +# stage job). Latest review dispatch for a PR/issue wins. name: Review Agent +concurrency: + group: fullsend-review-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index af1dedbf6..4dfd02cc1 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -1,7 +1,14 @@ -# Reusable triage agent workflow. Called by thin callers in .fullsend repos -# via workflow_call. Runs in the caller's repo context (secrets, checkout). +# Reusable triage agent workflow. Called by dispatch workflows via workflow_call. +# Runs in the caller's repo context (secrets, checkout). +# +# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch +# stage job). Latest triage dispatch for an issue wins. name: Triage Agent +concurrency: + group: fullsend-triage-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + on: workflow_call: inputs: diff --git a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml index d8c36fbda..f9360cb1a 100644 --- a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml +++ b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml @@ -13,6 +13,10 @@ # which determines the stage and conditionally calls the appropriate # reusable-{stage}.yml workflow. Adding a new stage requires only a case # branch in reusable-dispatch.yml — zero changes to this repo. +# +# Concurrency: per-role cancel-in-progress groups live in reusable-dispatch.yml +# stage jobs and reusable-{stage}.yml workflows — not on this shim. A monolithic +# shim group would serialize unrelated roles and drop pending runs (#2452). name: fullsend permissions: @@ -35,9 +39,6 @@ on: jobs: dispatch: - concurrency: - group: fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number }} - cancel-in-progress: false if: >- github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot' diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 0ca8f6c0d..6acaa564d 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -145,6 +145,21 @@ func TestShimWorkflowCallTemplateContent(t *testing.T) { assert.NotContains(t, s, "curl") } +func TestShimPerRepoTemplateContent(t *testing.T) { + content, err := FullsendRepoFile("templates/shim-per-repo.yaml") + require.NoError(t, err) + s := string(content) + assert.True(t, strings.HasPrefix(s, "---\n"), "per-repo shim must start with YAML document start marker") + assert.Contains(t, s, "dispatch:") + assert.Contains(t, s, "stop-fix:") + assert.Contains(t, s, "__REUSABLE_DISPATCH__") + assert.Contains(t, s, "install_mode: per-repo") + // Per-role concurrency lives in reusable-dispatch.yml, not a monolithic shim group (#2452). + assert.NotContains(t, s, "fullsend-dispatch-${{") + assert.NotContains(t, s, "concurrency:") + assert.Contains(t, s, "per-role cancel-in-progress groups live in reusable-dispatch.yml") +} + func TestShimTriggerParity(t *testing.T) { // Both shim templates must declare the same event trigger types so that // per-repo and workflow-call installation modes have identical behavior. diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go index 0379396e7..3dab43854 100644 --- a/internal/scaffold/workflow_call_alignment_test.go +++ b/internal/scaffold/workflow_call_alignment_test.go @@ -39,9 +39,80 @@ type callerWorkflow struct { } type callerJob struct { - Uses string `yaml:"uses"` - With map[string]string `yaml:"with"` - Secrets map[string]string `yaml:"secrets"` + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + Secrets map[string]string `yaml:"secrets"` + Concurrency *jobConcurrency `yaml:"concurrency"` +} + +type jobConcurrency struct { + Group string `yaml:"group"` + CancelInProgress bool `yaml:"cancel-in-progress"` +} + +// reusableStageWorkflow includes workflow-level concurrency on reusable agent workflows. +type reusableStageWorkflow struct { + Concurrency *jobConcurrency `yaml:"concurrency"` + On reusableWorkflow `yaml:"on"` +} + +type stageConcurrencyExpectation struct { + groupPrefix string + groupMust []string +} + +var reusableStageConcurrencyExpectations = map[string]stageConcurrencyExpectation{ + "triage": { + groupPrefix: "fullsend-triage-", + groupMust: []string{"inputs.source_repo", "issue.number"}, + }, + "code": { + groupPrefix: "fullsend-code-", + groupMust: []string{"inputs.source_repo", "issue.number"}, + }, + "review": { + groupPrefix: "fullsend-review-", + groupMust: []string{"inputs.source_repo", "pull_request.number", "issue.number"}, + }, + "fix": { + groupPrefix: "fullsend-fix-", + groupMust: []string{"inputs.source_repo", "pull_request.number", "issue.number", "inputs.pr_number"}, + }, + "retro": { + groupPrefix: "fullsend-retro-", + groupMust: []string{"inputs.source_repo", "pull_request.number", "issue.number"}, + }, + "prioritize": { + groupPrefix: "fullsend-prioritize-", + groupMust: []string{"inputs.source_repo", "issue.number"}, + }, +} + +var dispatchStageConcurrencyExpectations = map[string]stageConcurrencyExpectation{ + "triage": { + groupPrefix: "fullsend-triage-", + groupMust: []string{"github.repository", "github.event.issue.number"}, + }, + "code": { + groupPrefix: "fullsend-code-", + groupMust: []string{"github.repository", "github.event.issue.number"}, + }, + "review": { + groupPrefix: "fullsend-review-", + groupMust: []string{"github.repository", "github.event.pull_request.number", "github.event.issue.number"}, + }, + "fix": { + groupPrefix: "fullsend-fix-", + groupMust: []string{"github.repository", "github.event.pull_request.number", "github.event.issue.number"}, + }, + "retro": { + groupPrefix: "fullsend-retro-", + groupMust: []string{"github.repository", "github.event.pull_request.number", "github.event.issue.number"}, + }, + "prioritize": { + groupPrefix: "fullsend-prioritize-", + groupMust: []string{"github.repository", "github.event.issue.number"}, + }, } // reusableWorkflowRef extracts the reusable workflow filename from a uses: reference. @@ -227,6 +298,54 @@ func TestReusableDispatchProjectNumberInput(t *testing.T) { "prioritize job should thread project_number from dispatch inputs") } +// TestReusableDispatchStageConcurrency validates per-role cancel-in-progress groups +// on all stage jobs in reusable-dispatch.yml (#981, #982, ADR 0033). +func TestReusableDispatchStageConcurrency(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "reusable-dispatch.yml")) + require.NoError(t, err) + + var caller callerWorkflow + require.NoError(t, yaml.Unmarshal(content, &caller)) + + for stage, expect := range dispatchStageConcurrencyExpectations { + t.Run(stage, func(t *testing.T) { + job, ok := caller.Jobs[stage] + require.True(t, ok, "job %q should exist", stage) + require.NotNil(t, job.Concurrency, "job %q should declare a concurrency group", stage) + assert.Contains(t, job.Concurrency.Group, expect.groupPrefix) + for _, fragment := range expect.groupMust { + assert.Contains(t, job.Concurrency.Group, fragment, + "job %q concurrency group should reference %q", stage, fragment) + } + assert.True(t, job.Concurrency.CancelInProgress, + "job %q should cancel in-progress runs when a newer dispatch arrives", stage) + }) + } +} + +// TestReusableWorkflowConcurrency validates workflow-level concurrency on all +// reusable stage workflows (defense-in-depth; mirrors dispatch stage jobs). +func TestReusableWorkflowConcurrency(t *testing.T) { + for stage, expect := range reusableStageConcurrencyExpectations { + t.Run(stage, func(t *testing.T) { + path := filepath.Join("..", "..", ".github", "workflows", fmt.Sprintf("reusable-%s.yml", stage)) + content, err := os.ReadFile(path) + require.NoError(t, err) + + var wf reusableStageWorkflow + require.NoError(t, yaml.Unmarshal(content, &wf)) + require.NotNil(t, wf.Concurrency, "reusable-%s.yml should declare workflow-level concurrency", stage) + assert.Contains(t, wf.Concurrency.Group, expect.groupPrefix) + for _, fragment := range expect.groupMust { + assert.Contains(t, wf.Concurrency.Group, fragment, + "reusable-%s.yml concurrency group should reference %q", stage, fragment) + } + assert.True(t, wf.Concurrency.CancelInProgress, + "reusable-%s.yml should cancel in-progress runs", stage) + }) + } +} + // TestReusableDispatchUsesFullyQualifiedPaths validates that reusable-dispatch.yml // references stage workflows with fully-qualified paths, not relative (./) paths. // Relative paths resolve against the caller's repo, which breaks per-repo mode From 346776da357a5d3c89aa71f348a76a03f69df56c Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 21 Jun 2026 12:52:48 +0300 Subject: [PATCH 2/5] fix: gofmt and update ADR concurrency docs for per-role policy Align ADR 0034/0041 consequences with per-role cancel-in-progress groups introduced for per-repo dispatch (#981). Fixes gofmt CI failure. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .../0034-centralized-shim-routing-via-dispatch.md | 15 +++++++++------ ...41-synchronous-workflow-call-event-dispatch.md | 7 +++++-- internal/scaffold/workflow_call_alignment_test.go | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md b/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md index 6884b1c24..64e3ff458 100644 --- a/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md +++ b/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md @@ -135,12 +135,15 @@ The `stage` input to `dispatch.yml` becomes optional. When provided - Adding a new stage (command or event trigger) requires only a `case` branch in `dispatch.yml` and a new agent workflow file. No enrolled repo changes. -- Enrolled repos gain a single concurrency group - (`fullsend-${{ github.event.pull_request.number || github.event.issue.number }}`). - This is a behavioral change from the status quo, where stages run - independently: a new dispatch now cancels any in-progress run for the - same issue/PR. In practice, only one agent should run per issue/PR at a - time, and the latest event takes priority. +- Enrolled repos gain per-role concurrency groups with `cancel-in-progress: true` + on each stage (triage, code, review, fix, retro, prioritize). Groups are keyed + by `{repo}-{issue|pr}` per stage so roles operate independently — a new review + dispatch cancels an in-flight review but does not cancel triage or code on the + same issue. Per-org: thin caller workflows (`review.yml`, etc.) and + `dispatch.yml` stage jobs; per-repo: `reusable-dispatch.yml` stage jobs and + matching `reusable-{stage}.yml` workflows (two-layer defense-in-depth). + The per-org shim retains a single queue group (`cancel-in-progress: false`); + the per-repo shim has no monolithic group (#2452). - Events that don't match any stage still trigger a `workflow_call` to `dispatch.yml`, which exits early. Cost: one runner spin-up (~20s). The `if:` filter on the dispatch job eliminates bot comments, the diff --git a/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md b/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md index 0d43276db..1f1d2bd08 100644 --- a/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md +++ b/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md @@ -134,7 +134,10 @@ re-evaluate whether a discovery mechanism is needed. ([ADR 0034](0034-centralized-shim-routing-via-dispatch.md)) remains. - Adding or removing org-specific agent workflows requires editing `dispatch.yml` directly; the single-file marker pattern from ADR 0026 is gone. -- Per-org and per-repo dispatch shapes converge; enrolled-repo shims may need - `needs:` / concurrency review ([#504](https://github.com/fullsend-ai/fullsend/issues/504)). +- Per-org and per-repo dispatch shapes converge; per-role concurrency is + documented in [ADR 0034](0034-centralized-shim-routing-via-dispatch.md) + (per-org thin callers + per-repo `reusable-dispatch.yml` / `reusable-*.yml`). + Resolves the concurrency review noted in [#504](https://github.com/fullsend-ai/fullsend/issues/504) + for per-repo installs ([#981](https://github.com/fullsend-ai/fullsend/issues/981)). - Discovery may be revisited after [ADR 0038](0038-universal-harness-access.md) agent architecture changes land. diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go index 3dab43854..c4d427dcd 100644 --- a/internal/scaffold/workflow_call_alignment_test.go +++ b/internal/scaffold/workflow_call_alignment_test.go @@ -52,7 +52,7 @@ type jobConcurrency struct { // reusableStageWorkflow includes workflow-level concurrency on reusable agent workflows. type reusableStageWorkflow struct { - Concurrency *jobConcurrency `yaml:"concurrency"` + Concurrency *jobConcurrency `yaml:"concurrency"` On reusableWorkflow `yaml:"on"` } From 354759be6106dd1004aaf6948c51621dc2438898 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 21 Jun 2026 13:13:15 +0300 Subject: [PATCH 3/5] Signed-off-by: Barak Korren ci: retrigger e2e after babysit fixes Co-authored-by: Cursor From 6cc890f2f0c12ba0f951c1cfc234bdd1363789c7 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 21 Jun 2026 13:21:50 +0300 Subject: [PATCH 4/5] fix(workflows): drop reusable workflow concurrency to avoid parent cancel Per-role groups on workflow_call parents (thin callers and reusable-dispatch stage jobs) share keys with reusable stage workflows. Duplicate groups with cancel-in-progress cancel the parent immediately, breaking e2e triage (#981). Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/reusable-code.yml | 8 ++--- .github/workflows/reusable-dispatch.yml | 8 +++-- .github/workflows/reusable-fix.yml | 15 ++------- .github/workflows/reusable-prioritize.yml | 8 ++--- .github/workflows/reusable-retro.yml | 8 ++--- .github/workflows/reusable-review.yml | 8 ++--- .github/workflows/reusable-triage.yml | 10 +++--- ...4-centralized-shim-routing-via-dispatch.md | 8 ++--- .../templates/shim-per-repo.yaml | 4 +-- .../scaffold/workflow_call_alignment_test.go | 33 +++++++++++++++---- 10 files changed, 51 insertions(+), 59 deletions(-) diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index 1b3bf5977..97df36fb5 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -1,14 +1,10 @@ # Reusable code agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch -# stage job). Latest code dispatch for an issue wins. +# Concurrency: per-role cancel-in-progress groups live on callers only +# (reusable-dispatch stage jobs or per-org thin callers like code.yml). name: Code Agent -concurrency: - group: fullsend-code-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - on: workflow_call: inputs: diff --git a/.github/workflows/reusable-dispatch.yml b/.github/workflows/reusable-dispatch.yml index 32f835f63..7d9c4b54b 100644 --- a/.github/workflows/reusable-dispatch.yml +++ b/.github/workflows/reusable-dispatch.yml @@ -3,9 +3,11 @@ # workflow_call jobs. This is the per-repo equivalent of the per-org # dispatch.yml + thin caller pair. # -# Concurrency: each stage job declares a per-role cancel-in-progress group -# (mirrored on reusable-{stage}.yml). Roles operate independently — review -# dispatches do not cancel triage, code, fix, etc. +# Concurrency: each stage job declares a per-role cancel-in-progress group. +# Reusable stage workflows intentionally omit workflow-level concurrency — +# the same group on a workflow_call parent and child cancels the parent (#981). +# Per-org thin callers (triage.yml, etc.) declare matching groups. Roles operate +# independently — review dispatches do not cancel triage, code, fix, etc. # # Flow: shim (per-repo) → reusable-dispatch.yml → reusable-{stage}.yml # Nesting: 3 levels of workflow_call (within GitHub's 4-level limit) diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index 89c02ea3c..dfffc1215 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -1,21 +1,10 @@ # Reusable fix agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch -# stage job). A human /fs-fix cancels any running fix so the human's -# instruction takes immediate effect. Bot-triggered runs also cancel previous -# bot runs on the same PR. +# Concurrency: per-role cancel-in-progress groups live on callers only +# (reusable-dispatch stage jobs or per-org thin callers like fix.yml). name: Fix Agent -concurrency: - group: >- - fullsend-fix-${{ inputs.source_repo }}-${{ - fromJSON(inputs.event_payload).pull_request.number - || fromJSON(inputs.event_payload).issue.number - || inputs.pr_number - }} - cancel-in-progress: true - on: workflow_call: inputs: diff --git a/.github/workflows/reusable-prioritize.yml b/.github/workflows/reusable-prioritize.yml index a6a2126be..bb2d02517 100644 --- a/.github/workflows/reusable-prioritize.yml +++ b/.github/workflows/reusable-prioritize.yml @@ -1,14 +1,10 @@ # Reusable prioritize agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch -# stage job). Latest prioritize dispatch for an issue wins. +# Concurrency: per-role cancel-in-progress groups live on callers only +# (reusable-dispatch stage jobs or per-org thin callers like prioritize.yml). name: Prioritize Agent -concurrency: - group: fullsend-prioritize-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - on: workflow_call: inputs: diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index 1063e2f21..25fd0b233 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -1,14 +1,10 @@ # Reusable retro agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch -# stage job). Latest retro dispatch for a PR/issue wins. +# Concurrency: per-role cancel-in-progress groups live on callers only +# (reusable-dispatch stage jobs or per-org thin callers like retro.yml). name: Retro Agent -concurrency: - group: fullsend-retro-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - on: workflow_call: inputs: diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index 56cdecaa0..ec5867d7a 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -1,14 +1,10 @@ # Reusable review agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch -# stage job). Latest review dispatch for a PR/issue wins. +# Concurrency: per-role cancel-in-progress groups live on callers only +# (reusable-dispatch stage jobs or per-org thin callers like review.yml). name: Review Agent -concurrency: - group: fullsend-review-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - on: workflow_call: inputs: diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index 4dfd02cc1..f7b2e497a 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -1,14 +1,12 @@ # Reusable triage agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress group (mirrored on reusable-dispatch -# stage job). Latest triage dispatch for an issue wins. +# Concurrency: per-role cancel-in-progress groups live on callers only +# (reusable-dispatch stage jobs or per-org thin callers like triage.yml). +# Do not add workflow-level concurrency here — the same group on parent and +# child workflow_call runs causes instant cancellation (#981). name: Triage Agent -concurrency: - group: fullsend-triage-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} - cancel-in-progress: true - on: workflow_call: inputs: diff --git a/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md b/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md index 64e3ff458..97719deb2 100644 --- a/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md +++ b/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md @@ -139,10 +139,10 @@ The `stage` input to `dispatch.yml` becomes optional. When provided on each stage (triage, code, review, fix, retro, prioritize). Groups are keyed by `{repo}-{issue|pr}` per stage so roles operate independently — a new review dispatch cancels an in-flight review but does not cancel triage or code on the - same issue. Per-org: thin caller workflows (`review.yml`, etc.) and - `dispatch.yml` stage jobs; per-repo: `reusable-dispatch.yml` stage jobs and - matching `reusable-{stage}.yml` workflows (two-layer defense-in-depth). - The per-org shim retains a single queue group (`cancel-in-progress: false`); + same issue. Per-org: thin caller workflows (`review.yml`, etc.); per-repo: + `reusable-dispatch.yml` stage jobs. Reusable stage workflows omit concurrency + (same group on a workflow_call parent and child cancels the parent). The + per-org shim retains a single queue group (`cancel-in-progress: false`); the per-repo shim has no monolithic group (#2452). - Events that don't match any stage still trigger a `workflow_call` to `dispatch.yml`, which exits early. Cost: one runner spin-up (~20s). The diff --git a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml index f9360cb1a..1d1885532 100644 --- a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml +++ b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml @@ -15,8 +15,8 @@ # branch in reusable-dispatch.yml — zero changes to this repo. # # Concurrency: per-role cancel-in-progress groups live in reusable-dispatch.yml -# stage jobs and reusable-{stage}.yml workflows — not on this shim. A monolithic -# shim group would serialize unrelated roles and drop pending runs (#2452). +# stage jobs — not on this shim or reusable stage workflows. A monolithic shim +# group would serialize unrelated roles and drop pending runs (#2452). name: fullsend permissions: diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go index c4d427dcd..788ba7f4e 100644 --- a/internal/scaffold/workflow_call_alignment_test.go +++ b/internal/scaffold/workflow_call_alignment_test.go @@ -323,10 +323,13 @@ func TestReusableDispatchStageConcurrency(t *testing.T) { } } -// TestReusableWorkflowConcurrency validates workflow-level concurrency on all -// reusable stage workflows (defense-in-depth; mirrors dispatch stage jobs). -func TestReusableWorkflowConcurrency(t *testing.T) { - for stage, expect := range reusableStageConcurrencyExpectations { +// TestReusableWorkflowsNoWorkflowConcurrency ensures reusable stage workflows +// do not declare workflow-level concurrency. Callers (reusable-dispatch stage +// jobs or per-org thin callers) own the per-role group; duplicating the same +// group on a workflow_call child cancels the parent immediately (#981). +func TestReusableWorkflowsNoWorkflowConcurrency(t *testing.T) { + stages := []string{"triage", "code", "review", "fix", "retro", "prioritize"} + for _, stage := range stages { t.Run(stage, func(t *testing.T) { path := filepath.Join("..", "..", ".github", "workflows", fmt.Sprintf("reusable-%s.yml", stage)) content, err := os.ReadFile(path) @@ -334,14 +337,30 @@ func TestReusableWorkflowConcurrency(t *testing.T) { var wf reusableStageWorkflow require.NoError(t, yaml.Unmarshal(content, &wf)) - require.NotNil(t, wf.Concurrency, "reusable-%s.yml should declare workflow-level concurrency", stage) + assert.Nil(t, wf.Concurrency, + "reusable-%s.yml must not declare workflow-level concurrency (callers own the group)", stage) + }) + } +} + +// TestThinCallerStageConcurrency validates per-role cancel-in-progress groups on +// per-org thin caller workflows in the scaffold (#981, ADR 0033). +func TestThinCallerStageConcurrency(t *testing.T) { + for stage, expect := range reusableStageConcurrencyExpectations { + t.Run(stage, func(t *testing.T) { + path := fmt.Sprintf(".github/workflows/%s.yml", stage) + content := loadRenderedScaffoldCaller(path)(t) + + var wf reusableStageWorkflow + require.NoError(t, yaml.Unmarshal(content, &wf)) + require.NotNil(t, wf.Concurrency, "%s should declare workflow-level concurrency", path) assert.Contains(t, wf.Concurrency.Group, expect.groupPrefix) for _, fragment := range expect.groupMust { assert.Contains(t, wf.Concurrency.Group, fragment, - "reusable-%s.yml concurrency group should reference %q", stage, fragment) + "%s concurrency group should reference %q", path, fragment) } assert.True(t, wf.Concurrency.CancelInProgress, - "reusable-%s.yml should cancel in-progress runs", stage) + "%s should cancel in-progress runs when a newer dispatch arrives", path) }) } } From c0ffc0245a3b1518ed51922bf2cb1934b7a4ebf8 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 21 Jun 2026 13:49:19 +0300 Subject: [PATCH 5/5] fix(workflows): agent-scoped concurrency on reusable stage workflows Restore workflow-level cancel-in-progress on reusable-{stage}.yml using distinct fullsend-{stage}-agent- group keys so workflow_call parents keep their own dispatch groups. Revert ADR body edits; add short implementation notes only (#981). Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/reusable-code.yml | 8 ++- .github/workflows/reusable-dispatch.yml | 9 ++- .github/workflows/reusable-fix.yml | 13 +++- .github/workflows/reusable-prioritize.yml | 8 ++- .github/workflows/reusable-retro.yml | 8 ++- .github/workflows/reusable-review.yml | 8 ++- .github/workflows/reusable-triage.yml | 11 ++-- ...4-centralized-shim-routing-via-dispatch.md | 20 ++++--- ...ynchronous-workflow-call-event-dispatch.md | 12 ++-- .../templates/shim-per-repo.yaml | 4 +- .../scaffold/workflow_call_alignment_test.go | 59 +++++++++++++++---- 11 files changed, 114 insertions(+), 46 deletions(-) diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index 97df36fb5..0cdbbbf23 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -1,8 +1,8 @@ # Reusable code agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress groups live on callers only -# (reusable-dispatch stage jobs or per-org thin callers like code.yml). +# Concurrency: agent-scoped cancel-in-progress group (distinct from dispatch/thin +# caller groups so workflow_call parent runs are not cancelled). name: Code Agent on: @@ -42,6 +42,10 @@ on: FULLSEND_GCP_PROJECT_ID: required: true +concurrency: + group: fullsend-code-agent-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + jobs: code: name: Code diff --git a/.github/workflows/reusable-dispatch.yml b/.github/workflows/reusable-dispatch.yml index 7d9c4b54b..a0cf0d775 100644 --- a/.github/workflows/reusable-dispatch.yml +++ b/.github/workflows/reusable-dispatch.yml @@ -3,11 +3,10 @@ # workflow_call jobs. This is the per-repo equivalent of the per-org # dispatch.yml + thin caller pair. # -# Concurrency: each stage job declares a per-role cancel-in-progress group. -# Reusable stage workflows intentionally omit workflow-level concurrency — -# the same group on a workflow_call parent and child cancels the parent (#981). -# Per-org thin callers (triage.yml, etc.) declare matching groups. Roles operate -# independently — review dispatches do not cancel triage, code, fix, etc. +# Concurrency: each stage job declares a per-role cancel-in-progress dispatch group. +# Reusable stage workflows use distinct agent-scoped groups (fullsend-{stage}-agent-…) +# so workflow_call parents are not cancelled. Roles operate independently — review +# dispatches do not cancel triage, code, fix, etc. # # Flow: shim (per-repo) → reusable-dispatch.yml → reusable-{stage}.yml # Nesting: 3 levels of workflow_call (within GitHub's 4-level limit) diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index dfffc1215..2f41130b3 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -1,8 +1,8 @@ # Reusable fix agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress groups live on callers only -# (reusable-dispatch stage jobs or per-org thin callers like fix.yml). +# Concurrency: agent-scoped cancel-in-progress group (distinct from dispatch/thin +# caller groups so workflow_call parent runs are not cancelled). name: Fix Agent on: @@ -54,6 +54,15 @@ on: FULLSEND_GCP_PROJECT_ID: required: true +concurrency: + group: >- + fullsend-fix-agent-${{ inputs.source_repo }}-${{ + fromJSON(inputs.event_payload).pull_request.number + || fromJSON(inputs.event_payload).issue.number + || inputs.pr_number + }} + cancel-in-progress: true + jobs: fix: name: Fix diff --git a/.github/workflows/reusable-prioritize.yml b/.github/workflows/reusable-prioritize.yml index bb2d02517..a9216c833 100644 --- a/.github/workflows/reusable-prioritize.yml +++ b/.github/workflows/reusable-prioritize.yml @@ -1,8 +1,8 @@ # Reusable prioritize agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress groups live on callers only -# (reusable-dispatch stage jobs or per-org thin callers like prioritize.yml). +# Concurrency: agent-scoped cancel-in-progress group (distinct from dispatch/thin +# caller groups so workflow_call parent runs are not cancelled). name: Prioritize Agent on: @@ -46,6 +46,10 @@ on: FULLSEND_GCP_PROJECT_ID: required: true +concurrency: + group: fullsend-prioritize-agent-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + jobs: prioritize: name: Prioritize diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index 25fd0b233..05f86ad92 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -1,8 +1,8 @@ # Reusable retro agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress groups live on callers only -# (reusable-dispatch stage jobs or per-org thin callers like retro.yml). +# Concurrency: agent-scoped cancel-in-progress group (distinct from dispatch/thin +# caller groups so workflow_call parent runs are not cancelled). name: Retro Agent on: @@ -42,6 +42,10 @@ on: FULLSEND_GCP_PROJECT_ID: required: true +concurrency: + group: fullsend-retro-agent-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + jobs: retro: name: Retro diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index ec5867d7a..02d4c4df7 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -1,8 +1,8 @@ # Reusable review agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress groups live on callers only -# (reusable-dispatch stage jobs or per-org thin callers like review.yml). +# Concurrency: agent-scoped cancel-in-progress group (distinct from dispatch/thin +# caller groups so workflow_call parent runs are not cancelled). name: Review Agent on: @@ -42,6 +42,10 @@ on: FULLSEND_GCP_PROJECT_ID: required: true +concurrency: + group: fullsend-review-agent-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + jobs: review: name: Review diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index f7b2e497a..52f4c982b 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -1,10 +1,9 @@ # Reusable triage agent workflow. Called by dispatch workflows via workflow_call. # Runs in the caller's repo context (secrets, checkout). # -# Concurrency: per-role cancel-in-progress groups live on callers only -# (reusable-dispatch stage jobs or per-org thin callers like triage.yml). -# Do not add workflow-level concurrency here — the same group on parent and -# child workflow_call runs causes instant cancellation (#981). +# Concurrency: agent-scoped cancel-in-progress group (distinct from dispatch/thin +# caller groups so workflow_call parent runs are not cancelled). Latest agent +# run for an issue wins. name: Triage Agent on: @@ -44,6 +43,10 @@ on: FULLSEND_GCP_PROJECT_ID: required: true +concurrency: + group: fullsend-triage-agent-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + jobs: triage: name: Triage diff --git a/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md b/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md index 97719deb2..bff1351da 100644 --- a/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md +++ b/docs/ADRs/0034-centralized-shim-routing-via-dispatch.md @@ -135,15 +135,12 @@ The `stage` input to `dispatch.yml` becomes optional. When provided - Adding a new stage (command or event trigger) requires only a `case` branch in `dispatch.yml` and a new agent workflow file. No enrolled repo changes. -- Enrolled repos gain per-role concurrency groups with `cancel-in-progress: true` - on each stage (triage, code, review, fix, retro, prioritize). Groups are keyed - by `{repo}-{issue|pr}` per stage so roles operate independently — a new review - dispatch cancels an in-flight review but does not cancel triage or code on the - same issue. Per-org: thin caller workflows (`review.yml`, etc.); per-repo: - `reusable-dispatch.yml` stage jobs. Reusable stage workflows omit concurrency - (same group on a workflow_call parent and child cancels the parent). The - per-org shim retains a single queue group (`cancel-in-progress: false`); - the per-repo shim has no monolithic group (#2452). +- Enrolled repos gain a single concurrency group + (`fullsend-${{ github.event.pull_request.number || github.event.issue.number }}`). + This is a behavioral change from the status quo, where stages run + independently: a new dispatch now cancels any in-progress run for the + same issue/PR. In practice, only one agent should run per issue/PR at a + time, and the latest event takes priority. - Events that don't match any stage still trigger a `workflow_call` to `dispatch.yml`, which exits early. Cost: one runner spin-up (~20s). The `if:` filter on the dispatch job eliminates bot comments, the @@ -165,3 +162,8 @@ The `stage` input to `dispatch.yml` becomes optional. When provided the same routing script. Per-org shims could also adopt `reusable-fullsend.yml` directly, eliminating `dispatch.yml` as a routing layer entirely — see ADR 0033 Open Questions. + +## Implementation note (#981) + +Per-role dispatch concurrency is configured in repository workflows; the routing +model in this ADR is unchanged. diff --git a/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md b/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md index 1f1d2bd08..8b6ed9560 100644 --- a/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md +++ b/docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md @@ -134,10 +134,12 @@ re-evaluate whether a discovery mechanism is needed. ([ADR 0034](0034-centralized-shim-routing-via-dispatch.md)) remains. - Adding or removing org-specific agent workflows requires editing `dispatch.yml` directly; the single-file marker pattern from ADR 0026 is gone. -- Per-org and per-repo dispatch shapes converge; per-role concurrency is - documented in [ADR 0034](0034-centralized-shim-routing-via-dispatch.md) - (per-org thin callers + per-repo `reusable-dispatch.yml` / `reusable-*.yml`). - Resolves the concurrency review noted in [#504](https://github.com/fullsend-ai/fullsend/issues/504) - for per-repo installs ([#981](https://github.com/fullsend-ai/fullsend/issues/981)). +- Per-org and per-repo dispatch shapes converge; enrolled-repo shims may need + `needs:` / concurrency review ([#504](https://github.com/fullsend-ai/fullsend/issues/504)). - Discovery may be revisited after [ADR 0038](0038-universal-harness-access.md) agent architecture changes land. + +## Implementation note (#981) + +Per-repo concurrency follow-up ([#504](https://github.com/fullsend-ai/fullsend/issues/504)) +is addressed in workflow configuration; the dispatch shape in this ADR is unchanged. diff --git a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml index 1d1885532..0ce727435 100644 --- a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml +++ b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml @@ -15,8 +15,8 @@ # branch in reusable-dispatch.yml — zero changes to this repo. # # Concurrency: per-role cancel-in-progress groups live in reusable-dispatch.yml -# stage jobs — not on this shim or reusable stage workflows. A monolithic shim -# group would serialize unrelated roles and drop pending runs (#2452). +# stage jobs and agent-scoped groups on reusable-{stage}.yml — not on this shim. +# A monolithic shim group would serialize unrelated roles and drop pending runs (#2452). name: fullsend permissions: diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go index 788ba7f4e..756af0786 100644 --- a/internal/scaffold/workflow_call_alignment_test.go +++ b/internal/scaffold/workflow_call_alignment_test.go @@ -61,7 +61,7 @@ type stageConcurrencyExpectation struct { groupMust []string } -var reusableStageConcurrencyExpectations = map[string]stageConcurrencyExpectation{ +var thinCallerConcurrencyExpectations = map[string]stageConcurrencyExpectation{ "triage": { groupPrefix: "fullsend-triage-", groupMust: []string{"inputs.source_repo", "issue.number"}, @@ -88,6 +88,33 @@ var reusableStageConcurrencyExpectations = map[string]stageConcurrencyExpectatio }, } +var reusableAgentConcurrencyExpectations = map[string]stageConcurrencyExpectation{ + "triage": { + groupPrefix: "fullsend-triage-agent-", + groupMust: []string{"inputs.source_repo", "issue.number"}, + }, + "code": { + groupPrefix: "fullsend-code-agent-", + groupMust: []string{"inputs.source_repo", "issue.number"}, + }, + "review": { + groupPrefix: "fullsend-review-agent-", + groupMust: []string{"inputs.source_repo", "pull_request.number", "issue.number"}, + }, + "fix": { + groupPrefix: "fullsend-fix-agent-", + groupMust: []string{"inputs.source_repo", "pull_request.number", "issue.number", "inputs.pr_number"}, + }, + "retro": { + groupPrefix: "fullsend-retro-agent-", + groupMust: []string{"inputs.source_repo", "pull_request.number", "issue.number"}, + }, + "prioritize": { + groupPrefix: "fullsend-prioritize-agent-", + groupMust: []string{"inputs.source_repo", "issue.number"}, + }, +} + var dispatchStageConcurrencyExpectations = map[string]stageConcurrencyExpectation{ "triage": { groupPrefix: "fullsend-triage-", @@ -323,13 +350,11 @@ func TestReusableDispatchStageConcurrency(t *testing.T) { } } -// TestReusableWorkflowsNoWorkflowConcurrency ensures reusable stage workflows -// do not declare workflow-level concurrency. Callers (reusable-dispatch stage -// jobs or per-org thin callers) own the per-role group; duplicating the same -// group on a workflow_call child cancels the parent immediately (#981). -func TestReusableWorkflowsNoWorkflowConcurrency(t *testing.T) { - stages := []string{"triage", "code", "review", "fix", "retro", "prioritize"} - for _, stage := range stages { +// TestReusableAgentWorkflowConcurrency validates agent-scoped cancel-in-progress +// groups on reusable stage workflows. Groups use a distinct -agent- prefix so +// they do not collide with dispatch/thin-caller groups on workflow_call parents. +func TestReusableAgentWorkflowConcurrency(t *testing.T) { + for stage, expect := range reusableAgentConcurrencyExpectations { t.Run(stage, func(t *testing.T) { path := filepath.Join("..", "..", ".github", "workflows", fmt.Sprintf("reusable-%s.yml", stage)) content, err := os.ReadFile(path) @@ -337,8 +362,20 @@ func TestReusableWorkflowsNoWorkflowConcurrency(t *testing.T) { var wf reusableStageWorkflow require.NoError(t, yaml.Unmarshal(content, &wf)) - assert.Nil(t, wf.Concurrency, - "reusable-%s.yml must not declare workflow-level concurrency (callers own the group)", stage) + require.NotNil(t, wf.Concurrency, "reusable-%s.yml should declare workflow-level concurrency", stage) + assert.Contains(t, wf.Concurrency.Group, expect.groupPrefix) + for _, fragment := range expect.groupMust { + assert.Contains(t, wf.Concurrency.Group, fragment, + "reusable-%s.yml concurrency group should reference %q", stage, fragment) + } + assert.True(t, wf.Concurrency.CancelInProgress, + "reusable-%s.yml should cancel in-progress runs", stage) + + callerExpect := thinCallerConcurrencyExpectations[stage] + assert.NotEqual(t, callerExpect.groupPrefix, expect.groupPrefix, + "reusable-%s.yml must use a distinct agent-scoped group prefix", stage) + assert.Contains(t, wf.Concurrency.Group, "-agent-", + "reusable-%s.yml group must be agent-scoped, not reuse dispatch/thin-caller prefix", stage) }) } } @@ -346,7 +383,7 @@ func TestReusableWorkflowsNoWorkflowConcurrency(t *testing.T) { // TestThinCallerStageConcurrency validates per-role cancel-in-progress groups on // per-org thin caller workflows in the scaffold (#981, ADR 0033). func TestThinCallerStageConcurrency(t *testing.T) { - for stage, expect := range reusableStageConcurrencyExpectations { + for stage, expect := range thinCallerConcurrencyExpectations { t.Run(stage, func(t *testing.T) { path := fmt.Sprintf(".github/workflows/%s.yml", stage) content := loadRenderedScaffoldCaller(path)(t)