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. Runs as one ActiveJob. Correctness lives here, in the database, not in the queue:
- Atomic claim (CAS on claim_token) — one worker owns the turn.
- Heartbeat — the owner touches heartbeat_at at step boundaries; 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 zombie that wakes up after being reaped affects zero rows.
- Max attempts — poison turns become turn.failed, not infinite retries.
Constant Summary collapse
- STATUSES =
%w[pending running waiting completed failed cancelled].freeze
- KINDS =
%w[message compaction].freeze
Class Method Summary collapse
-
.append!(session, user_message:, transcript_deferred: false, kind: "message") ⇒ Object
Appends the next turn to the session, sequence assigned race-safely.
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 to the session, sequence assigned race-safely. The INSERT runs in its own savepoint (requires_new): stage_turn! calls this inside a transaction, and on Postgres a failed INSERT otherwise aborts the whole transaction — the retry would raise PG::InFailedSqlTransaction instead of recovering.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# File 'app/models/xeno/turn.rb', line 27 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 on success, nil when another worker owns the turn (exit quietly) or it isn't claimable. Claimable: pending, or running with a stale heartbeat (dead owner). Poison turns are failed here instead of retrying forever.
attempts counts FAILURES, not claims: a stale-running reclaim is
crash evidence and counts here; a transient error counts in
release_for_retry!; an approval/question resume counts in resumes
and never against the poison ladder (H2).
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 |
# File 'app/models/xeno/turn.rb', line 55 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) .where(status: %w[pending running waiting]) 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
42 43 44 |
# File 'app/models/xeno/turn.rb', line 42 def enqueue! TurnJob.perform_later(id) end |
#fenced_update!(token, attributes) ⇒ Object
108 109 110 111 112 113 |
# File 'app/models/xeno/turn.rb', line 108 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.
104 105 106 |
# File 'app/models/xeno/turn.rb', line 104 def heartbeat!(token) fenced_update!(token, heartbeat_at: Time.current) end |
#record_step!(token) ⇒ Object
115 116 117 118 119 120 121 122 123 |
# File 'app/models/xeno/turn.rb', line 115 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.
94 95 96 97 98 99 100 |
# File 'app/models/xeno/turn.rb', line 94 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 |