Start Duo Code Review flow out of band

What does this MR do and why?

Duo Code Review is itself a Duo flow. Starting it authenticates as the Code Review service account, which links a composite identity to the request. A request can carry only one composite identity.

When a review is requested from inside a request that already acts as another Duo flow's service account, such as a merge request created or un-drafted by a Duo flow, linking the second identity fails and no review runs.

This MR starts the review from a Sidekiq worker that carries no composite identity, so the review's identity link no longer competes with whatever identity the enqueuing request already carries.

Changes

  • New worker Ai::DuoWorkflows::CodeReview::StartReviewWorker, which starts the flow with no composite identity linked.
  • EE::MergeRequests::BaseService#start_duo_code_review enqueues that worker instead of starting inline, behind the feature flag.
  • The billable human is resolved at enqueue time, because the worker has no identity to resolve from.
  • MergeRequest#duo_code_review_in_flight? keeps the worker idempotent under Sidekiq's at-least-once delivery.
  • MergeRequest#duo_code_review_startable? is now shared by the request path and the worker.
  • CreateAndStartWorkflowService marks a workflow failed when its start does not succeed, instead of leaving it looking live, and emits the terminal event and audit record that CleanStuckWorkflowsService used to emit half an hour later.

The last item touches a service shared by all Duo flows, not just Duo Code Review. It has three callers: CodeReview::ReviewMergeRequestService, RecommendReviewers::ExecuteService and GenerateWorkplanService.

Each of these carries an inline comment on the diff explaining why it is the way it is.

Feature flag

  • Name: duo_code_review_clean_identity_start
  • Type: gitlab_com_derisk, default_enabled: false
  • Checked against the merge request's project.
  • Gates the transport. With the flag off, the review starts inline exactly as before.

One change is deliberately not gated, because it is not specific to Duo Code Review: a workflow whose start fails now moves to failed at once rather than sitting at created until CleanStuckWorkflowsService reaps it. The terminal state and the records emitted are the same as before, they just arrive at the point of failure instead of thirty minutes later. duo_code_review_in_flight? depends on this, because it matches created and running, so without it a failed start would block every later review request on that merge request until the reaper ran.

References

How to test

Prerequisites

  • A GDK with Duo Agent Platform working, and an online CI runner tagged gitlab--duo. Duo flows sit at "Starting job" without one.

  • A project with Duo features and foundational flows enabled, and "Automatically request Duo Code Review" enabled in the project settings.

  • The project's numeric ID, shown on the project overview page. You paste it into the flow definition below.

  • Restart Sidekiq after checking out this branch, because GDK's Sidekiq does not reload changed code:

    gdk restart rails-background-jobs

Step 1: create the probe flow

To reproduce the original bug you need a request that already carries a composite identity before the review is requested. This flow acts as "another Duo flow": it takes a merge request out of draft using its own service account, and that action is what causes Duo Code Review to be requested from inside an already identity-carrying request.

  1. Go to your project, then Automate, then Flows, then New flow.
  2. Give it a name, for example PROBE DCR undraft.
  3. Paste the definition below, replacing PID=1000000 with your own project's numeric ID.
  4. Save the flow, then press Enable on it so a service account is provisioned for the project.
  5. Note the service account username that gets created. It looks like ai-probe-dcr-undraft-<group>.
version: "v1"
environment: ambient

components:
  - name: "capture_goal"
    type: DeterministicStepComponent
    tool_name: "create_file_with_contents"
    inputs:
      - from: "context:goal"
        as: "contents"
      - from: "probe_goal.txt"
        as: "file_path"
        literal: true
    ui_log_events:
      - "on_tool_execution_success"
      - "on_tool_execution_failed"

  - name: "probe"
    type: DeterministicStepComponent
    tool_name: "run_command"
    inputs:
      - from: >-
          echo "=====PROBE_BEGIN=====";
          PID=1000000;
          IID=$(sed -n 's/.*MergeRequest IID: \([0-9][0-9]*\).*/\1/p' probe_goal.txt | head -1);
          echo "--- target MR: !$IID";
          P="projects/$PID/merge_requests/$IID";
          echo "--- draft before:"; glab api $P 2>&1 | grep -o '"draft":[a-z]*' | head -1;
          T=$(glab api $P 2>&1 | grep -o '"title":"[^"]*"' | head -1 | sed 's/^"title":"//; s/"$//; s/^Draft: *//');
          echo "--- un-draft:"; glab api --method PUT $P -f title="$T" 2>&1 | grep -o '"draft":[a-z]*' | head -1;
          sleep 40;
          echo "--- notes:"; glab api "$P/notes?per_page=30&sort=desc" 2>&1 | grep -o '"body":"[^"]\{0,240\}"';
          echo "=====PROBE_END====="
        as: "command"
        literal: true
    ui_log_events:
      - "on_tool_execution_success"
      - "on_tool_execution_failed"

routers:
  - from: "capture_goal"
    to: "probe"
  - from: "probe"
    to: "end"

flow:
  entry_point: "capture_goal"

A mention on a merge request hands the flow a goal containing Context: {MergeRequest IID: 17}. The first component writes that goal to a file, and the second reads the IID out of it and takes that merge request out of draft. This is why the flow acts on whichever merge request you mention it on.

Step 2: add a Mention trigger

  1. Go to Automate, then Triggers, then New trigger.
  2. Point it at the flow you created, and choose the Mention event.

Step 3: run it

  1. Create or reopen a merge request in that project. It must be a Draft, have a non-empty diff, and not already have GitLab Duo as a reviewer.

  2. Comment on it, mentioning the flow's service account, for example:

    @ai-probe-dcr-undraft-<group> please run
  3. The flow starts and takes the merge request out of draft.

Expected results

With the flag disabled:

  • The merge request is still taken out of draft.
  • GitLab Duo is still auto-assigned as reviewer.
  • No review runs. GitLab Duo posts an error note instead, carrying error code DCR5000.
Un-drafted by a human Un-drafted by service account (the PROBE DCR undraft flow)
Screenshot_2026-08-12_at_16.23.40 Screenshot_2026-08-12_at_16.21.22

With the flag enabled for the project:

Feature.enable(:duo_code_review_clean_identity_start, Project.find_by_full_path('group/project'))
  • The merge request is taken out of draft.
  • GitLab Duo is auto-assigned as reviewer.
  • A system note appears saying a review session started, and the review runs.
Un-drafted by a human Un-drafted by service account (the PROBE DCR undraft flow)
Screenshot_2026-08-12_at_19.29.49 Screenshot_2026-08-12_at_19.27.28

Enabling a flag for a project actor shows as "off" on the global toggle in the admin UI. That is expected, because the actor gate is what applies, not the global one.

Also worth testing

  • Un-draft a merge request as a human, with the flag both on and off. Both should produce a review.
  • Cancel a running review session from Automate, then unassign and reassign GitLab Duo. A new review session should start.
Edited by Denys Mishunov

Merge request reports

Loading
Loading