GitLab Duo Triggers on Merge Request Created, and the Loops Around Them
GitLab Duo triggers fire on merge request created. Bots cannot activate them, so the loops left are external agents, cross-system hops and cost. Five guards.
Go deeper. Build your own.
Fourteen draft merge requests pushed after one rebase are fourteen flow runs, the moment your GitLab Duo triggers listen for the Created action. Each flow reviews once and bills once, against a monthly credit cap whose owner may never have heard of the trigger. Nothing looped. It did not need to.
GitLab 19.4 added Created to the Merge request trigger event, and the docs ship with a guard most teams will read as the end of the loop question: a bot user, a service account, or another flow cannot activate a trigger. That sentence closes the loop GitLab can see. It leaves three it cannot: an external agent acting under a person’s OAuth grant, a loop that crosses into another system and comes back, and the cost of a trigger that fires exactly as designed.
The move is five guards that live outside GitLab’s guard: drop agent-authored events at your own gateway, cap cycles per lineage rather than per merge request, refuse a cycle whose review says what the last one said, hold a cost ceiling per merge request across three meters, and time a kill path that is three switches deep. The CI fix-loop runbook taught the state machine for one pull request going red and green; this piece is about a loop that starts with a trigger and refuses to stay in one system.
What 19.4 added to GitLab Duo triggers on September 17
GitLab 19.4 shipped on September 17, 2026, and the release notes list the new action under the Merge request trigger event: “You can now select Created as a trigger action. When someone opens a merge request in draft or ready state, your flow runs.” (GitLab 19.4 release notes). The triggers docs are more exact about the moment: Created fires “When someone creates a merge request, draft or ready, and GitLab generates its diff.” and “GitLab syncs code owner approval rules against that diff before the flow runs, unless the merge request joins a merge train or GitLab cannot reload the diff.” (GitLab Docs, Triggers). Triggers are a Premium and Ultimate feature, need the Maintainer or Owner role, and require the Duo Agent Platform. The same release adds the switch this piece leans on later: “Now you can turn a flow trigger off and retain its configuration. Use the new toggle to turn it back on anytime.”
Screenshot: GitLab Docs, “GitLab 19.4 release notes” (Sep 17, 2026), captured Sep 19, 2026.
The lineage matters because it tells you how young this is. Triggers arrived in 18.3 behind a flag, went generally available in 18.8, had their Merge request event consolidated in 19.1, and gained Created in 19.4. The event list today: Mention; Assign; Assign reviewer; Pipeline events (Running, Passed, Failed, Canceled); Merge request (Approved, Created, Marked ready, Merge conflict); Work item (Created, Status changed). A trigger, in the docs’ words, “determines when a flow or external agent runs,” and “cannot be created for a custom agent or foundational agent.”
Then the guard, verbatim, because the whole piece hangs on it: “All trigger event types require a human user to perform the triggering action. A non-human user such as a bot user, service account user, or another flow, cannot activate a trigger.”
Screenshot: GitLab Docs, “Triggers” (version history through 19.4), captured Sep 19, 2026.
GitLab’s launch post explains why any of this touches agents at all: “A flow or external agent can now run the moment someone opens a merge request, once GitLab generates the diff.” (GitLab blog). What the docs do not say is as load-bearing as what they do. They state no concurrency limit, no maximum number of triggers, and no credit cost per trigger run. The news stops here.
Why one closed loop leaves three open
Loop one: the external agent that looks like a person
The GitLab MCP server authenticates clients through OAuth 2.0 Dynamic Client Registration: the client registers, a person authorizes it, and from then on the agent acts as that person (GitLab MCP server docs). Among the tools it can then call is save_merge_request, which opens a merge request (GitLab MCP server tools). Whether a merge request opened that way counts as “someone creates a merge request” for the trigger is not stated anywhere in the docs.
The exclusion names bots, service accounts and flows. An agent holding a developer’s grant is none of those from GitLab’s side. It is the developer.
So plan for the loop as if it fires: trigger, flow, external agent under a human grant, save_merge_request, trigger. If GitLab turns out to suppress it, you have lost an afternoon of configuration. If it does not and you assumed it did, you have lost a weekend of credits.
Loop two: the loop that leaves the building
The second loop never asks GitLab to fire twice on its own actor. The trigger launches a flow; the flow posts a review; an external agent subscribed to that merge request, in a coordinator elsewhere or reading a channel where the review was mirrored, treats the comments as instructions; it pushes a new branch and opens a fresh merge request under its user’s token; the trigger fires on the new one. Or the hop is longer: a mirror on another host, a webhook there, an agent that answers webhooks, a merge request back here.
Every system in the chain excludes its own bots. Every system sees the other system’s actor as a human. The loop is real and no single vendor can see it, which is the same lesson Slack-started loops and review agents commenting on each other already taught with different nouns.
Loop three: no loop at all, just a bill
The third failure has no recursion in it. Created fires on every human-opened merge request, draft included, and developers open drafts in bursts: a rebase, a stacked-diff push, a Friday cleanup. Each fires a flow. The docs give no per-run credit cost.
What 19.4 does give you is the wrong shape of cap for this fault: a Credit caps page that sets a flat cap per user by default with per-user overrides, and emails to billing account managers when a capability’s on-demand usage reaches 50% or 80% of its monthly spend cap (GitLab 19.4 release notes). Monthly and per-user is a fine budget. It is not a fault detector for something that is per-merge-request and per-hour.
GitLab’s guard sits at the trigger and checks the actor. Guards one to five sit on your side and check the lineage.
The runbook: five guards outside GitLab’s guard
Guard 1: drop agent-authored events at your own gateway
Your gateway here is whatever receives GitLab events and hands them to external agents: a webhook receiver, a coordinator’s subscription, a channel bridge. It is the one place that sees every event before any agent does, so it is where GitLab’s actor rule gets extended to actors GitLab cannot classify. The rule is a denylist by provenance, not by name:
# gateway event policy — illustrative shape, not vendor syntax
source: gitlab
events: [merge_request.created, merge_request.note]
drop_when:
- author.oauth_application in ["codex-fleet", "claude-code-fleet", "cursor-cloud"] # agent grants
- mr.labels contains "agent-lineage:*" # agent-opened
- mr.description matches "^\\[agent-authored\\]" # marker line
- event.source_system != "gitlab" # mirrored hops
dispatch:
lineage_key: mr.labels["agent-lineage"] or mr.iid
max_cycles: 3
ceiling_usd: 8.00
The first line is the important one, and it needs a fact you may not have: which OAuth application the merge request’s author used. If your instance does not expose that on the event, make the agents declare themselves with the label and the description marker on every merge request they open, and reject at review any agent-opened merge request that arrives without them. An agent that cannot be told apart from a person is a person, with a person’s blast radius, which is the argument for giving each agent its own identity in the first place.
There is a cheaper version of this guard that GitLab hands you for free. Open agent merge requests from a service account, and per the docs the trigger cannot fire on them at all. The cost is that the flow’s review never runs on that merge request either, so decide per lineage which you want: a reviewed merge request that can loop, or an unreviewed one that cannot.
Guard 2: cap cycles per lineage, not per merge request
A loop that opens a fresh merge request each cycle defeats any counter attached to one merge request. Count the lineage instead: a label the first agent-opened merge request carries and every descendant inherits, agent-lineage:<id>, with the cycle count kept in a state note on the newest member. Three cycles per lineage, then a person. The CI piece has the state comment shape and the reasons three is the number; do not rebuild it, reuse it, with mr_iid and lineage fields added so a reader can find the siblings.
Two details are new here. Reset the count only when a human pushes a commit of their own to any member of the lineage. And treat a merge request that closes and reopens as the same member, not a new one; agents close and reopen when they cannot think of anything else.
Guard 3: a review signature, so the same findings do not buy another cycle
The CI loop keyed on a failure signature, the hash of what broke. The trigger loop keys on what the reviewer said.
Hash the flow’s review output with the noise removed: sorted file paths it commented on, the category of each finding, no line numbers, no timestamps. If the review of cycle N carries the same signature as cycle N-1, the last cycle changed nothing the reviewer cared about, and the gateway refuses to dispatch cycle N+1 no matter how many cycles remain. A new signature is the only thing that buys another paid cycle. That is the whole mechanic; the CI piece has the normalization recipe.
Guard 4: a cost ceiling per merge request across three meters
One cycle touches three bills: the flow run, metered in GitLab credits; the external agent’s run, metered in tokens and, for hosted agents, sandbox time; and CI minutes for whatever the agent pushed. The ceiling has to sum all three per merge request, and it has to be checked at the gateway before dispatch, because none of the three vendors can see the other two. Attribute credits after the fact from GitLab’s usage export, which 19.4 returns as a ZIP with two CSVs, a daily summary and a per-event file with one row per billable event; the per-event rows are the ones to try joining to a merge request, though the release notes do not say which identifiers they carry. Which meters exist and how they hide from each other is the three-meters piece; wire the ceiling’s trips into your cost anomaly feed so a lineage that hits it twice in a week is a signal on its own.
Illustrative, not measured. At $2.50 a cycle, eight uncapped cycles cost $20 per merge request; a cap of three stops at $7.50; the signature gate stopped this lineage at two cycles, $5, because the second review repeated the first. Run the cap and the gate together, because a gate alone keeps paying for as long as each review says something new.
Guard 5: the kill path, three switches deep
Time this on a throwaway project before you need it. Three switches, in order, and each one’s number is seconds from decision to the last agent action:
- The trigger toggle. 19.4’s off switch retains the configuration, so it is the first thing to hit, not the last. It ends GitLab-side fan-out for every merge request at once. Owner or Maintainer, under AI > Triggers.
- The gateway pause. Stop dispatch for one lineage, one project, or all of GitLab, without touching GitLab. This is the switch that ends loop two, because loop two runs through your gateway even when GitLab’s trigger is already off.
- The grant. Revoke the authorization the person gave the agent client. This ends loop one at the source, because an agent with no token cannot call
save_merge_requestas anyone. It is also the slowest to reverse, which is why it is third.
Write the three numbers on the runbook page and re-time them monthly. If the second switch does not exist yet, that is the Tuesday project.
One more setting belongs in the drill. A flow that a trigger launches runs with no person present, and the tool governance docs say that for background flows Always Ask does not apply and a tool with no configured runner rule defaults to Always Allow (GitLab Docs, Agent tool governance). The page never says whether a trigger-launched flow falls under those runner rules, so treat it as if it does: set explicit Deny on every write tool the reviewing flow does not need, or the review that was meant to comment can push.
Then check that the Deny is enforced at all. The same page puts enforcement for background flows behind a feature flag, duo_workflow_background_tool_governance, which was disabled by default when it arrived in 19.3. The governance piece has the full matrix.
Six signals a merge request loop has slipped a guard
| Failure mode | The signal | First response |
|---|---|---|
| Agent grant fires the trigger | A lineage label appears on a merge request whose author is a developer who was asleep | Guard 1 by label and marker; move the agent to a service account or its own identity |
| Cross-system echo | Two merge requests in one lineage opened within minutes, both with reviews | Gateway pause for the lineage; check the mirror and channel bridges for the second hop |
| Burst, not loop | Ten or more flow runs in an hour from one developer’s merge requests | Rate-limit dispatch per author at the gateway; ask whether drafts should fire at all |
| Ceiling counts one meter | Spend under ceiling while sandbox minutes climb | Add the agent and CI meters to the sum before dispatch |
| Reopen resets the count | Cycle count restarts on a closed-and-reopened merge request | Key the count on the lineage and the merge request identity, not on the open event |
| Runner rule left at default | The reviewing flow pushed a commit | Explicit Deny on write tools in runner scope; confirm background-flow enforcement is on; re-run the kill drill |
The burst row is the one you will see first, and it is not a bug in anything. It is a trigger doing what a trigger does, fourteen times after one rebase.
GitLab wrote the trigger rule; the lineage is yours
GitLab did the part that belongs to GitLab: it wrote down who may fire a trigger, and it excluded its own bots and flows. What remains is the part no vendor can write, because it spans the vendors. The lineage, the review signature, the three-meter ceiling and the timed kill path all live in the layer that decides whether a run starts, which is the operating layer every fleet ends up building whether or not it names it.
The state note on the newest merge request is also the record: every cycle, its signature, its spend, in order, which is what replaying a fleet’s night needs when someone asks on Tuesday what the agents did with the weekend. The trigger is intake. The loop is yours.
FAQ: GitLab Duo triggers and merge request loops
Can a bot or service account activate a GitLab Duo trigger?
No. GitLab’s triggers docs state that every trigger event type requires a human user to perform the triggering action, and that a bot user, a service account user or another flow cannot activate one. The docs do not address an external agent acting under a person’s OAuth grant, so plan for that case yourself.
Does the merge request Created trigger fire for draft merge requests?
Yes. Per the 19.4 release notes, the Created action runs your flow when someone opens a merge request in draft or ready state, once GitLab has generated its diff. That is why bulk pushes of draft merge requests are the first cost surprise, and why a per-author rate limit at your gateway pays off early.
How do I stop a GitLab Duo trigger without deleting it?
GitLab 19.4 adds a toggle that turns a flow trigger off while retaining its configuration, so you can turn it back on later. Treat it as the first switch in a three-switch kill path: the trigger toggle, a dispatch pause at your own gateway, then revoking the OAuth grant an external agent runs under.
