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
- Follow-up that uses this: !255095 (closed)
- Background on the server-side execution endpoint this builds on, now merged: !254712 (merged)
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.rbEnd-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.