Add explicit resume mutation for workplan flows
What does this MR do and why?
Replaces !249603 (closed), which was closed because the team moved away from auto-resuming a paused workplan flow when every clarifying-question discussion was resolved, toward resuming as an explicit user action triggered by a button. This MR adds the GraphQL endpoint that button calls.
- Adds
Mutations::WorkItems::ResumeWorkplan(WorkItemResumeWorkplan), mounted as an experiment for milestone 19.4. Takes the work item's global ID, returns the resumed workflow plus errors. Mirrors the already-mergedWorkItemGenerateWorkplanmutation, including its authorization and a granular-token project boundary. - Adds
Ai::DuoWorkflows::ResumeWorkplanService. It finds the most recentworkplan/v1workflow for the work item, then checks in order: the work item is in a project, theduo_workplan_async_flowflag is enabled, a workflow exists, the resuming user canupdate_work_item, and the workflow isresumable?(awaiting input, and the previous run's workload pipeline has finished). It then takes an exclusive lease (guards against a double-click), reloads and re-checks state under that lease, assembles a message from the flow's questions and any replies left on them, and resumes throughResumeWorkflowService. - Carries forward two fixes from the closed MR, both prerequisites for any resume:
ResumeWorkflowServicere-appliesDUO_WORKFLOW_GOALon top of the preserved previous-workload variables (otherwise the stale goal from the first run wins and the reply never reaches the executor), and it omits the--approvalflag entirely whenhuman_approvalis nil instead of sending it empty. - Drops everything from the closed MR that isn't needed for this approach, so there is no CE footprint left:
WorkItems::DiscussionResolvedEvent, the publish fromDiscussions::ResolveServiceand its EE feature-flag module, theDiscussion#for_work_item?delegation, the Sidekiq worker and its event subscription, the queue entries, and a.rubocop_todo/gitlab/bounded_contexts.ymlentry.
Design decisions
- Unanswered questions do not block a resume. Clicking the button is the user's signal that they are done answering, so whatever has been answered is what the flow receives; resolving the discussion is no longer required.
- If nothing at all was answered, the flow gets an explicit "no answers were provided, continue with your best assumptions" message rather than an empty goal it cannot interpret.
- The resumed run keeps the original owner's identity and tokens, so the conversation has one identity across its turns.
workItemResumeWorkplanandworkItemGenerateWorkplanboth authorize onupdate_work_item, the same floorGenerateWorkplanalready used. An earlier version of this MR tightened both mutations to new granular permissions,resume_work_item_workplanandgenerate_work_item_workplan, enabled only viacan?(:admin_work_item)inee/app/policies/ee/work_item_policy.rb, becauseupdate_work_itemis granted to a Guest who authored or is assigned the work item — and checking it lets that Guest cause a CI workload to run under the flow owner's identity, tokens, and push access.- That tightening was reverted in commit
d7c89781to unblock testing and merging without first settling open review questions about the permission model, including whether the resuming user's own Duo Agent Platform entitlement needs to be re-checked on resume. Known consequence: a Guest who authored or is assigned the work item can generate or resume a workplan again — the exact gap the tightening had closed. The tightened permission model is being moved to a follow-up MR. :resume_duo_workflowwas not reused here because it bundles the resumable-state check together with a requirement that the resuming user be the flow owner, which would lock out every teammate. It is also the ability the REST resume endpoint uses for every flow type, so widening it would have affected chat sessions. Splitting state from permission avoids both problems:Workflow#resumable?now holds the state check (awaiting input and the previous run's workload pipeline has finished) andWorkflowPolicy'sresumablecondition delegates to it, so there is one definition rather than two that could drift.- Replying is open to anyone who can comment on the thread; the gated action is pressing resume, not replying. But the assembled goal only counts replies from authors above Guest access, checked via
ProjectTeam#max_member_access_for_user_ids(batched once for the whole reply set, not per author). The flow runs under the owner's push access and tokens, so an unfiltered reply would let a Guest, or a non-member on a public project, steer it without ever holding resume or push rights themselves. The threshold is above Guest rather than Reporter+ simply because every actual project member's reply should still count; only Guest-level and non-member commenters, who were never real project participants, are excluded. - Questions come from a new
created_notesassociation on the workflow (notes the flow itself wrote), notlinked_notes, which also covers the note that triggered the flow - using it would hand the agent its own prompt back as a question. Replies exclude the full set of flow-authored note ids, not just the question being answered, so a later message from the flow in the same thread cannot come back to it as an answer. Only one question is taken per discussion, the note that opened it, because a flow posting twice in one thread would otherwise restate the same reply under twoQ:headings. - The assembled message is truncated by characters (
GOAL_MAX_LENGTH) then by bytes (GOAL_MAX_BYTESIZE), the same pairCreateCheckpointServiceuses. This path never writesgoalto the workflow record, so the model's own length validation never runs, and these constants keep theDUO_WORKFLOW_GOALenvironment variable under Linux'sMAX_ARG_STRLEN. - Replies are loaded in one query grouped by discussion, since
Note#discussionqueries per call and would otherwise be an N+1 across the notes.
Dependencies
The whole feature sits behind the duo_workplan_async_flow feature flag, which is default off. No changelog entry, for that reason.
Testing
bundle exec rubocopis clean on all changed files.- The resume service spec and the resume mutation request spec together: 29 examples, 0 failures.
- The
ResumeWorkflowServicespec: 12 examples, 0 failures. - The generate mutation request spec: 8 examples, 0 failures.
- The work item policy spec: 29 examples, 0 failures.
- Exercised end to end against a local GDK: generation ran, the agent posted its clarifying question as a comment on a work item, and the workflow reached the awaiting-input state.
Testing locally
This feature is behind the duo_workplan_async_flow feature flag, so enable it for the project first. The flow is CI-backed, so the project needs a runner tagged gitlab--duo. Use GraphiQL at /-/graphql-explorer to run the mutations and query below.
- Enable the flag for the project:
Feature.enable(:duo_workplan_async_flow, Project.find_by_full_path('group/project'))- Start a generation. This returns the workflow that was started.
mutation {
workItemGenerateWorkplan(input: { id: "gid://gitlab/WorkItem/<id>" }) {
workflow { id status humanStatus }
errors
}
}- Watch the flow until it pauses. When the agent needs something, it posts its questions as comments on the work item and the workflow moves to
INPUT_REQUIRED.
query {
duoWorkflowWorkflows(projectPath: "group/project") {
nodes { id status humanStatus workflowDefinition updatedAt }
}
}- Answer by replying to the agent's comment on the work item in the UI. Resolving the thread is not required, a reply is enough. Replying at all is optional, see the next step.
- Resume the flow:
mutation {
workItemResumeWorkplan(input: { id: "gid://gitlab/WorkItem/<id>" }) {
workflow { id status humanStatus }
errors
}
}Error paths worth exercising
- Resuming with nothing answered: succeeds, and the flow receives the "no answers were provided" message instead of an empty goal.
- Resuming a flow that is not resumable:
Workplan generation is not awaiting input, or its previous run has not finished - Resuming a work item that never ran a flow:
No workplan generation to resume for this work item - Clicking twice quickly: the second call returns
A workplan resume is already in progress for this work item - A Guest who authored or is assigned the work item generating or resuming a workplan: succeeds (a known, deliberate gap - see Design decisions).
- A project with Duo remote flows disabled:
You are not allowed to run Duo Agent Platform flows in this project - The only reply comes from a Guest (or a non-member): treated the same as no answer, the flow receives the "no answers were provided" message instead.
Out of scope
The frontend button that calls this mutation. It needs the generationStatus field from !249588 (merged) to know when a flow is awaiting input, and belongs to the HITL workstream.