Files
sure/app/javascript/controllers/chat_controller.js
Guillem Arias Fauste 169fd139b5 fix(chat): surface and recover from undelivered assistant responses (#2436)
* fix(chat): surface and recover from undelivered assistant responses

When the background worker that runs AssistantResponseJob is down or not
polling the high_priority queue, the eager "pending" assistant message had no
safeguard: chat hung on "Thinking…" forever with no error, timeout, or retry,
and the LLM provider was never even called.

Add three layers of resilience:

- Client watchdog (chat_controller.js): a pending "Thinking…" bubble that waits
  past a threshold (default 90s) with no response asks the server to fail it.
  Keyed on the pending marker — which disappears the instant a real response
  streams — so a slow-but-working response is never falsely timed out.

- Server failure capture (Chat#handle_undelivered_response! +
  MessagesController#report_timeout): clears the dead bubble, records a friendly
  "the assistant didn't respond" error with Retry, and writes a DebugLogEntry so
  support can see it in /settings/debug.

- Worker liveness signal (BackgroundJobHealth + warning banner): reads Sidekiq's
  process/queue state directly from Redis in the web process — so a down worker
  is detectable even though the worker is the thing that's broken — and warns
  self-hosted users when no worker polls high_priority or it's badly backed up.
  Fails open so a Sidekiq/Redis blip never blocks chat.

Tests: Chat model (3), MessagesController (2), BackgroundJobHealth (5).

* fix(chat): address review — server-side timeout guard + watchdog retry safety

- Chat#handle_undelivered_response!: gate the state change behind a row lock and
  a server-side minimum age (UNDELIVERED_RESPONSE_TIMEOUT = 60s). The browser
  watchdog is untrusted, so re-read the row under lock and only fail a bubble
  that is still pending AND has genuinely waited past the timeout — never racing
  a worker that is finishing a slow response. (Codex P2 + CodeRabbit)
- chat_controller.js: only mark a report URL as reported on response.ok (fetch
  resolves on HTTP 4xx/5xx, rejecting only on network errors), and guard
  concurrent duplicate POSTs with an in-flight set, so a failed report retries
  instead of stranding the bubble. (CodeRabbit)
- _worker_health_warning: drop the unused `chat:` local + its render arg. (CodeRabbit)
2026-06-25 05:44:44 +02:00

152 lines
4.7 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
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() {
const input = this.inputTarget;
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
const maxLines = 3; // 3 lines = 60px total
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
input.style.overflowY =
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
this.#updateSubmitState();
}
submitSampleQuestion(e) {
this.inputTarget.value = e.target.dataset.chatQuestionParam;
this.#updateSubmitState();
setTimeout(() => {
this.formTarget.requestSubmit();
}, 200);
}
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
handleInputKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (this.#hasContent()) {
this.formTarget.requestSubmit();
}
}
}
#hasContent() {
return this.inputTarget.value.trim().length > 0;
}
#updateSubmitState() {
if (!this.hasSubmitTarget) return;
this.submitTarget.disabled = !this.#hasContent();
}
#configureAutoScroll() {
this.messagesObserver = new MutationObserver((_mutations) => {
if (this.hasMessagesTarget) {
this.#scrollToBottom();
}
});
// Listen to entire sidebar for changes, always try to scroll to the bottom
this.messagesObserver.observe(this.element, {
childList: true,
subtree: true,
});
}
#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);
});
}
}