diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index f5e98ae41..71c2107f3 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -17,6 +17,17 @@ class MessagesController < ApplicationController end end + # Called by the chat watchdog when an assistant "Thinking…" bubble has waited + # too long for a response that never arrived. Idempotent and scoped to the + # current user's chat. + def report_timeout + message = @chat.messages.find(params[:id]) + @chat.handle_undelivered_response!(message) + head :ok + rescue ActiveRecord::RecordNotFound + head :not_found + end + private def set_chat @chat = Current.user.chats.find(params[:chat_id]) diff --git a/app/javascript/controllers/chat_controller.js b/app/javascript/controllers/chat_controller.js index 25020f35e..dfb750238 100644 --- a/app/javascript/controllers/chat_controller.js +++ b/app/javascript/controllers/chat_controller.js @@ -1,17 +1,31 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["messages", "form", "input", "submit"]; + static targets = ["messages", "form", "input", "submit", "pendingResponse"]; + static values = { + // How long a pending "Thinking…" bubble may wait before we assume the + // background worker never delivered a response. Generous so slow models or + // tool calls don't trip it. + responseTimeout: { type: Number, default: 90000 }, + // How often to re-check pending bubbles. + pollInterval: { type: Number, default: 5000 }, + }; connect() { + this.reportedUrls = new Set(); + this.inFlightUrls = new Set(); this.#configureAutoScroll(); this.#updateSubmitState(); + this.#startUndeliveredWatchdog(); } disconnect() { if (this.messagesObserver) { this.messagesObserver.disconnect(); } + if (this.watchdogTimer) { + clearInterval(this.watchdogTimer); + } } autoResize() { @@ -72,4 +86,66 @@ export default class extends Controller { #scrollToBottom = () => { this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; }; + + // Watchdog: a "Thinking…" bubble only resolves when the background worker + // streams a response over Turbo. If the worker is down — or the job dies + // before it can broadcast an error — the bubble would otherwise spin forever + // with no feedback. We detect a pending bubble that has waited past the + // threshold and ask the server to mark it failed, so the user gets an error + // message + Retry instead of a dead spinner. + // + // We key off the pending marker itself (it only exists while pending and + // disappears the instant a real response renders) rather than a status flag, + // so a response that starts streaming can never be falsely timed out. + #startUndeliveredWatchdog() { + this.#checkUndeliveredResponses(); + this.watchdogTimer = setInterval(() => { + this.#checkUndeliveredResponses(); + }, this.pollIntervalValue); + } + + #checkUndeliveredResponses() { + if (!this.hasPendingResponseTarget) return; + + const now = Date.now(); + + this.pendingResponseTargets.forEach((el) => { + const url = el.dataset.pendingResponseTimeoutUrl; + // Skip if already reported (succeeded) or a report is in flight. + if (!url || this.reportedUrls.has(url) || this.inFlightUrls.has(url)) return; + + const createdAt = Date.parse(el.dataset.pendingResponseCreatedAt); + if (Number.isNaN(createdAt)) return; + if (now - createdAt < this.responseTimeoutValue) return; + + this.#reportUndelivered(url); + }); + } + + #reportUndelivered(url) { + const token = document.querySelector('meta[name="csrf-token"]')?.content; + + this.inFlightUrls.add(url); + + fetch(url, { + method: "POST", + headers: { + "X-CSRF-Token": token || "", + Accept: "text/vnd.turbo-stream.html, text/html", + }, + credentials: "same-origin", + }) + .then((response) => { + // Only mark as reported on success. fetch resolves on HTTP 4xx/5xx + // (it rejects only on network errors), so without this check a failed + // POST would permanently suppress retries and strand the bubble. + if (response.ok) this.reportedUrls.add(url); + }) + .catch(() => { + // Best-effort. Leave the URL un-reported so the next tick can retry. + }) + .finally(() => { + this.inFlightUrls.delete(url); + }); + } } diff --git a/app/models/background_job_health.rb b/app/models/background_job_health.rb new file mode 100644 index 000000000..fe3b1bc48 --- /dev/null +++ b/app/models/background_job_health.rb @@ -0,0 +1,60 @@ +require "sidekiq/api" + +# Reads Sidekiq's runtime state (live worker processes + queue latency) directly +# from Redis in the *web* process, so a down or stuck worker can be detected even +# when the worker itself is the thing that's broken (a worker-side healthcheck +# can't report that it's dead). +# +# Fails open: if Sidekiq/Redis is unreachable we assume healthy rather than block +# or alarm the UI on an unrelated infrastructure blip. +class BackgroundJobHealth + # The queue chat (AssistantResponseJob), syncs and imports run on. If nothing + # is polling it, user-facing background work silently never runs. + CRITICAL_QUEUE = "high_priority".freeze + LATENCY_WARN_SECONDS = 60 + CACHE_KEY = "background_job_health".freeze + CACHE_TTL = 15.seconds + + class << self + def current + Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) { compute } + rescue => e + Rails.logger.warn("BackgroundJobHealth check failed: #{e.class}: #{e.message}") + fail_open + end + + def healthy? + current[:healthy] + end + + def snapshot + current + end + + def summary + h = current + "workers=#{h[:workers].inspect} polls_#{CRITICAL_QUEUE}=#{h[:polls_critical_queue].inspect} latency=#{h[:latency].inspect}s" + end + + private + def compute + processes = Sidekiq::ProcessSet.new + workers = processes.size + polled_queues = processes.flat_map { |p| Array(p["queues"]) }.uniq + polls_critical = polled_queues.include?(CRITICAL_QUEUE) + latency = Sidekiq::Queue.new(CRITICAL_QUEUE).latency + + { + healthy: workers.positive? && polls_critical && latency < LATENCY_WARN_SECONDS, + workers: workers, + polls_critical_queue: polls_critical, + latency: latency.round(1), + checked_at: Time.current + } + end + + def fail_open + { healthy: true, workers: nil, polls_critical_queue: nil, latency: nil, checked_at: Time.current, error: true } + end + end +end diff --git a/app/models/chat.rb b/app/models/chat.rb index f8935e427..6b76f4c95 100644 --- a/app/models/chat.rb +++ b/app/models/chat.rb @@ -132,8 +132,74 @@ class Chat < ApplicationRecord assistant.respond_to(message, assistant_message: assistant_message) end + # Minimum age before the server will treat a still-pending response as + # undelivered. The browser watchdog waits longer (default 90s) before it even + # asks, but the client clock is untrusted, so the server enforces its own floor. + UNDELIVERED_RESPONSE_TIMEOUT = 60.seconds + + # Handles the case where an assistant response was never delivered — the + # background worker never ran `AssistantResponseJob` (or it died before it + # could broadcast an error), leaving a `pending` "Thinking…" bubble forever. + # Mirrors `Assistant::Builtin`'s rescue: clears the dead bubble, records a + # friendly error + a debug log entry, and broadcasts the error/Retry UI. + # + # Driven by an untrusted client watchdog, so the state change is gated behind a + # row lock + a server-side age check: we re-read the row under lock and only act + # if it is *still* pending and has genuinely waited past the timeout, so we never + # race a worker that is finishing a legitimate (slow) response. + def handle_undelivered_response!(assistant_message) + return false unless assistant_message.is_a?(AssistantMessage) + + resolved = assistant_message.with_lock do + next false unless assistant_message.pending? + next false if assistant_message.created_at > UNDELIVERED_RESPONSE_TIMEOUT.ago + + if assistant_message.content.blank? + assistant_message.destroy! + else + # Demote partially-streamed turns to `failed` so history builders exclude them. + assistant_message.update_columns(status: "failed") + end + true + end + + return false unless resolved + + capture_undelivered_response!(assistant_message) + update!(error: undelivered_error_payload(assistant_message).to_json) + broadcast_append target: messages_target, partial: "chats/error", locals: { chat: self } + true + end + private + def undelivered_error_payload(assistant_message) + { + message: I18n.t("chat.errors.no_response"), + technical_message: "Assistant response was never delivered. The background worker did not process " \ + "AssistantResponseJob for message ##{assistant_message.id} (model #{assistant_message.ai_model}). " \ + "#{BackgroundJobHealth.summary}", + type: "DeliveryTimeout" + } + end + + def capture_undelivered_response!(assistant_message) + DebugLogEntry.capture( + category: "assistant", + level: "error", + message: "Assistant response not delivered — background worker likely down or not polling the high_priority queue", + source: "chat.delivery_timeout", + metadata: { + chat_id: id, + message_id: assistant_message.id, + ai_model: assistant_message.ai_model, + waited_seconds: (Time.current - assistant_message.created_at).round, + background_jobs: BackgroundJobHealth.snapshot + }, + family: user&.family + ) + end + def build_error_payload(error) technical_message = error_message_for(error) diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb index 6b00a3c2d..85768996c 100644 --- a/app/views/assistant_messages/_assistant_message.html.erb +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -2,7 +2,10 @@
<%= t("chats.thinking") %>