Class: SolidLoop::LlmCompletionJob
- Inherits:
-
ApplicationJob
- Object
- ActiveJob::Base
- ApplicationJob
- SolidLoop::LlmCompletionJob
- Defined in:
- app/jobs/solid_loop/llm_completion_job.rb
Constant Summary collapse
- READ_TIMEOUT =
10 minutes
600- EVENTS_SAVE_BEFORE_REQUEST =
two phase event save, good for slow models debugging
true- HTTP_STREAMING_UPDATE_MODEL_INTERVAL =
seconds - update model content every N seconds
2
Instance Method Summary collapse
Instance Method Details
#perform(loop_id) ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'app/jobs/solid_loop/llm_completion_job.rb', line 9 def perform(loop_id) execution_token = SecureRandom.uuid # Derive the lease from the agent's own read_timeout (+ margin) so it is # guaranteed to outlive the HTTP client — a healthy in-flight turn can then # never be reclaimed by the reaper. Resolved before the CAS so the same # atomic transition to `running` also stamps `lease_expires_at`. loop_for_lease = SolidLoop::Loop.find(loop_id) begin agent_for_lease = loop_for_lease.agent rescue NameError => e # A renamed/missing agent class: fail the still-`queued` loop # VISIBLY instead of raising forever on every retry. No token/lease is # held yet, so this is a plain queued→failed transition. Rails.logger.error "LlmCompletionJob: cannot resolve agent for loop #{loop_id}: #{e.class}: #{e.}" # from: :queued only — a missing agent fails BEFORE any CAS claim, so no # worker can ever hold this loop in `running`; fencing to :queued keeps # this consistent with every other transition and can't stomp a sibling. loop_for_lease.transition_status( from: :queued, to: :failed, error_message: "#{e.class}: #{e.}", execution_token: nil, lease_expires_at: nil ) return end return unless agent_for_lease # nil agent_class_name: nothing to run lease_duration = SolidLoop.config.llm_lease_duration(agent_for_lease) lease_expires_at = Time.current + lease_duration claimed = SolidLoop::Loop .where(id: loop_id, status: :queued) .update_all( status: :running, execution_token: execution_token, lease_expires_at: lease_expires_at, updated_at: Time.current ) return if claimed == 0 loop_record = nil # Reconciliation wrapper: wrap the ENTIRE post-claim job body — # the Context build, the second `agent` resolution, middleware config, and # the whole pipeline — so a non-graceful error (a NoMethodError from a # renamed agent, a bug in a middleware) fails the loop VISIBLY instead of # stranding it `running`. Hard kills still fall through to the reaper. The # inner ErrorHandling middleware still terminalizes its graceful allowlist # first; this only catches what escapes it. begin # Heartbeat the lease for the ENTIRE claimed interval. # The heartbeat wrapper opens IMMEDIATELY after the successful claim, so # it covers the CAS→`Context.new`→second-`agent`→config window too — making # "the entire claimed interval is heartbeated" literally true (previously # Context.new/agent/config ran BEFORE the heartbeat started, an unheartbeated # gap). It covers MCP init (AgentInitialization), the whole stream incl. # silent prefill (NetworkCalling), and finalization. `read_timeout` is an # inactivity timeout, not a total-duration bound, so the static claim-time # lease can expire on a HEALTHY worker; the heartbeat renews it at ~ttl/4 # keyed on loop_id + execution_token, and flips a lost-lease flag if the # generation rotated away. The heartbeat still stops in `run`'s ensure # BEFORE any canonical completion. SolidLoop::LeaseHeartbeat.run( loop_id: loop_id, execution_token: execution_token, lease_duration: lease_duration, # The agent's own legal turn bound sizes the renewer's leak ceiling # (`max_duration + lease_leak_grace`), so a legitimate turn — capped at # max_duration by the agent's contract — is never dropped, while a genuine # leak self-expires shortly after max_duration. max_duration: agent_for_lease.max_duration, logger: Rails.logger ) do |heartbeat| context = SolidLoop::Pipeline::Context.new(loop_id: loop_id, execution_token: execution_token) loop_record = context.loop agent = loop_record.agent context.heartbeat = heartbeat builder = SolidLoop::Pipeline::Builder.new(SolidLoop.llm_middlewares.middlewares) agent.configure_llm_middlewares(builder) pipeline = SolidLoop::Pipeline.new(builder.middlewares) pipeline.call(context) end rescue SolidLoop::LostLease => e # The heartbeat lost the lease mid-turn (generation rotated away or the # reaper reclaimed us). We DON'T own the loop, so we write nothing and do # NOT fail it — recovery belongs to whoever now owns the generation (the # resume, or the reaper's re-enqueue). Swallow so ActiveJob doesn't retry. Rails.logger.info "LlmCompletionJob: lease lost for loop #{loop_id}, yielding turn: #{e.}" rescue StandardError => e # If we owned the loop we reconciled it to `failed` (durable) — swallow so # ActiveJob does not retry endlessly (the CAS would no-op anyway). If we # did NOT own it (a concurrent pause/stop/reclaim already rotated the # token), re-raise so the genuinely-unhandled error stays visible. raise unless reconcile_failure!(loop_record, execution_token, e) end end |