Class: Xeno::Turn
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Xeno::Turn
- Defined in:
- app/models/xeno/turn.rb
Overview
One user message and all work until the agent responds, run as one ActiveJob. Correctness lives in these rows, not in the queue:
- Atomic claim (CAS on claim_token): one worker owns the turn.
- Heartbeat: a stale heartbeat lets the next attempt reclaim; checkpoint replay makes takeover safe.
- Fencing token: every write is conditioned on claim_token = mine, so a reaped zombie affects zero rows.
- Max attempts: poison turns fail instead of retrying forever.
Class Method Summary collapse
-
.append!(session, user_message:, transcript_deferred: false, kind: "message") ⇒ Object
Appends the next turn with a race-safely assigned sequence.
Instance Method Summary collapse
-
#claim! ⇒ Object
The atomic claim.
- #enqueue! ⇒ Object
- #fenced_update!(token, attributes) ⇒ Object
-
#heartbeat!(token) ⇒ Object
Fenced write: touches the heartbeat iff we still own the claim.
- #record_step!(token) ⇒ Object
-
#release_for_retry!(token, error) ⇒ Object
Transient failure: record the error, count the failure against the poison ladder, and hand the claim back (status pending) so the queue retry can claim immediately.
Class Method Details
.append!(session, user_message:, transcript_deferred: false, kind: "message") ⇒ Object
Appends the next turn with a race-safely assigned sequence. The INSERT runs in its own savepoint because stage_turn! calls this inside a transaction, and on Postgres a failed INSERT would otherwise abort the whole transaction.
25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'app/models/xeno/turn.rb', line 25 def self.append!(session, user_message:, transcript_deferred: false, kind: "message") attempts = 0 begin sequence = (where(session_id: session.id).maximum(:sequence) || 0) + 1 transaction(requires_new: true) do create!(session: session, sequence: sequence, user_message: , transcript_deferred: transcript_deferred, kind: kind) end rescue ActiveRecord::RecordNotUnique attempts += 1 retry if attempts < 5 raise end end |
Instance Method Details
#claim! ⇒ Object
The atomic claim. Returns the fencing token, or nil when another worker owns the turn or it isn't claimable. Claimable: pending, or running with a stale heartbeat. Poison turns fail here instead of retrying forever.
attempts counts failures, not claims: a stale-running reclaim is crash evidence and counts;
a transient error counts in release_for_retry!; an approval or question resume counts in
resumes, never against the poison ladder.
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 |
# File 'app/models/xeno/turn.rb', line 51 def claim! current = self.class.find(id) if current.attempts >= Xeno.config.max_turn_attempts poisoned = self.class.where(id: id, claim_token: current.claim_token) .where.not(status: %w[completed failed cancelled]) .update_all(status: "failed", error: { "message" => "max attempts (#{current.attempts}) exhausted" }) if poisoned == 1 # A poisoned TURN leaves the SESSION running (idle, can take the next message); settle any # dangling tool calls so it stays usable. session.settle_unanswered_tool_calls!(self, reason: "turn failed (max attempts exhausted)") session.emit("turn.failed", { turn_id: id, error: "max attempts exhausted" }) end return nil end stale_before = Xeno.config.turn_stale_after.ago earlier_active = self.class .where(session_id: session_id) .where("sequence < ?", sequence) .active claimed = self.class .where(id: id, claim_token: current.claim_token) .where("status = 'pending' OR (status = 'running' AND (heartbeat_at IS NULL OR heartbeat_at < ?))", stale_before) .where.not(earlier_active.arel.exists) # turns run in session order .update_all([ "status = 'running', claim_token = ?, " \ "attempts = attempts + (CASE WHEN status = 'running' THEN 1 ELSE 0 END), " \ "heartbeat_at = ?", current.claim_token + 1, Time.current ]) claimed == 1 ? current.claim_token + 1 : nil end |
#enqueue! ⇒ Object
40 41 42 |
# File 'app/models/xeno/turn.rb', line 40 def enqueue! TurnJob.perform_later(id) end |
#fenced_update!(token, attributes) ⇒ Object
103 104 105 106 107 108 |
# File 'app/models/xeno/turn.rb', line 103 def fenced_update!(token, attributes) rows = self.class.where(id: id, claim_token: token).update_all(attributes) raise Xeno::Fenced, "turn #{id}: claim #{token} was fenced out" unless rows == 1 true end |
#heartbeat!(token) ⇒ Object
Fenced write: touches the heartbeat iff we still own the claim. Raises Xeno::Fenced when a zombie discovers it was reaped.
99 100 101 |
# File 'app/models/xeno/turn.rb', line 99 def heartbeat!(token) fenced_update!(token, heartbeat_at: Time.current) end |
#record_step!(token) ⇒ Object
110 111 112 113 114 115 116 117 118 |
# File 'app/models/xeno/turn.rb', line 110 def record_step!(token) current = self.class.find(id) if current.steps_count >= Xeno.config.max_steps raise Xeno::MaxStepsExceeded, "turn #{id}: exceeded #{Xeno.config.max_steps} steps" end fenced_update!(token, steps_count: current.steps_count + 1, heartbeat_at: Time.current) current.steps_count + 1 end |
#release_for_retry!(token, error) ⇒ Object
Transient failure: record the error, count the failure against the poison ladder, and hand the claim back (status pending) so the queue retry can claim immediately.
89 90 91 92 93 94 95 |
# File 'app/models/xeno/turn.rb', line 89 def release_for_retry!(token, error) current = self.class.find(id) fenced_update!(token, status: "pending", attempts: current.attempts + 1, error: { "class" => error.class.name, "message" => error. }) end |