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
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_failedon its adapter, with no CI job involved. - A session that enters
tool_call_approval_requiredfires a newon_approval_requiredhook; 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_requiredposts nothing as a final answer and does not mark the delivery as done; the real answer is posted when the session finishes later. - Existing
@GitLabDuomention 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:
dropandstop→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.CallbackWorkercalls it forWorkflowFailedEventwith 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 ason_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.
CleanStuckWorkflowsServicedrops sessions stuck increated/runningafter 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.