Skip to content
Open
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
12 changes: 11 additions & 1 deletion .github/workflows/fullsend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,23 @@ on:
jobs:
dispatch:
concurrency:
group: fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number }}
# Include label name for labeled events so routing labels (ready-to-code,
# ready-for-review) don't share a concurrency slot with non-routing labels
# applied in the same burst (#2452).
group: >-
fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number }}-${{
github.event.action == 'labeled' && github.event.label.name || 'dispatch'
}}
Comment thread
ggallen marked this conversation as resolved.
cancel-in-progress: false
if: >-
Comment thread
ggallen marked this conversation as resolved.
(github.event_name != 'pull_request_target' && github.event_name != 'pull_request_review'
|| github.event.pull_request.head.ref != 'fullsend/scaffold-install')
&& (github.event_name != 'issue_comment'
|| github.event.comment.user.type != 'Bot')
&& (
github.event.action != 'labeled'
|| startsWith(github.event.label.name, 'ready-')
Comment thread
ggallen marked this conversation as resolved.
)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
uses: fullsend-ai/.fullsend/.github/workflows/dispatch.yml@main
with:
event_action: ${{ github.event.action }}
Expand Down
15 changes: 13 additions & 2 deletions docs/ADRs/0034-centralized-shim-routing-via-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,19 @@ The `stage` input to `dispatch.yml` becomes optional. When provided
(`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.
same issue/PR.
**Note (2026-07, [#2452](https://github.com/fullsend-ai/fullsend/issues/2452)):** the per-org workflow-call shims
(`fullsend.yaml`, `shim-workflow-call.yaml`) now use label-aware
concurrency groups — `labeled` events include the label name in the
key so routing labels are not cancelled by non-routing labels applied
in the same burst. Non-labeled events still share a common per-issue/PR
group. The per-repo shim (`shim-per-repo.yaml`) has no job-level
concurrency group; per-role groups live in `reusable-dispatch.yml`
stage jobs. All shims gained an `if:` early-exit that skips non-routing
labels (those not matching the `ready-` prefix) to avoid wasting
runner time. Note: the concurrency-cancellation hypothesis for the
original incident (#2405) had medium confidence — dispatch.yml routing
drift was not ruled out — but the fix is low-risk regardless.
- 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
Expand Down
2 changes: 1 addition & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ See [architecture.md](architecture.md) and [agent-architecture.md](problems/agen

### Label State Machine

The set of valid label transitions on issues and PRs that encode workflow state. Labels like `ready-for-triage`, `ready-to-code`, and `ready-for-review` drive agent dispatch; others such as `ready-for-merge` and `requires-manual-review` encode review outcomes. In per-repo installs, `ready-for-review` on a PR also triggers review; applying it to a standalone issue does not. Per-org installs still accept legacy issue-side review triggers pending a follow-up. The label state machine guard validates that transitions are legal and enforces mutual exclusion — for example, starting a triage run clears downstream labels so stale state does not carry forward.
The set of valid label transitions on issues and PRs that encode workflow state. Labels like `ready-for-triage`, `ready-to-code`, and `ready-for-review` drive agent dispatch; others such as `ready-for-merge` and `requires-manual-review` encode review outcomes. All routing labels share the `ready-` prefix — the shim `if:` guard silently skips `labeled` events whose label doesn't match `startsWith('ready-')`, so custom agents must follow this convention (e.g. `ready-for-my-agent`). In per-repo installs, `ready-for-review` on a PR also triggers review; applying it to a standalone issue does not. Per-org installs still accept legacy issue-side review triggers pending a follow-up. The label state machine guard validates that transitions are legal and enforces mutual exclusion — for example, starting a triage run clears downstream labels so stale state does not carry forward.
Comment thread
ggallen marked this conversation as resolved.
See [ADR 0002](ADRs/0002-initial-fullsend-design.md) building block 3.

## M
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/user/bring-your-own-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ Authentication for CLI commands uses the `gh` CLI or `GH_TOKEN` environment vari

The examples above show customizing built-in agents via `base`. If you've built an entirely new agent from scratch, register it the same way — just point to a local harness instead of a URL.

> **Routing label convention:** if your agent is triggered by a label, the label must use the `ready-` prefix (e.g. `ready-for-my-agent`). The shim `if:` guard silently skips `labeled` events whose label name doesn't match `startsWith('ready-')`, so a non-conforming label will never reach dispatch.

### CLI

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
|| github.event.pull_request.head.ref != 'fullsend/scaffold-install')
&& (github.event_name != 'issue_comment'
|| github.event.comment.user.type != 'Bot')
&& (
github.event.action != 'labeled'
|| startsWith(github.event.label.name, 'ready-')
)
uses: __REUSABLE_DISPATCH__
with:
event_action: ${{ github.event.action }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@ on:
jobs:
dispatch:
concurrency:
group: fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number }}
# Include label name for labeled events so routing labels (ready-to-code,
# ready-for-review) don't share a concurrency slot with non-routing labels
# applied in the same burst (#2452).
group: >-
fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number }}-${{
github.event.action == 'labeled' && github.event.label.name || 'dispatch'
}}
cancel-in-progress: false
if: >-
(github.event_name != 'pull_request_target' && github.event_name != 'pull_request_review'
|| github.event.pull_request.head.ref != 'fullsend/scaffold-install')
&& (github.event_name != 'issue_comment'
|| github.event.comment.user.type != 'Bot')
&& (
github.event.action != 'labeled'
|| startsWith(github.event.label.name, 'ready-')
)
Comment thread
ggallen marked this conversation as resolved.
uses: __ORG__/.fullsend/.github/workflows/dispatch.yml@main
with:
event_action: ${{ github.event.action }}
Expand Down
48 changes: 48 additions & 0 deletions internal/scaffold/workflow_call_alignment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,54 @@ func TestReusableDispatchPRHeadSHAPassthrough(t *testing.T) {
}
}

// TestShimLabeledEventFiltering validates that shim workflows skip non-routing
// labeled events at the if: guard level and use label-aware concurrency keys
// so routing labels don't cancel each other (#2452).
func TestShimLabeledEventFiltering(t *testing.T) {
type shimCase struct {
name string
content func(t *testing.T) []byte
hasConcurrency bool
}

cases := []shimCase{
{"fullsend.yaml", loadRepoFile(".github/workflows/fullsend.yaml"), true},
{"scaffold/shim-workflow-call.yaml", loadScaffoldFile("templates/shim-workflow-call.yaml"), true},
{"scaffold/shim-per-repo.yaml", loadScaffoldFile("templates/shim-per-repo.yaml"), false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := string(tc.content(t))

// All shims must filter non-routing labeled events via startsWith.
assert.Contains(t, s, `github.event.action != 'labeled'`,
"%s must skip non-routing labeled events", tc.name)
assert.Contains(t, s, `startsWith(github.event.label.name, 'ready-')`,
"%s must use startsWith prefix match for routing labels", tc.name)
})
}

// Workflow-call shims must have a label-aware concurrency key (#2452).
// Parse YAML to check the concurrency group field directly, not just
// string-match on the whole file (which would false-positive on the if: guard).
for _, tc := range cases {
if !tc.hasConcurrency {
continue
}
t.Run(tc.name+"/concurrency", func(t *testing.T) {
var wf callerWorkflow
require.NoError(t, yaml.Unmarshal(tc.content(t), &wf))
job, ok := wf.Jobs["dispatch"]
require.True(t, ok, "%s must have a dispatch job", tc.name)
require.NotNil(t, job.Concurrency, "%s dispatch job must have a concurrency group", tc.name)
assert.Contains(t, job.Concurrency.Group,
"github.event.action == 'labeled' && github.event.label.name || 'dispatch'",
"%s concurrency group must use label-aware ternary", tc.name)
})
}
}

// TestRoleCheckCaseBranches validates the role-check step's case mapping and
// backward-compat logic in both dispatch workflows (#2298).
func TestRoleCheckCaseBranches(t *testing.T) {
Expand Down
Loading