class RailsAiAdmin::Assistant::Coordinator
Persistent per-chat inbox (plan §6.8, SPIKE-03). Web requests and Surface confirmations only admit a turn by key; workers claim the lowest active turn under a lease with a fencing generation and run RubyLLM outside any transaction. The human message is persisted once per turn, so a retried worker continues the same conversation instead of repeating it. Provider calls can still repeat (and bill) after a crash; business writes never happen here, only through a human-confirmed Surface.
Constants
- ADMISSION_KEY
- CLAIM_KEY
- Claim
- DRAFT_INTERVAL
-
Streamed text is saved at most this often, and at most this long.
- DRAFT_LIMIT
- Fenced
- LEASE
- MAX_ATTEMPTS
- MAX_CONTENT
- MAX_STEPS
- RETRYABLE
-
Provider-side failures that a later attempt may recover from.
- StepLimit
- UNANSWERED_NOTE
-
Recorded before a new human message when the previous request ended without an answer (RAA-030), so a provider never reads two unanswered user messages as one request. It is shown in the transcript too.
Public Class Methods
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 71 def self.admit!(chat, kind:, content:, admission_key:) raise ArgumentError, "Unknown turn kind" unless Turn::KINDS.include?(kind) raise AccessDenied, "INVALID_QUERY" unless admission_key.is_a?(String) && admission_key.match?(ADMISSION_KEY) text = content.is_a?(String) ? content.strip : "" raise AccessDenied, "INVALID_QUERY" unless text.valid_encoding? && text.length.between?(1, MAX_CONTENT) turn = Turn.transaction(requires_new: true) do locked = Chat.lock.find(chat.id) raise AccessDenied, "CHAT_CLOSED" unless locked.status == "active" existing = Turn.find_by(chat_id: locked.id, admission_key: admission_key) next existing if existing && existing.admission_key == admission_key raise AccessDenied, "RATE_LIMITED" if kind == "message" && over_daily_limit?(locked) sequence = Turn.where(chat_id: locked.id).maximum(:sequence).to_i + 1 created = Turn.create!(id: SecureRandom.uuid, chat_id: locked.id, sequence: sequence, kind: kind, admission_key: admission_key, content: text, status: "queued") locked.touch created end enqueue_after_commit(chat.ref) turn end
Admits one turn per (chat, key); a repeated key returns the first turn. Safe inside a caller transaction (Surface confirmation) and outside one.
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 102 def self.enqueue_after_commit(ref) ::ActiveRecord.after_all_transactions_commit { AssistantTurnJob.perform_later(ref) } end
Workers are an accelerator; a recurring scan recovers lost jobs.
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 94 def self.over_daily_limit?(chat) limit = RailsAiAdmin.configuration.assistant_daily_message_limit return false if limit.nil? chats = Chat.where(actor_ref: chat.actor_ref, tenant_ref: chat.tenant_ref).select(:id) Turn.where(chat_id: chats, kind: "message").where(Turn.arel_table[:created_at].gt(24.hours.ago)).count >= limit end
The smallest spend control (roadmap “budgets”): human messages per actor and tenant in a rolling 24 hours. Continuations are not counted.
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 46 def self.protect_message(message) claim = ActiveSupport::IsolatedExecutionState[CLAIM_KEY] return yield unless claim raise Fenced unless message.chat_id == claim.chat_id with_claim_lock(claim) { yield } end
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 37 def self.with_claim(claim) state = ActiveSupport::IsolatedExecutionState previous = state[CLAIM_KEY] state[CLAIM_KEY] = claim yield ensure state[CLAIM_KEY] = previous end
Only the worker’s admin messages are guarded. Product chat models and actual provider usage accounting retain their own persistence rules.
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 56 def self.with_claim_lock(claim) Turn.transaction do chat = Chat.lock.find(claim.chat_id) turn = Turn.lock.find(claim.turn_id) unless chat.status == "active" && turn.chat_id == chat.id && turn.status == "running" && turn.lease_owner == claim.owner && turn.generation == claim.generation && turn.lease_expires_at && turn.lease_expires_at > Time.current raise Fenced end yield turn end end
Lock in the same order as admission/claiming. A message and the SDK’s tool-call/result links commit under this lock, never across a provider request. Checking after chat.step would be too late to fence its saves.
Public Instance Methods
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 129 def claim_next(ref) Turn.transaction(requires_new: true) do chat = Chat.lock.find_by(ref: ref) next unless chat && chat.ref == ref now = Time.current loop do turn = Turn.where(chat_id: chat.id, status: Turn::ACTIVE).order(:sequence).lock.first break unless turn break if turn.status == "running" && turn.lease_expires_at && turn.lease_expires_at > now break if turn.status == "queued" && turn.next_attempt_at && turn.next_attempt_at > now if chat.status != "active" turn.update!(status: "cancelled", error_code: "CHAT_CLOSED", completed_at: now) next end if turn.attempts >= MAX_ATTEMPTS turn.update!(status: "failed", error_code: turn.error_code || "ATTEMPTS_EXHAUSTED", completed_at: now) next end # A cancel request belongs to the turn that was running then. chat.update_columns(cancelled: false) if chat.cancelled turn.update!(status: "running", lease_owner: SecureRandom.uuid, generation: turn.generation + 1, attempts: turn.attempts + 1, lease_expires_at: now + LEASE, next_attempt_at: nil) break Claim.new(turn.id, chat.id, turn.lease_owner, turn.generation) end end end
Claims the lowest active turn only; later turns never overtake it.
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 156 def execute(claim) chat = Chat.find(claim.chat_id) turn = Turn.find(claim.turn_id) context = resolve_context(chat) return finish(claim, "failed", "AUTHORIZATION_REVOKED") unless context session = Session.new(context: context, chat: chat, fence: -> { renew!(claim) }) return finish(claim, "failed", "NOT_ALLOWED") if session.resources.empty? llm = Assistant.llm_context(context) return finish(claim, "failed", "AI_UNAVAILABLE") unless llm self.class.with_claim(claim) do Runtime.bind(chat, llm) admit_message!(claim, chat, turn) Runtime.configure(chat, session, llm) steps = 0 ::RubyLLM.with_owner(chat) do loop do renew!(claim) break if chat.complete? raise ConfigurationError, "Assistant tools must not wait for approval" if chat.waiting? raise StepLimit if (steps += 1) > MAX_STEPS stream = DraftStream.new(self, claim) begin if RailsAiAdmin.configuration.assistant_streaming chat.step { |chunk| stream << chunk.content } else chat.step end ensure stream.clear end end end end finish(claim, "completed") rescue Fenced nil rescue StepLimit finish(claim, "failed", "STEP_LIMIT") rescue ::RubyLLM::CancelledError finish(claim, "cancelled", "CANCELLED") rescue ::RubyLLM::ConfigurationError, ::RubyLLM::ModelNotFoundError, ::RubyLLM::UnauthorizedError, ::RubyLLM::ForbiddenError, ::RubyLLM::PaymentRequiredError finish(claim, "failed", "AI_UNAVAILABLE") rescue ::RubyLLM::BadRequestError, ::RubyLLM::ContextLengthExceededError, ::RubyLLM::ContentFilterError finish(claim, "failed", "AI_REJECTED") rescue *RETRYABLE retry_later(claim, "PROVIDER_UNAVAILABLE") rescue StandardError => error Rails.error.report(error, handled: true, severity: :error, context: { turn_id: claim.turn_id }, source: "rails_ai_admin") retry_later(claim, "ASSISTANT_FAILED") end
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 119 def run(ref, limit: 10) raise ConfigurationError, "Assistant turns cannot run inside a transaction" if Turn.connection.transaction_open? limit.times do claim = claim_next(ref) break unless claim execute(claim) end end
Runs due turns of one chat in sequence until none is claimable.
Source
# File lib/rails_ai_admin/assistant/coordinator.rb, line 107 def scan_pending!(limit: 100, inline: false) raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit) now = Time.current queued = Turn.where(status: "queued").where("next_attempt_at IS NULL OR next_attempt_at <= ?", now) stale = Turn.where(status: "running").where("lease_expires_at IS NULL OR lease_expires_at <= ?", now) chat_ids = queued.or(stale).distinct.limit(limit).pluck(:chat_id) Chat.where(id: chat_ids).pluck(:ref).count do |ref| inline ? (run(ref); true) : !!AssistantTurnJob.perform_later(ref) end end
Enqueues chats with due queued turns or expired leases.