Consolidate how server-side Duo flow runs are started
Product context
This is the consolidation half of making Duo's two execution engines (CI runner and Workhorse) interchangeable behind one entry point. The seam issue (#628435 (closed)) builds that single door; this issue moves the three existing server-side starters onto it so a new runtime or surface is added once, not three times.
Why
Rails has three different services that start a Duo flow from the server, and each one does session creation, provisioning and start on its own:
| Service | Used by |
|---|---|
Ai::Catalog::ExecuteWorkflowService |
Slack, @GitLabDuo MR mentions, API resume |
Ai::FlowTriggers::RunService |
Issue and MR triggers (assign, mention) |
Ai::DuoWorkflows::CreateAndStartWorkflowService |
Code review, workplan, recommend reviewers |
Composite identity is linked in four places (StartWorkflowService, RunService, Adapters::Base, GitlabDuoNote). Workflow tokens are minted in three. Service-account membership is added in the messaging adapter. The one thing all paths share is CreateWorkflowService, which creates the session row.
This is not broken, but it is why every new capability — a new runtime, a new surface, a change to identity handling — has to be added three times, and why the messaging adapter ended up owning execution it should only deliver for.
What we want
One way to start a run on a session, with the runtime chosen by the caller and provisioning owned by the runtime that needs it:
CreateWorkflowService # the session — already shared
Ai::DuoWorkflows::ExecuteRunService(workflow, event:, runtime:)
├─ Runtimes::Ci # StartWorkflowService + everything that exists because CI runs as a
│ # service account: tokens, identity link, membership, workspace project, branch
└─ Runtimes::Workhorse # user token + Workhorse turn workerEnd state: composite identity linked in one place, tokens minted in one place, membership added in one place — all inside Runtimes::Ci. The messaging adapter delivers and does nothing else. Web chat and the Duo CLI are untouched: they create a session through Rails and connect themselves, which is already the right split.
The vocabulary — a durable session, a run on one runtime — follows the direction described for Duo Developer sessions more broadly; this issue applies it to the server-side starters only.
Done when
- The three starters above call
ExecuteRunServiceand contain no runtime-specific provisioning. - Composite identity is linked in exactly one place.
Adapters::Base#triggeris gone or is a wrapper aroundwith_lifecycle_hookswith no execution logic.- Behaviour of every affected surface — Slack,
@GitLabDuomentions, issue/MR triggers, code review, workplan, recommend reviewers — is unchanged, verified by their existing specs. - On the
:workhorsepath no workspace project is materialized:DefaultProjectFlowResolverskips workspace-project and service-account resolution when the resolved runtime is:workhorse.
Proposal — order and what moves where
The entry point itself — ExecuteRunService with a minimal :ci branch (mint tokens, call StartWorkflowService / ResumeWorkflowService as they are) and the Workhorse turn worker — is built in the Duo for Slack epic (#628435 (closed)). It deliberately does not extract the CI path: the CI start code is shared by triggers, vulnerability flows, notes and the API, and extracting it for one caller would mean a copy. This issue is that extraction and the migration, in this order:
0. Runtimes::Ci — extract the CI start half out of ExecuteWorkflowService#build_start_workflow_params (token minting, identity link, membership, branch, StartWorkflowService) into a runtime class that ExecuteRunService dispatches to. StartWorkflowService's specs are the regression net.
1. CreateAndStartWorkflowService — cheapest, proves the extraction. It is already "create + start"; its start_workflow and token minting move into Runtimes::Ci, and the service becomes a thin wrapper or its three callers go direct.
2. Catalog::ExecuteWorkflowService — the rest of its start half and the StartWorkflowService / ResumeWorkflowService choice move into Runtimes::Ci. Resume becomes ExecuteRunService with an approval event (pending tool or plan decision) or an input event (a free-text reply to input_required). The service keeps validation, catalog checks and session creation. The hard-coded AGENT_PRIVILEGES constant goes; privileges come from the registry or the catalog item, as CreateAndStartWorkflowService already does.
3. FlowTriggers::RunService — most tangled, last. The composite link in its constructor and run_workload / start_catalog_workflow move into Runtimes::Ci via ExecuteRunService. Trigger validation and the autonomous-trigger rules stay. Its progress-note handling overlaps Adapters::GitlabDuoNote, which already does the same for foundational flows; folding the two is a follow-up worth its own issue.
4. Delete the duplicates — link_composite_identity in RunService, Adapters::Base and GitlabDuoNote; the membership add in the adapter; DefaultProjectFlowResolver's project-and-service-account half, which becomes Runtimes::Ci input.
5. The REST start route (added 2026-10-16). POST /api/v4/ai/duo_workflows/workflows (ee/lib/api/ai/duo_workflows/workflows.rb) is a fourth starter that the table above doesn't list: for non-catalog flows it calls StartWorkflowService directly. With !259682, foundational agents started over MCP take a second path in the same route, through ExecuteRunService on Workhorse, so the route picks the runtime itself. Move its CI start onto ExecuteRunService as well. That needs Runtimes::Ci to accept what the route builds today: start_workflow_params and goal_params (source branch, pipeline context).
Not needed: an explicit Runtimes::Client for browser and CLI. Rails only creates the session for them today, and that is correct as it is. Recording :client as the session's runtime at the WebSocket attach is a small, optional addition.
Naming: ExecuteRunService plus a Runtimes:: namespace for the strategies, keyed by where the run happens (:ci, :workhorse, :client). RunService and RunWorkloadService already exist, so no bare Run*Service.
Open question — how a Workhorse run is authorized. CreateWorkflowService#check_ai_catalog_item_access only consults the AI Catalog item-consumer records (an Ai::Catalog::ItemConsumer row), which is the project-scoped governance model. A foundational flow enabled at the namespace level (an EnabledFoundationalFlow row, via flow enablement) has no such consumer, so passing ai_catalog_item_version on the Workhorse path makes the check reject sessions that flow-enablement already allowed. The Slack switch-over (!256183 (merged)) therefore drops ai_catalog_item_version on the Workhorse path and relies on flow enablement plus check_access — the same level of gating web chat uses. Before Workhorse runs project-scoped API-only flows, decide whether that check should also accept namespace flow-enablement, or whether project-scoped Workhorse runs keep the item-consumer requirement. Do not let this stay implicit: the consolidation is the right place to settle it, because it is where the runtime/provisioning split is defined. !259682 is the first API-started, project-scoped Workhorse run: for foundational agents it relies on the agentic chat checks in CreateWorkflowService (the agent enabled for the namespace, access_duo_agentic_chat), the same gating web chat uses.
Per-turn delivery reset must move into the consolidated dispatch. A messaging session's answer is delivered exactly once via the delivered_at claim in messaging_callback_context; whatever starts a new turn must clear that claim first, or the next turn's answer is skipped as already-delivered. Today the reset (Workflow#reset_messaging_turn!) runs only on the Workhorse path, immediately before ServerSideTurnWorker.perform_async, because that is the one place the turn is guaranteed to enqueue. The CI path deliberately does not reset: start_ci_run's provisioning/token guards can still fail after a reset, which would re-open the previous turn's delivery gate with no new turn to close it (a duplicate-delivery risk). When the consolidation gives both runtimes a single dispatch that runs only after provisioning and validation have succeeded, apply the reset there, on both runtimes, at that single point. Until then, do not hoist the reset earlier in ExecuteRunService#execute — that is the premature-reset bug flagged in the review of !256391 (merged).
Already landed ahead of this issue (in !256183 (merged)). Two pieces of the target shape are done early, so build on them rather than re-doing them. (1) The surface resolves the runtime once and sets runtime: on the trigger bundle: DefaultProjectFlowResolver#runtime resolves it and returns it in its payload, the Slack surface passes it into the bundle, and Adapters::Base#trigger honours bundle.runtime (falling back to inference for a caller that did not resolve). This is the "caller-chosen runtime" seam; the consolidation should keep the surface as the resolver. (2) Adapters::Base#trigger refuses a non-:none coding-environment flow before creating the session row, so a refused Workhorse run never leaves a stranded created workflow. That guard duplicates the "Workhorse = :none" knowledge that also lives in ExecuteRunService::SUPPORTED_CODING_ENVIRONMENTS; the consolidated dispatch should own that support matrix in one place so the adapter's guard can be dropped.
Related
- Duo for Slack epic: &23521 (closed) — builds
ExecuteRunServiceand the Workhorse runtime. - Runtime seam issue: #628435 (closed) — defers the CI extraction to here.
- Investigation of the current starters: #602541 (closed)