Reset the messaging delivery claim on each turn
Why are we doing this?
Duo messaging sessions (Slack today) deliver a turn's answer through ResultDelivery#deliver_success, which returns early when workflow.messaging_callback_context['delivered_at'] is present. We use delivered_at to ensure that we don't do duplicate deliveries.
The claim is reset by convention: whatever starts a new turn must clear it, or the next turn's answer is silently skipped. The service that did this, Ai::Messaging::ResumeServerSideFlowService, is removed in !256183 (merged), and its replacement (Ai::DuoWorkflows::ExecuteRunService, added in !255976 (merged)) does not reset it. So a continuation — a reply to an input_required session, or a tool-approval resume — would enqueue turn 2, the answer would be produced, and deliver_success would skip it because turn 1's delivered_at was still set. Nothing user-facing ships that path yet, so the bug is latent; this fixes it before a reply feature lands on it. It came out of review on !255976 (merged).
What does this MR do?
Makes the reset adapter-owned and applies it on every Workhorse continuation:
Ai::Messaging::Adapters::Base#per_turn_resetreturns the universal reset,{ 'delivered_at' => nil }. The Slack adapter overrides it to add its own per-turn key,status_ts— the progress messagedeliver_resultedits in place, cleared so a new turn posts its own message instead of overwriting the answer the user is still reading. No adapter-specific key appears in the service or workflow layers.Workflow#reset_messaging_turn!resolves the adapter from the persisted context (messaging_callback_context['adapter']viaAdapterRegistry) and merges itsper_turn_reset. It no-ops when there is no callback context or no registered adapter — a non-messaging session has nothing to reset.ExecuteRunService#start_workhorse_runcalls it on a continuation (!workflow.created?), immediately beforeServerSideTurnWorker.perform_async. That placement matters: the reset re-opens the previous turn's delivery gate, so it must run only once the new turn is certain to be enqueued — otherwise a continuation that fails validation would re-open the gate with no new turn to close it. A first turn does not reset — a fresh context has nothing to clear.
The CI path deliberately does not reset. start_ci_run's provisioning and token guards can still fail after a reset would run, and no messaging surface resumes on CI today. Applying the reset uniformly on both runtimes — once the consolidated dispatch runs after provisioning has succeeded — is tracked in #629005.
The approval path in !255538 (merged) enters through ExecuteRunService, so it inherits the reset with no change of its own.
References
- Issue: #628435
- Epic: gitlab-org#23521
- Stacked on: !255976 (merged)
- Blocks the wiring MR: !256183 (merged)
- Review thread that raised it: !255976 (comment 3857186547)
How to set up and validate locally
Specs cover the behavior. To poke it by hand in a GDK console (bundle exec rails console):
wf = Ai::DuoWorkflows::Workflow.last # a session that already delivered a turn
wf.claim_messaging_callback_delivery # simulate turn 1's delivery
wf.messaging_callback_context['delivered_at'] # => present
# A Workhorse continuation reopens the gate before enqueueing the next turn:
Ai::DuoWorkflows::ExecuteRunService.new(wf, event: { type: :input, text: 'ok, fix it' }).execute
wf.reload.messaging_callback_context['delivered_at'] # => nil, so turn 2 can be deliveredThe regression spec drives this end to end on the Workhorse path: deliver turn 1, run a continuation through ExecuteRunService, assert the gate reopens so turn 2's deliver_success would not skip. A second spec pins the failure case: a continuation that fails validation after the claim was set leaves the gate closed.
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.