Class: SolidLoop::ToolExecutionJob
- Inherits:
-
ApplicationJob
- Object
- ActiveJob::Base
- ApplicationJob
- SolidLoop::ToolExecutionJob
- Defined in:
- app/jobs/solid_loop/tool_execution_job.rb
Instance Method Summary collapse
-
#perform(tool_call_id, execution_token = nil, reaper_lease_token = nil) ⇒ Object
reaper_lease_token(optional): when the reaper's case 2 re-enqueues a stalled tool call it FIRST CAS-claims the per-tool lease under the loop lock and hands its token here, so two racing reapers resolve to exactly one re-enqueue (the loser's CAS gets 0 rows).
Instance Method Details
#perform(tool_call_id, execution_token = nil, reaper_lease_token = nil) ⇒ Object
reaper_lease_token (optional): when the reaper's case 2 re-enqueues a
stalled tool call it FIRST CAS-claims the per-tool lease under the loop
lock and hands its token here, so two racing reapers resolve to exactly one
re-enqueue (the loser's CAS gets 0 rows). A normal dispatch omits it and the
job self-claims a fresh lease below.
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 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'app/jobs/solid_loop/tool_execution_job.rb', line 10 def perform(tool_call_id, execution_token = nil, reaper_lease_token = nil) # A tool job must carry the generation token it was dispatched with. # A job without one (legacy/host-enqueued) is not part of any current # generation: fail closed instead of adopting the loop's token. return if execution_token.nil? # Pre-fetch basic objects to determine the agent class. The token fence # here is a plain generation check (no lease acquired yet); a stale job # no-ops silently — there is nothing to reconcile. tool_call = SolidLoop::ToolCall.find(tool_call_id) = tool_call. loop_model = .loop loop_model.reload return unless loop_model.running? && loop_model.execution_token == execution_token # Reconciliation wrapper (findings 4/5/9): begin reconciliation immediately # after loading the loop/tool IDs and INCLUDE everything that used to sit # OUTSIDE the begin — agent resolution (a renamed/missing agent method), # sequential-mode evaluation, the lease claim aftermath, builder creation, # and `configure_tool_middlewares`. A non-graceful error at ANY of these # steps fails the loop VISIBLY under the fence instead of stranding it # `running` / reaping-and-retrying it forever. The context is built up # front (with lease_token nil until claimed) so a PRE-LEASE error still has # a generation-fenced failure path: `reconcile_failure!` transitions the # loop to failed under the execution_token even when no tool lease was held. # Hard kills (SIGKILL / an Exception outside StandardError) still fall # through to the reaper's case 2, which re-invokes with the SAME stable key. context = SolidLoop::Pipeline::ToolContext.new(tool_call_id: tool_call_id) context.tool_call = tool_call context. = context.loop = loop_model context.execution_token = execution_token context.lease_token = nil begin agent = loop_model.agent # Token fencing + the executed_at checkpoint keep every mode safe against # stale/duplicate jobs. The first-unresolved boundary is the *ordering* # invariant: it only applies when the agent opts into sequential # execution (parallel mode deliberately runs the turn's jobs in any # order). The check runs under the parent assistant message lock — the # same lock ResponseCreation resolves calls under — so it cannot race a # result landing. if agent.sequential_tool_calls? first_unresolved_id = nil .with_lock do first_unresolved_id = loop_model.unresolved_tool_calls.first&.id end unless first_unresolved_id == tool_call.id # A stray/duplicate/out-of-order job must not stall the turn: # no-op, but re-dispatch the actual head so ordering self-heals. Route # through enqueue! so a silently-aborted re-dispatch surfaces (job # retries) instead of leaving the head-of-line call unrun. SolidLoop.enqueue!(self.class, first_unresolved_id, execution_token) if first_unresolved_id return end end # The per-tool_call lease (the SECOND fence token). # `execution_token` above is the generation fence (shared by every tool of # the turn); the `lease_token` here is per-tool, so the reaper can reclaim # ONE dead parallel tool without fencing its healthy siblings. The lease is # derived to strictly outlive the tool client timeout, so a healthy # long-running tool can never lose its lease to a reaper. lease_token = if tool_call.executed_at? # Already checkpointed but re-dispatched: the tool BODY must not run # again (executed_at is the terminal exactly-once guard). This path # exists so a call whose CONTINUATION (canonical response + successor) # was dropped — a crash after executed_at, reclaimed by reaper case 2 — # is completed: ToolExecution restores the checkpoint and ResponseCreation # commits the continuation (its own idempotent `response_message` check # makes a duplicate delivery harmless). No lease is needed or held. nil elsif reaper_lease_token # Reaper path: the lease was already CAS-claimed under the loop lock and # handed here. ADOPT it only if we STILL hold it (a worker that took over # after the reaper enqueued would have rotated it). No-op otherwise. return unless tool_call.lease_held_by?(reaper_lease_token) reaper_lease_token else # Normal dispatch: self-claim UNDER THE LOOP LOCK. # # SECONDARY race (clean-resume violation): the generation was validated # by the `reload` at the top of `perform`, but claiming the ToolCall # lease independently leaves a window in which a pause/resume can rotate # G1→G2 and revoke old leases BETWEEN the two. A stale worker would then # install a FRESH lease on the ToolCall without re-checking the loop, # and the legitimate G2 job could not claim until that lease expired (a # full tool-TTL delay before reaper case 2 repairs it). Fix: re-validate # `running + execution_token` under the loop row lock and claim the lease # in the SAME locked scope (loop → tool_call order). A rotation that # commits before we take the lock is now seen here → no-op; one that # arrives after blocks on the loop lock and rotates cleanly afterward. minted = claim_lease_under_loop_lock(loop_model, tool_call, execution_token, agent) if minted.nil? # Observability: the CAS minted nothing — either the # generation rotated away under us (stale worker no-op) or a DIFFERENT # live per-tool lease still holds this call (e.g. a host-side # paused→running resume that re-enqueued this job WITHOUT revoking the # gated tool_call's unexecuted lease — see SolidLoop::Base#resume!, # which NULLs lease_token/leased_until before re-enqueue). Without this # log the strand is invisible: no error, no event, the loop just waits # out the lease TTL / reaper. Reload so the logged lease reflects the # holder we lost to, not our stale in-memory copy — guarded so a # concurrent teardown of the row can't turn this pure-observability # path into a raise (a log must never change control flow). begin tool_call.reload rescue ActiveRecord::RecordNotFound nil end Rails.logger.warn( "ToolExecutionJob: tool lease claim lost — another live lease holds " \ "this tool_call; job no-op (loop may strand until lease TTL/reaper). " \ "tool_call_id=#{tool_call.id} loop_id=#{loop_model.id} " \ "lease_token=#{tool_call.lease_token.inspect} " \ "leased_until=#{tool_call.leased_until.inspect} " \ "executed_at=#{tool_call.executed_at.inspect}" ) return end minted end tool_call.reload context.tool_call = tool_call context.lease_token = lease_token builder = SolidLoop::Pipeline::Builder.new(SolidLoop.tool_middlewares.middlewares) agent.configure_tool_middlewares(builder) pipeline = SolidLoop::Pipeline.new(builder.middlewares) pipeline.call(context) rescue StandardError => e # If this generation (+ lease, when one was held) still owns the call under # the row lock we reconciled it to `failed` (durable) — swallow so ActiveJob # does not retry endlessly. If ownership was already rotated away # (pause/stop/reclaim), re-raise so a genuinely-unhandled error stays # visible. A pre-lease error takes the no-lease, generation-fenced path. raise unless reconcile_failure!(loop_model, tool_call, context, e) end end |