mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
* 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)
152 lines
4.7 KiB
JavaScript
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);
|
|
});
|
|
}
|
|
}
|