34. Centralized shim routing via dispatch.yml
Date: 2026-05-07
Status
Accepted
Context
The target-repo shim workflow (fullsend.yaml) routes GitHub events to agent stages by mapping events to dispatch.yml calls. Each stage requires its own job in the shim: an if: filter, a jq payload builder, and a gh workflow run dispatch step. The shim currently has 9 jobs (8 dispatch + post-run-link) and ~470 lines.
Every time a new stage is added, every enrolled repo's shim must be updated. Adding the retro stage required 2 new jobs (~80 lines) in every target repo. Command matching uses a 3-line pattern (exact match, space-delimited args, newline-delimited body) repeated 6 times across the shim.
The routing logic — "which events map to which stages" — is conceptually part of the centralized fullsend system, not target-repo configuration. Yet it is duplicated in every enrolled repo and drifts when shims are not updated after scaffold changes.
The token mint work (ADR 0029 PR #655) migrates the shim from workflow_dispatch + gh workflow run to native workflow_call. This removes the dispatch credential (FULLSEND_DISPATCH_TOKEN) and the imperative dispatch boilerplate, creating the natural opportunity to also move routing into dispatch.yml.
ADR 0031 addresses .fullsend agent workflow drift via reusable workflows but explicitly leaves the shim unchanged. This ADR addresses the shim side.
Options
Option A: Per-stage jobs in the shim (status quo pattern)
Each stage gets its own job in the shim with an if: filter, payload builder, and dispatch call. With workflow_call (token mint), the dispatch mechanism changes but the structure stays the same: 9 jobs, ~350 lines, routing in the shim.
New stages require updating every enrolled repo's shim.
Option B: Merge paired jobs
Combine jobs that dispatch the same stage (dispatch-fix-bot + dispatch-fix-human → dispatch-fix, dispatch-retro + dispatch-retro-command → dispatch-retro). Reduces to 6 jobs, ~300 lines. Quick win, no contract changes. New stages still require shim updates.
Option C: Centralized routing in dispatch.yml
The shim becomes a single dispatch job that forwards the event context to dispatch.yml via workflow_call without specifying a stage. dispatch.yml examines event_type and the event payload to determine which stage to trigger, then fans out to the matching agent workflow.
The shim shrinks to ~50 lines (one dispatch job + one stop-fix job). dispatch.yml gains ~50 lines of routing logic. New stages require zero changes to enrolled repos.
Decision
Use Option C. Move event-to-stage routing into dispatch.yml. The shim forwards event context via workflow_call without determining the stage.
The shim has three jobs:
dispatch— builds a universal minimal payload from safe GitHub context fields and calls.fullsend/dispatch.ymlviaworkflow_callwithevent_type,event_action,source_repo,trigger_source, andevent_payloadinputs. Filters out bot comments viaif:to avoid unnecessary invocations.stop-fix— adds thefullsend-no-fixlabel and posts a comment. This job acts directly on the target repo and does not dispatch to.fullsend. It stays in the shim (~25 lines).post-run-link— posts a link to the dispatched workflow run as a comment on the triggering issue or PR. This job runs afterdispatchcompletes and stays in the shim.
dispatch.yml gains a routing step that maps event_type + payload fields to a stage name:
issue_commentwith/fs-triage,/fs-code,/fs-review,/fs-fix,/fs-retro,/fs-prioritizecommands → corresponding stageissue_commentonneeds-infoissue from non-bot →triageissues+labeledwithready-to-code→codepull_request_targetopened/synchronize/ready_for_review →reviewpull_request_targetclosed →retropull_request_reviewwith botchanges_requested→fix
Note (2026-06): Per-repo
reusable-dispatch.ymlnow requires PR context for review label and slash-command triggers (ready-for-review,/fs-reviewgated onissue.pull_request). Per-orgdispatch.ymlis unchanged pending follow-up. Fix dispatch was already PR-only viapull_request_reviewand PR-gated/fs-fix.
If no stage matches, dispatch.yml exits early with no fan-out. The existing kill switch, role enablement, and # fullsend-stage: marker scanning (ADR 0026) run after stage determination, unchanged.
The stage input to dispatch.yml becomes optional. When provided (backwards compatibility with old shims), it is used directly. When omitted, dispatch.yml determines the stage from the event context.
Security properties preserved
pull_request_targetruns the base branch version of the shim, preventing PRs from modifying it (ADR 0009).- Payload built from individual context fields — not inline shell — to prevent script injection from attacker-controlled fields.
- Fork-PR blocking for the fix stage moves to
fix.yml, where it already exists (redundant with the shim's current check). - Author association checks for
/fs-fix,/fs-retro,/fs-fix-stopmove todispatch.yml's routing step. workflow_callinputs stay under 10. The current 5 inputs minusstageplusevent_actionandtrigger_source= 6 inputs.
Consequences
- Adding a new stage (command or event trigger) requires only a
casebranch indispatch.ymland 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. - Events that don't match any stage still trigger a
workflow_calltodispatch.yml, which exits early. Cost: one runner spin-up (~20s). Theif:filter on the dispatch job eliminates bot comments, the highest-volume no-op. - Old shims (per-stage jobs with explicit
stageinput) continue to work —dispatch.ymlsupports both explicit and auto-determined stages. - The
stop-fixjob remains in the shim because it acts on the target repo directly (label + comment), not via.fullsenddispatch. - This decision is sequenced after the token mint migration. The token mint provides
workflow_call; this ADR uses it to simplify routing. - Per-repo installation (ADR 0033) needs the same routing logic but published upstream as
reusable-fullsend.ymlinfullsend-ai/fullsend. Per-repo repos have no.fullsend/dispatch.yml— their thinfullsend.ymlcallsreusable-fullsend.ymldirectly, which routes events to stages and fans out to per-stage reusable workflows. The routing implementation should be shared: eitherdispatch.ymlcallsreusable-fullsend.ymlupstream (unifying both models), or both embed the same routing script. Per-org shims could also adoptreusable-fullsend.ymldirectly, eliminatingdispatch.ymlas 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.
