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.
-
Create a token with the
mcpscope. 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 -
Start a real session. Do not hand-build the workflow: the resume route calls
find_catalog_consumer_for_workflow!, which needsai_catalog_item_versionplus a consumer for that item, so a bareWorkflow.create!gets404 AI Catalog item not found for this workflowfurther 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 idscurl -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} -
Move it into an approval status and mark its pipeline finished.
update_columnskips 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 whatresumable?returns on master. That pair is the whole change. -
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_idmoved from 505 to 506, andworkflows_workloads.countwent from 1 to 2, so a second CI job really was queued. On master the same call answers403 Forbidden, which the tool rewrites as"Session 979 is not waiting for input, or you cannot resume it."resumable?isfalseimmediately 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. -
Confirm
get_duo_sessionno 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_actionin the response:0. The guidance now namessend_duo_session_inputwithhuman_approval=trueto approve orfalseto reject. -
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.