Write ai_governance_sessions from SyncSessionArtifactWorker

What does this MR do and why?

Ai::DuoWorkflows::SyncSessionArtifactWorker now also writes each Duo workflow into the new ai_governance_sessions table, alongside the existing duo_workflow_session_artifacts write.

Without a writer, the new table stays empty, so nothing that reads from it can be tested end to end.

How it works:

  • Adds Ai::Governance::Session.sync_from_workflow!, an upsert that mirrors the existing Ai::DuoWorkflows::SessionArtifact.sync_from_workflow!.
  • It upserts on the partial unique index i_ai_governance_sessions_on_namespace_workflow (namespace_id, workflow_id where workflow_id is not null). This means repeated runs on each status change update one row instead of creating duplicates.
  • session_finished_at is left alone on sync, so a sync never resets it.
  • The table is added to the worker's defer_on_database_health_signal list.
  • The new write is behind the sync_ai_governance_sessions feature flag (gitlab_com_derisk, off by default), checked against the workflow's root namespace. Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/630766

Known limit: session_finished_at stays null for now. Setting it is a follow-up.

Review note: all three callers of this worker are Duo Agent Platform services (Ai::DuoWorkflows::CreateWorkflowService, Ai::DuoWorkflows::UpdateWorkflowStatusService, Ai::ExternalAgents::Sessions::CreateService), so a review from that team is wanted.

References

Closes https://gitlab.com/gitlab-org/gitlab/-/work_items/630096

Epic: https://gitlab.com/groups/gitlab-org/-/epics/21540

Depends on !256344 (merged), which adds the table (merged).

Screenshots or screen recordings

Not applicable. This is a backend-only change.

How to set up and validate locally

Run:

bundle exec rspec ee/spec/models/ai/governance/session_spec.rb ee/spec/workers/ai/duo_workflows/sync_session_artifact_worker_spec.rb

Database

Ai::Governance::Session.sync_from_workflow! runs one upsert per SyncSessionArtifactWorker job.

ai_governance_sessions has no rows on production yet, because this MR adds the first writer. To get realistic plans, I filled the table in the postgres.ai clone with the latest 1 million rows from duo_workflows_workflows:

exec INSERT INTO ai_governance_sessions (namespace_id, project_id, user_id, workflow_id, created_at, updated_at, session_started_at, source, status, agent_type, flow_type)
SELECT COALESCE(w.namespace_id, p.project_namespace_id), w.project_id, w.user_id, w.id, now(), now(), w.created_at, 0, w.status, w.agent_type, w.workflow_definition
FROM (SELECT * FROM duo_workflows_workflows ORDER BY id DESC LIMIT 1000000) w
LEFT JOIN projects p ON p.id = w.project_id;

exec ANALYZE ai_governance_sessions;

I then inserted one row with a known key, so the conflict path has a row to hit:

exec INSERT INTO ai_governance_sessions (namespace_id, project_id, user_id, workflow_id, created_at, updated_at, session_started_at, source, status, agent_type, flow_type)
VALUES (9970, NULL, 1, 999999999, now(), now(), '2026-09-22 14:39:51', 0, 0, NULL, 'software_development');

The insert path uses workflow_id 999999998, which has no existing row.

INSERT INTO "ai_governance_sessions"
  ("workflow_id","user_id","project_id","namespace_id","status","flow_type","agent_type","source","session_started_at","created_at","updated_at")
VALUES
  (999999999, 1, NULL, 9970, 1, 'software_development', NULL, 0, '2026-09-22 14:39:51', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("namespace_id","workflow_id") WHERE (workflow_id IS NOT NULL)
DO UPDATE SET
  updated_at = (CASE WHEN ("ai_governance_sessions"."user_id" IS NOT DISTINCT FROM excluded."user_id"
    AND "ai_governance_sessions"."project_id" IS NOT DISTINCT FROM excluded."project_id"
    AND "ai_governance_sessions"."status" IS NOT DISTINCT FROM excluded."status"
    AND "ai_governance_sessions"."flow_type" IS NOT DISTINCT FROM excluded."flow_type"
    AND "ai_governance_sessions"."agent_type" IS NOT DISTINCT FROM excluded."agent_type"
    AND "ai_governance_sessions"."source" IS NOT DISTINCT FROM excluded."source"
    AND "ai_governance_sessions"."session_started_at" IS NOT DISTINCT FROM excluded."session_started_at")
    THEN "ai_governance_sessions".updated_at ELSE CURRENT_TIMESTAMP END),
  "user_id" = excluded."user_id",
  "project_id" = excluded."project_id",
  "status" = excluded."status",
  "flow_type" = excluded."flow_type",
  "agent_type" = excluded."agent_type",
  "source" = excluded."source",
  "session_started_at" = excluded."session_started_at"
RETURNING "id"
Path When Plan Execution WAL records
Conflict (update) Later status changes https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58554/commands/163490 1.431 ms 2
Insert First sync of a workflow https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58554/commands/163491 0.452 ms 7

The conflict path updates the existing row in place. The insert path writes the row plus one entry in each of the 6 indexes. Both take only RowExclusiveLock, through the fast path.

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 Hitesh Raghuvanshi

Merge request reports

Loading
Loading