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-merged WorkItemGenerateWorkplan mutation, including its authorization and a granular-token project boundary.
  • Adds Ai::DuoWorkflows::ResumeWorkplanService. It finds the most recent workplan/v1 workflow for the work item, then checks in order: the work item is in a project, the duo_workplan_async_flow flag is enabled, a workflow exists, the resuming user can update_work_item, and the workflow is resumable? (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 through ResumeWorkflowService.
  • Carries forward two fixes from the closed MR, both prerequisites for any resume: ResumeWorkflowService re-applies DUO_WORKFLOW_GOAL on 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 --approval flag entirely when human_approval is 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 from Discussions::ResolveService and its EE feature-flag module, the Discussion#for_work_item? delegation, the Sidekiq worker and its event subscription, the queue entries, and a .rubocop_todo/gitlab/bounded_contexts.yml entry.

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.
  • workItemResumeWorkplan and workItemGenerateWorkplan both authorize on update_work_item, the same floor GenerateWorkplan already used. An earlier version of this MR tightened both mutations to new granular permissions, resume_work_item_workplan and generate_work_item_workplan, enabled only via can?(:admin_work_item) in ee/app/policies/ee/work_item_policy.rb, because update_work_item is 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 d7c89781 to 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_workflow was 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) and WorkflowPolicy's resumable condition 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_notes association on the workflow (notes the flow itself wrote), not linked_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 two Q: headings.
  • The assembled message is truncated by characters (GOAL_MAX_LENGTH) then by bytes (GOAL_MAX_BYTESIZE), the same pair CreateCheckpointService uses. This path never writes goal to the workflow record, so the model's own length validation never runs, and these constants keep the DUO_WORKFLOW_GOAL environment variable under Linux's MAX_ARG_STRLEN.
  • Replies are loaded in one query grouped by discussion, since Note#discussion queries 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 rubocop is clean on all changed files.
  • The resume service spec and the resume mutation request spec together: 29 examples, 0 failures.
  • The ResumeWorkflowService spec: 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.

  1. Enable the flag for the project:
Feature.enable(:duo_workplan_async_flow, Project.find_by_full_path('group/project'))
  1. Start a generation. This returns the workflow that was started.
mutation {
  workItemGenerateWorkplan(input: { id: "gid://gitlab/WorkItem/<id>" }) {
    workflow { id status humanStatus }
    errors
  }
}
  1. 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 }
  }
}
  1. 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.
  2. 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.

Edited by Stefanos Xanthopoulos

Merge request reports

Loading
Loading