Add generationStatus field to AgentPlan widget
What does this MR do and why?
Adds the generationStatus field to the WorkItemWidgetAgentPlan GraphQL type, exposing the current status of a work item's Workplan generation through the Agent Plan widget.
The field is marked experiment: { milestone: '19.4' } and null: true, and resolves through BatchLoader::GraphQL so a list of work items costs a single query. It's backed by a new GraphQL enum, WorkItemAgentPlanGenerationStatus (registered as Ruby symbols), with values NOT_STARTED, GENERATING, NEEDS_INPUT, COMPLETED, and FAILED.
The widget exposes a class method, WorkItems::Widgets::AgentPlan.generation_statuses_for(work_item_ids), that returns one status per requested ID. The actual status derivation lives on the model, as Ai::DuoWorkflows::Workflow#progress_status, next to the existing #status_group and #status_terminal?. It returns :generating, :needs_input, :completed, or :failed; the widget adds :not_started for work items with no run:
- NOT_STARTED: no
workplan/v1workflow has run for the work item - GENERATING: a run is in progress and hasn't reached a terminal state, which includes a paused run
- NEEDS_INPUT: the run is waiting on the user, covering
input_required,plan_approval_required, andtool_call_approval_required - COMPLETED: the run finished successfully. This describes the run, not the work item: plan content is written by a separate work item update and may be absent, so a client that needs to know whether a plan exists should read
content - FAILED: the run ended without completing, which deliberately includes a run canceled by the user (
stopped)
This is purely additive and read-only; no existing behavior changes.
Specs
ee/spec/models/ai/duo_workflows/workflow_spec.rb: a parameterized#progress_statustable covering all nine workflow statuses, plus alatest_per_issuescope spec covering most-recent-per-issue selection and the narrowed column listee/spec/models/work_items/widgets/agent_plan_spec.rb: status per work item, including the non-obviousstoppedto FAILED mapping, and a single-query assertion for the batchee/spec/graphql/types/work_items/widgets/agent_plan_type_spec.rb: field exposure on the typeee/spec/requests/api/graphql/project/work_items_spec.rb: the real enum coercion path, Ruby Symbol to GraphQL string, plus an N+1 check across several work items
Dependencies
Ai::DuoWorkflows::GenerateWorkplanService and the WORKFLOW_DEFINITION_REFERENCE = 'workplan/v1' constant landed in !249142 (merged) (merged). This MR has been rebased onto master and no longer depends on an open MR.
The broader async Workplan generation feature is behind a feature flag (duo_workplan_async_flow); this MR only exposes the schema field.
Split from !247170 (closed)
Database
This MR adds no migration and no schema change. It exposes a read-only generationStatus field on the AgentPlan work item widget, backed by one batched query against duo_workflows_workflows (gitlab_schema: gitlab_main_org, table_size: small, 14 indexes). The query runs once per GraphQL request, batched over at most one page of work item IDs (100), and returns one row per work item.
Two scopes were added to Ai::DuoWorkflows::Workflow: for_issue, and latest_per_issue, which picks the newest run per work item with DISTINCT ON (issue_id). The select and order live in the same scope because DISTINCT ON only returns the intended row when the ORDER BY leads with issue_id.
SELECT DISTINCT ON (issue_id) id, issue_id, status
FROM "duo_workflows_workflows"
WHERE "duo_workflows_workflows"."workflow_definition" = 'workplan/v1'
AND "duo_workflows_workflows"."issue_id" IN (100 work item IDs)
ORDER BY "duo_workflows_workflows"."issue_id" ASC, "duo_workflows_workflows"."id" DESC;The query uses index_duo_workflows_workflows_on_issue_id, a btree on issue_id alone; workflow_definition is applied as a filter, not an index condition.
The database reviewer ran this against a production clone: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/56027/commands/160013, measured against the 100 issues with the most duo_workflows_workflows rows attached — a deliberate worst case. workplan/v1 has no rows on GitLab.com yet, since creating it is gated by the duo_workplan_async_flow feature flag (default off), so the reviewer substituted workflow_definition = 'ai_catalog_agent'. This substitution is valid because no index covers workflow_definition: the pre-index cost depends only on how many rows sit under those issue_ids, not on which definition is filtered for. The definition only changes how many of the read rows survive the filter, not how many are read.
Results: 1,405 rows read, 736 matched the definition, 669 were discarded by the filter, 54 rows returned (only 54 of the 100 issues had a matching run) — about 14 workflows per issue. Buffers hit=106, read=1,432. 37.3 ms cold, of which 31.8 ms was I/O; about 5.5 ms warm. Plan shape: Index Scan feeding an Incremental Sort (Presorted Key: issue_id), then Unique, collapsing 736 rows to 54. The reviewer's verdict: fine from a database perspective — 37 ms on a deliberate worst case is inside the 100 ms guideline for general queries documented in doc/development/database/query_performance.md — and no new index is needed to ship this.
An earlier revision picked the newest run per work item with a Ruby-side group_by, which left the result set unbounded: a work item that regenerates its plan N times contributed N rows. DISTINCT ON bounds it to one row per work item. Buffers are unchanged either way, since no index covers workflow_definition and Postgres has no index skip scan, so this bounds what the application materializes, not what the database reads.
A previous version of this section quoted timings from a local synthetic seed (200,089 workplan/v1 rows over 1,130 issues, about 177 runs per issue, 13% matching the definition). That seed overstated production in both directions — real production is about 14 runs per issue and 52% matching (736 of 1,405) — so those numbers are dropped in favour of the clone measurement above.
Two more notes from review:
- The query selects only
id,issue_id,status, becausestatusis all the derivation reads, and the table also carries agoal textcolumn (capped at 65,536 characters by a check constraint) plus twojsonbcolumns thatSELECT *would pull for every row. - A follow-up index MR (still a draft), !253180 (closed), proposes a composite index on
(issue_id, workflow_definition, id DESC). It is not part of this MR, which stays schema-only. Its description currently claims a ~67x improvement, but that came from the same bad synthetic seed; on the clone shape the realistic gain is closer to 2x (roughly 1,405 heap fetches down to ~736, and the sort node disappears). It needs re-measuring on the clone, should dropindex_duo_workflows_workflows_on_issue_idin the same migration (issue_idleads the new composite, so it covers the old index's queries and the FK onissue_id) to avoid taking the table from 14 to 15 indexes — the documented per-table ceiling indoc/development/database/adding_database_indexes.md— should also measure anINCLUDE (status)variant since the query only reads those three columns, and belongs in a post-deployment migration as an improvement to an existing query.