Tell the messaging adapter when a run fails or pauses for approval

Why

The messaging adapter turns session events into Slack messages. Today it hears about two of them directly — the session started, the session finished — and learns everything else from CI: when the CI job ends, the adapter assumes the run is over, and if the job failed, that the run failed.

Two things break that assumption. Without CI (the headless runtime) there is no job, so a crashed run leaves the Slack thread with a spinning 👀 forever. And with tool approvals, a run pauses rather than finishes — and on CI the job ends at that point, which the adapter today would read as "done" and post whatever the agent said last as the final answer.

What this issue delivers

The session itself tells the adapter what happened, on every runtime:

  • A run failed — the adapter posts the failure to Slack, as it does today for a failed CI job.
  • The agent is waiting for an approval — a new hook the Slack approvals issue fills in. Until then it does nothing.
  • The CI backstop stops mistaking a pause for a finish. A CI job that ends while the session is waiting for approval is a pause, not a result.

Done when

  • A session with a messaging callback context that is dropped or stopped fires on_flow_failed on its adapter, with no CI job involved.
  • A session that enters tool_call_approval_required fires a new on_approval_required hook; the base adapter's default does nothing, and Slack still behaves as today.
  • On CI, a job that ends while its session is in tool_call_approval_required posts nothing as a final answer and does not mark the delivery as done; the real answer is posted when the session finishes later.
  • Existing @GitLabDuo mention and Slack behaviour is unchanged for sessions that simply finish or fail.
Proposal — for whoever implements this

Events

Ai::DuoWorkflows::Workflow's state machine already publishes WorkflowStartedEvent on start and WorkflowFinishedEvent on finish, both after_transition, both guarded by messaging_callback_context.present? so non-messaging sessions emit nothing. Add the same pattern for:

  • drop and stop → WorkflowFailedEvent (one event; the data can carry which transition).
  • require_tool_call_approval → WorkflowApprovalRequiredEvent.
  • require_input → WorkflowInputRequiredEvent. Same code, no consumer yet; the hook defaults to a no-op. Cheap to add now, needed when the flow gets a conversation loop.

Subscribe Ai::Messaging::CallbackWorker to them in ee/lib/gitlab/event_store/subscriptions/ai_subscriptions.rb.

Adapter hooks

In Ai::Messaging::Adapters::Base:

  • on_flow_failed(callback_context:, error:, workflow:) exists. CallbackWorker calls it for WorkflowFailedEvent with a reason such as :flow_dropped / :flow_stopped.
  • New on_approval_required(callback_context:, workflow:), default no-op. The Slack adapter implements it in the approvals issue. Runs in an at-least-once worker, so implementations must be idempotent — same rule as on_flow_started.
  • New on_input_required(callback_context:, workflow:), default no-op.

The CI backstop — this is the part that must not be skipped

CallbackWorker#handle_workload_finished treats a finished CI workload as the end of the run: if the workload status is finished (or the session is), it calls deliver_success. When a flow pauses for approval on CI, the executor exits and the job finishes successfully — so today a pause would be delivered as the final answer.

Worse: deliver_success claims the delivery first (claim_messaging_callback_delivery, sets delivered_at). If a pause is misread as completion, the claim is taken, and when the session later really finishes, already_delivered? is true and the real answer is never posted.

Rule for the backstop: a finished workload whose session is in tool_call_approval_required, input_required, plan_approval_required or paused is a pause. Do not call deliver_success, do not claim. Either do nothing (the state-machine event above already fired the hook) or fire the matching hook idempotently. Only a workload that finishes with the session finished is a result; only one that fails with the session not paused is a failure.

Check the other consumer of the same event, ProgressDeliveryWorker: it already stops on status_terminal?; a paused session is not terminal, so it keeps rendering — which is right, the approval request appears in ui_chat_log.

Not needed

  • A faster stuck-session watchdog. CleanStuckWorkflowsService drops sessions stuck in created/running after 30 minutes on every runtime already. If that turns out too slow for Slack, tune it then.
  • Reporting failure from the runtime. That is the headless runtime issue's requirement 4; this issue only makes sure that when the session does reach a terminal state, the adapter hears it.

Not in this issue

  • Slack's approve/deny UI — approvals issue.
  • Detecting that a headless run died — headless runtime issue.

Depends on / unblocks

  • Depends on: nothing.
  • Unblocks: Slack approvals; the headless runtime's failure path reaching Slack.