Add plumbing to resume a server-side messaging flow

What does this MR do and why?

Adds what is needed to run a second turn of a server-side flow against a session that already exists, without using it yet. The Slack side that decides when to continue a session rather than start one is in the follow-up, so on its own this MR changes no behaviour.

Today every mention of Duo in Slack creates a new session. The flow answers, finishes, and the next mention starts over with no memory of the exchange. What a surface needs instead is a way to say "run this against the session I already have", which is what these three pieces provide.

The pieces

TriggerBundle gains an optional workflow:. When set, the trigger means "continue this session" and goal is the next message rather than the first. Only the server-side branch honours it. A CI-executed flow resumes through a pipeline and ignores it, which is covered by a spec so the two paths cannot drift.

Ai::Messaging::ResumeServerSideFlowService, the counterpart to ExecuteServerSideFlowService. It creates nothing: the Duo Workflow Service restores the session from its last checkpoint, so a turn only has to carry the new message. What it does do is reconcile the callback context the previous turn left behind, nulling exactly two keys:

Key Why it is cleared
status_ts The surface message the previous turn wrote. Cleared so this turn opens its own rather than editing over an answer the user may still be reading.
delivered_at The one-shot claim that stops two jobs delivering the same answer. Cleared so this turn's answer can take it in turn.

Everything the session accumulated and still holds for is left alone. That matters most for progress_cursor: re-deriving it would replay the whole conversation as this turn's live progress. Both keys are reset before the job is enqueued, so a worker can never deliver against the previous turn's claim.

ServerSideTurnWorker#perform takes the message the turn is answering. Absent for the first turn, whose message is the session's own goal. The argument is appended after approval, which master already carries, so the signature is perform(workflow_id, approval = nil, goal = nil) and ResumeServerSideFlowService enqueues with ServerSideTurnWorker.perform_async(workflow.id, nil, goal), passing nil for approval because a resume driven by a new user message carries no approval decision. Inserting ahead of approval instead would shift a positional argument, so jobs already in flight would be read back wrong. The workflow's goal column is deliberately not rewritten per turn: a session's goal stays the message that opened it, matching a browser-driven chat session, where each new message travels in the start request and the stored goal is never touched.

Why this is split from its caller

The worker argument forces it. Per doc/development/sidekiq/compatibility_across_updates.md, an argument has to be deployed with a default in one release before anything passes it, or jobs enqueued by new code can reach old workers. This MR is the first step and the follow-up is the second, so they should not be deployed in the same release.

References

Screenshots or screen recordings

Not applicable: no user-visible change, and nothing calls the new path yet.

How to set up and validate locally

Behaviour is unchanged, so validation is the suite:

bundle exec rspec \
  ee/spec/services/ai/messaging/resume_server_side_flow_service_spec.rb \
  ee/spec/services/ai/messaging/adapters/base_spec.rb \
  ee/spec/workers/ai/messaging/server_side_turn_worker_spec.rb

End-to-end validation lives in the follow-up, which is where a Slack thread first continues a session.

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 Igor Drozdov

Merge request reports

Loading
Loading