Say why a Duo session failed in get_duo_session
What does this MR do and why?
When a Duo session fails, get_duo_session now says why. It returns the reason GitLab
already records for the session as failure_reason, links the CI job as job_url when
the caller may read its log, and says when the session failed before the agent started,
so it is safe to start again. Before this, a caller got failed and a URL.
where the reason comes from, and why these choices
- The reason was already stored. It is the session's
summary. When a workload's job fails,UpdateWorkflowStatusEventWorkersets it toError during Session: <job failure_reason>, andGenerateWorkflowSummaryWorkerthen replaces that with an AI summary of the job log.CreateWorkflowServicealso writes one when it drops a session for want of a runner or compute minutes. On GitLab.com, session 8687327 from the issue already has: "The session failed because the WebSocket connection to the GitLab Duo Workflows service was rejected with an HTTP 401 Unauthorized error…". The tool now asks GraphQL forsummaryalongside the fields it already reads, so it is gated exactly as the session itself is. - The job link is checked against the job, not the session. GraphQL's
lastExecutorLogsUrldoes not check job access, so the tool looks the job up itself (the same jobWorkload#logs_urlpoints at) and returns the URL only withread_build_trace. Otherwise the text says "You do not have access to the job log." With no workload at all it says "No CI job ran for this session." - The retry hint is a fact, not a guess about the error text. Matching words like
"401" in an AI summary would be brittle. Instead the tool says whether the agent ever
ran: a session with no top-level checkpoint never started its graph, so it made no
changes and starting it again is safe. That was the issue's case (8687327 has no
checkpoint). It reuses the
latestCheckpointthe query already fetched, so a poll costs no extra query; the job lookup runs only for failed sessions. Sessions older than 30 days never get the hint: their checkpoints are partition-dropped by then, so a missing checkpoint says nothing about whether the agent ran (thanks @terrichu). - One thing worth a reviewer's eye: the summary is written from the job log, and
anyone who can read the session can read the summary, including someone who cannot read
that log. That is how
WorkflowType#summaryand the session page already behave; this MR does not widen it, but it does put it in front of agents.
The issue also asked whether its 401 came from the workload's token expiring. It did not: the token lasts an hour and the job connected after 14 minutes, using the same token that had worked three seconds earlier. With the hypothesis ruled out there is no follow-up issue; finding the real cause needs GitLab.com logs.
the 401 in the issue: token expiry ruled out
- The composite OAuth token the workload connects with lasts one hour
(
TOKEN_EXPIRES_IN = 1.hourincreate_composite_oauth_access_token_service.rb:11). It is minted at start and passed asGITLAB_OAUTH_TOKEN. - Job 16738506200 was created at 14:26:24, cloned from 14:27:13 to 14:38:59, connected the WebSocket at 14:40:30.87 and was rejected at 14:40:31.15: 14 minutes in.
- The same token had just worked: GraphQL
getUserreturned @fdegier at 14:40:28.UpdateAgentPrivilegesat 14:40:30.82 came back with a permission error in its body, but the successful run has the identical error, so it does not tell the two apart. - The log's
Token: type: pat, scopes:is the CLI describing an env-var token it never looked up, not empty scopes. A missing scope would be 403, not 401. - The retry's fresh start, job 16738980310, waited about as long (12m54s) and connected fine.
- Workhorse asks Rails to authorise the upgrade, so the 401 came from Rails' auth finders.
The next step is the GitLab.com
api_jsonlog for/api/v4/ai/duo_workflows/wsaround 2026-09-25T14:40:31Z:meta.auth_fail_reasonnames the cause, and an empty one points at token-not-found or the composite identity check.
References
Closes #631690 (closed). Related: !257040 (merged), #631448 (closed), part of &21395 (closed).
Screenshots or screen recordings
No UI change. The text get_duo_session returns for a session that failed before its agent
started (session 1013 in my GDK):
| Before | After |
|---|---|
| Session failed. See http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/1013 for details. | Session failed: The session failed during the get_sources step when the GitLab runner attempted to clone the repository … and received an HTTP 500 Internal Server Error … It failed before the agent started, so it made no changes and can be started again. Job log: http://gdk.test:3000/gitlab-duo/test/-/jobs/1408 See http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/1013 for details. |
How to set up and validate locally
steps, with what I got
I served the branch on port 3006 next to GDK on 3000, against the same database, so every call can be compared with master. With the branch checked out in GDK, use your GDK URL for both.
-
Create a token with the
mcpscope inrails console, and export it:PersonalAccessToken.create!(user: User.find_by_username('root'), name: 'mcp-validation', scopes: [:mcp], expires_at: 7.days.from_now, organization: Organizations::Organization.first).tokenexport GDK_MCP_TOKEN=<the token> -
Find a failed session:
curl --silent http://127.0.0.1:3006/api/v4/mcp \ --header "Authorization: Bearer $GDK_MCP_TOKEN" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_duo_sessions","arguments":{"project_id":"gitlab-duo/test","statuses":["failed"],"first":3}}}' \ | jq -c '.result.structuredContent.sessions[] | {id, status}'What I got:
{"id":1013,"status":"failed"} {"id":993,"status":"failed"} {"id":992,"status":"failed"}Session 1013 is a real failure from today: I started it with
start_duo_session(agent: analytics_agent/v1) and its runner could not clone the repository, because my GDK's Rails had broken on a code reload. A GDK problem, not this change, but the issue's case in miniature: it failed before the agent started, so starting again is the right call. -
Read it on master:
curl --silent http://gdk.test:3000/api/v4/mcp \ --header "Authorization: Bearer $GDK_MCP_TOKEN" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_duo_session","arguments":{"workflow_id":1013}}}' \ | jq '.result'What I got:
{ "content": [ { "type": "text", "text": "Session failed. See http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/1013 for details." } ], "structuredContent": { "workflow_id": 1013, "status": "failed", "web_url": "http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/1013", "created_merge_requests": [], "created_work_items": [], "created_notes": [] }, "isError": false } -
Read it on the branch, same call on port 3006:
curl --silent http://127.0.0.1:3006/api/v4/mcp \ --header "Authorization: Bearer $GDK_MCP_TOKEN" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_duo_session","arguments":{"workflow_id":1013}}}' \ | jq '.result'What I got:
{ "content": [ { "type": "text", "text": "Session failed: The session failed during the `get_sources` step when the GitLab runner attempted to clone the repository from `http://gdk.test:3000/gitlab-duo/test.git/` and received an HTTP 500 Internal Server Error from the Gitaly server. This caused `git fetch` to abort with exit code 128, preventing the job from retrieving the source code.\n\nIt failed before the agent started, so it made no changes and can be started again.\n\nJob log: http://gdk.test:3000/gitlab-duo/test/-/jobs/1408\n\nSee http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/1013 for details." } ], "structuredContent": { "workflow_id": 1013, "status": "failed", "web_url": "http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/1013", "created_merge_requests": [], "created_work_items": [], "created_notes": [], "failure_reason": "The session failed during the `get_sources` step when the GitLab runner attempted to clone the repository from `http://gdk.test:3000/gitlab-duo/test.git/` and received an HTTP 500 Internal Server Error from the Gitaly server. This caused `git fetch` to abort with exit code 128, preventing the job from retrieving the source code.", "job_url": "http://gdk.test:3000/gitlab-duo/test/-/jobs/1408" }, "isError": false }The reason is the AI summary
GenerateWorkflowSummaryWorkerwrote from job 1408's log, and it is right: my GDK's Rails log has the clone'sinfo/refsrequest failing with a 500 (Can't reinstall subscriptions) after a broken reload. -
Read a session that failed after its agent started:
curl --silent http://127.0.0.1:3006/api/v4/mcp \ --header "Authorization: Bearer $GDK_MCP_TOKEN" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_duo_session","arguments":{"workflow_id":993}}}' \ | jq '.result.content[0].text'What I got:
"Session failed: Error during Session: script_failure\n\nJob log: http://gdk.test:3000/gitlab-duo/test/-/jobs/1377\n\nSee http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/993 for details."No "started again" line, because session 993 has checkpoints. The reason is the job's own failure reason rather than an AI summary: on 2026-09-22 my local AI gateway rejected the summary request (a 401 in
log/llm.log), so the placeholder was never replaced. On GitLab.com the summary step works, as session 8687327 shows. -
Read a failed session older than 30 days, whose checkpoints are gone:
curl --silent http://127.0.0.1:3006/api/v4/mcp \ --header "Authorization: Bearer $GDK_MCP_TOKEN" \ --header 'Content-Type: application/json' \ --header 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_duo_session","arguments":{"workflow_id":960}}}' \ | jq '.result.content[0].text'What I got before skipping archived sessions:
"Session failed. No reason was recorded.\n\nIt failed before the agent started, so it made no changes and can be started again.\n\nJob log: http://gdk.test:3000/gitlab-duo/test/-/jobs/1349\n\nSee http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/960 for details."And after:
"Session failed. No reason was recorded.\n\nJob log: http://gdk.test:3000/gitlab-duo/test/-/jobs/1349\n\nSee http://gdk.test:3000/gitlab-duo/test/-/automate/agent-sessions/960 for details."Session 960 is from 2026-05-08. Its job log has 29
Tool completedlines, so the agent did run; the hint was wrong. Session 1013 from step 4, four days old and never started, still gets it. -
A caller who may read the session but not its job is covered by a spec only. The GDK test project has public pipelines on, so anyone who can read a session there can read its job too. The spec
when the caller may read the session but not its jobturns them off for a guest with theread_agent_artifactscustom role. Dropping theread_build_tracecheck makes it fail. -
Specs:
bundle exec rspec ee/spec/services/mcp/tools/duo_workflows/get_duo_session_tool_spec.rb ee/spec/services/mcp/tools/duo_workflows/get_duo_session_service_spec.rbWhat I got:
50 examples, 0 failures.
The validation ran on the branch before its rebase onto master (base 0a80f055), because
the newer master needs migrations my GDK database does not have yet. The rebase did not
change the patch.
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.