Let Duo sessions resume from the approval statuses

What does this MR do and why?

A Duo session waiting for plan or tool-call approval can now be answered with send_duo_session_input, where before only input_required sessions could be. Also fixes get_duo_session pointing callers at approve_duo_agent_action, which is not a registered tool.

Mechanism and verification

Workflow#resumable? accepted input_required alone, even though the resume state machine event already transitions out of all three statuses and a resume carries the caller's approve/reject decision to the executor as --approval. It now accepts the whole awaiting_input group.

Widening resumable? also widens the :resume_duo_workflow ability, so the REST resume endpoint accepts those statuses for every client, not only the MCP tool. ResumeWorkplanService keeps the old narrow rule through a new input_resumable?: it sends no approve/reject decision, so it must stay on plain input_required resumes. That guard is mutation-tested — removing && workflow.input_required? fails exactly the two new examples in resume_workplan_service_spec.rb.

Verified against duo-workflow-service main, correcting the code path. A custom AI Catalog flow sends its definition as an inline config, so it resolves through agent_platform, not through the legacy human_approval components this note cited before. The open question was whether the executor treats --approval as the answer to a pending tool-call pause, or whether that path needs prior approval bookkeeping populated. In agent_platform/v1/flows/base.py, _resume_command maps an approve decision to a FlowEventType.APPROVE event. In agent_platform/v1/components/agent/nodes/tool_approval_fetch_node.py, the APPROVE branch only sets the workflow status to WorkflowStatusEnum.EXECUTION and records the decision. No tool-approval state is read or written there, so --approval true on its own is sufficient, the same conclusion as before.

The same _resume_command maps a rejection carrying a message to FlowEventType.MODIFY, and a rejection with no message to FlowEventType.REJECT. Both tool_approval_fetch_node.py and agent_platform/v1/components/human_input/nodes/fetch_node.py set the status to WorkflowStatusEnum.EXECUTION for a rejection either way, so the flow continues. It does not end the session. agent_platform/experimental/flows/base.py carries the identical mapping, so the schema version a config declares does not change this.

The legacy human_approval component (duo_workflow_service/components/human_approval/component.py) does not end the session either. Its routing returns Routes.STOP only on an explicit stop event or a cancelled or errored status, and Routes.BACK otherwise. So the answer holds on every path.

Because of that rejection finding, the wording was corrected in three places, the tool's human_approval parameter description, the get_duo_session guidance string, and the human_approval row in the user documentation. They previously claimed a rejection without a message may end the session.

The CI path runs the Node executor (duo run --existing-session-id <id> --connection-type websocket, see StartWorkflowService#executor_cli_command), with --approval appended in ResumeWorkflowService#executor_cli_command. The Go duo-workflow-executor is not involved.

send_duo_session_input now refuses human_approval=true combined with a human_message before it dispatches, since duo-cli's own argument parsing refuses --rejection-reason unless --approval is false, so without the guard that pair would queue a CI job that dies parsing its own arguments and leaves the session paused. The REST route still accepts the pair, but MCP can't reach it directly since the underlying resume_duo_workflow tool is only registered through this aggregator, which is why the guard belongs here.

References

Completes #617171 (closed), which will be closed manually.

Together with !254765 (merged), which added the tool and the scope grant, a session can now be answered in every status that waits for a human: input_required, plan_approval_required and tool_call_approval_required.

Screenshots or screen recordings

No frontend code changes. One user-visible effect: the session page's approve/modify panel, gated on the resumeDuoWorkflow permission, now appears for CI sessions paused in plan_approval_required or tool_call_approval_required. Previously the approval request rendered there with no way to act on it.

How to set up and validate locally

Verified end to end on GDK, steps and observed output

Status values: 6 = input_required, 7 = plan_approval_required, 8 = tool_call_approval_required.

  1. Create a token with the mcp scope. There is no checkbox for it in the UI yet:

    PersonalAccessToken.create!(
      user: User.find_by_username('root'), name: 'mcp', scopes: [:mcp],
      expires_at: 2.days.from_now,
      organization: Organizations::Organization.default_organization
    ).token
  2. Start a real session. Do not hand-build the workflow: the resume route calls find_catalog_consumer_for_workflow!, which needs ai_catalog_item_version plus a consumer for that item, so a bare Workflow.create! gets 404 AI Catalog item not found for this workflow further down the endpoint.

    project = Project.find_by_full_path('gitlab-duo/test')
    Ai::Catalog::ItemConsumer.where(project: project).map { |c| [c.id, c.item&.name] }
    # => [[723, "Developer"], ...]   # consumer ids, not workflow ids
    curl -s -X POST http://gdk.test:3000/api/v4/mcp \
      -H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"start_duo_session",
           "arguments":{"project_id":"1000000","ai_catalog_item_consumer_id":723,
                        "goal":"verify approval resume"}}}' | jq -c '.result.structuredContent'

    What I got: {"workflow_id":979,"status":"created","workload_id":505,"poll_after_seconds":30}

  3. Move it into an approval status and mark its pipeline finished. update_column skips the state machine, which is the point:

    w = Ai::DuoWorkflows::Workflow.find(979)   # workflow_id from the response, not the consumer id
    w.update_column(:status, 8)
    w.workloads.order(created_at: :desc).first.pipeline.update_column(:status, 'success')
    w.reload
    
    done = Ci::Pipeline.completed_statuses.include?(w.last_workload_pipeline_status)
    [w.status_name, w.resumable?, w.input_required? && done]

    What I got: [:tool_call_approval_required, true, false]. The second value is this branch, the third is what resumable? returns on master. That pair is the whole change.

  4. Answer the session:

    curl -s -X POST http://gdk.test:3000/api/v4/mcp \
      -H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"send_duo_session_input",
           "arguments":{"workflow_id":979,"human_approval":true}}}' | jq '.result'

    What I got: isError: false, workload_id moved from 505 to 506, and workflows_workloads.count went from 1 to 2, so a second CI job really was queued. On master the same call answers 403 Forbidden, which the tool rewrites as "Session 979 is not waiting for input, or you cannot resume it."

    resumable? is false immediately afterwards, because the new workload's pipeline has not finished. That is the half of the predicate that stops a second pipeline being started for the same session.

  5. Confirm get_duo_session no longer names a tool that does not exist:

    curl -s -X POST http://gdk.test:3000/api/v4/mcp \
      -H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"get_duo_session",
           "arguments":{"workflow_id":979}}}' | jq -r '.result.content[0].text'

    Occurrences of approve_duo_agent_action in the response: 0. The guidance now names send_duo_session_input with human_approval=true to approve or false to reject.

  6. Confirm the workplan guard still refuses:

    w.resumable? && w.input_required?   # => false, so ResumeWorkplanService refuses

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Fred de Gier

Merge request reports

Loading
Loading