xeno 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -2
  3. data/README.md +3 -1
  4. data/Rakefile +2 -0
  5. data/app/controllers/xeno/api_controller.rb +15 -18
  6. data/app/controllers/xeno/application_controller.rb +2 -0
  7. data/app/controllers/xeno/dev_controller.rb +5 -4
  8. data/app/controllers/xeno/dev_ui_controller.rb +5 -4
  9. data/app/controllers/xeno/health_controller.rb +3 -1
  10. data/app/controllers/xeno/sessions_controller.rb +22 -24
  11. data/app/controllers/xeno/slack_controller.rb +9 -10
  12. data/app/controllers/xeno/streams_controller.rb +18 -22
  13. data/app/helpers/xeno/application_helper.rb +2 -0
  14. data/app/jobs/xeno/application_job.rb +2 -0
  15. data/app/jobs/xeno/reaper_job.rb +5 -4
  16. data/app/jobs/xeno/schedule_job.rb +10 -13
  17. data/app/jobs/xeno/slack_event_job.rb +7 -7
  18. data/app/jobs/xeno/turn_job.rb +5 -4
  19. data/app/mailers/xeno/application_mailer.rb +2 -0
  20. data/app/models/xeno/action.rb +7 -12
  21. data/app/models/xeno/application_record.rb +2 -0
  22. data/app/models/xeno/chat.rb +9 -11
  23. data/app/models/xeno/dedup.rb +7 -7
  24. data/app/models/xeno/event.rb +16 -20
  25. data/app/models/xeno/message.rb +2 -0
  26. data/app/models/xeno/pending_message.rb +4 -2
  27. data/app/models/xeno/session.rb +52 -67
  28. data/app/models/xeno/turn.rb +29 -34
  29. data/config/routes.rb +2 -0
  30. data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
  31. data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
  32. data/docs/configuration.md +56 -0
  33. data/docs/runtime.md +4 -4
  34. data/exe/xeno +1 -1
  35. data/lib/generators/xeno/install/install_generator.rb +3 -2
  36. data/lib/generators/xeno/install/templates/agent.rb +1 -2
  37. data/lib/generators/xeno/install/templates/initializer.rb +6 -9
  38. data/lib/generators/xeno/tool/tool_generator.rb +4 -2
  39. data/lib/tasks/xeno_tasks.rake +2 -0
  40. data/lib/xeno/agent_config.rb +9 -8
  41. data/lib/xeno/agent_definition.rb +25 -28
  42. data/lib/xeno/approval_context.rb +2 -0
  43. data/lib/xeno/arguments.rb +8 -9
  44. data/lib/xeno/ask_question.rb +5 -5
  45. data/lib/xeno/channels/slack.rb +25 -29
  46. data/lib/xeno/channels.rb +10 -9
  47. data/lib/xeno/compaction.rb +20 -33
  48. data/lib/xeno/configuration.rb +35 -44
  49. data/lib/xeno/engine.rb +8 -7
  50. data/lib/xeno/errors.rb +11 -10
  51. data/lib/xeno/hooks.rb +7 -10
  52. data/lib/xeno/info.rb +3 -2
  53. data/lib/xeno/inputs.rb +26 -19
  54. data/lib/xeno/reaper.rb +13 -17
  55. data/lib/xeno/schedules.rb +8 -9
  56. data/lib/xeno/session_state.rb +8 -9
  57. data/lib/xeno/standalone/local_secret.rb +7 -6
  58. data/lib/xeno/standalone/model_refresh.rb +6 -6
  59. data/lib/xeno/standalone/puma.rb +9 -9
  60. data/lib/xeno/standalone.rb +22 -21
  61. data/lib/xeno/tool.rb +28 -25
  62. data/lib/xeno/turn_runner.rb +74 -96
  63. data/lib/xeno/version.rb +3 -1
  64. data/lib/xeno.rb +16 -14
  65. metadata +4 -9
  66. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
  67. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
  68. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
  69. data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
  70. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
  71. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
@@ -1,12 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The append-only session stream — also the audit log. `index` is the
3
- # per-session cursor; consumers dedupe
4
- # re-emitted events by (session, index).
4
+ # The append-only session stream — also the audit log. `index` is the per-session cursor;
5
+ # consumers dedupe re-emitted events by (session, index).
5
6
  class Event < ApplicationRecord
6
- belongs_to :session, class_name: "Xeno::Session"
7
-
8
- # The fixed vocabulary (docs/runtime.md § Events). Hooks may subscribe
9
- # to any of these or "*".
7
+ # The fixed vocabulary (docs/runtime.md § Events). Hooks may subscribe to any of these or "*".
10
8
  TYPES = %w[
11
9
  session.started turn.started message.received
12
10
  step.started step.completed step.failed
@@ -18,16 +16,16 @@ module Xeno
18
16
  session.waiting session.completed session.failed
19
17
  ].freeze
20
18
 
21
- # Hooks fire only after the row is durably committed (and therefore
22
- # visible to other processes) — never inside the emitting transaction.
19
+ belongs_to :session, class_name: "Xeno::Session"
20
+
21
+ # Hooks fire only after the row is durably committed (and therefore visible to other processes)
22
+ # — never inside the emitting transaction.
23
23
  after_create_commit { Hooks.dispatch(self) }
24
24
 
25
- # Appends with a race-safe per-session index: concurrent writers collide
26
- # on the unique index and retry with the next slot. The INSERT runs in
27
- # its own savepoint (requires_new): emit is routinely called inside
28
- # enclosing transactions, and on Postgres a failed INSERT otherwise
29
- # aborts the whole transaction — the retry would raise
30
- # PG::InFailedSqlTransaction instead of recovering.
25
+ # Appends with a race-safe per-session index: concurrent writers collide on the unique index and
26
+ # retry with the next slot. The INSERT runs in its own savepoint because emit is routinely
27
+ # called inside enclosing transactions, and on Postgres a failed INSERT would otherwise abort
28
+ # the whole transaction.
31
29
  def self.append!(session, event_type, data = {})
32
30
  attempts = 0
33
31
  begin
@@ -42,11 +40,9 @@ module Xeno
42
40
  )
43
41
  end
44
42
  rescue ActiveRecord::RecordNotUnique
45
- # Every collision means a competing append SUCCEEDED, so retries are
46
- # bounded by actual contention. SQLite's file lock serializes writers
47
- # (collisions are rare); Postgres runs them truly concurrently, so a
48
- # burst can cost more than a handful of rounds — back off with jitter
49
- # instead of giving up early.
43
+ # Every collision means a competing append succeeded, so retries are bounded by actual
44
+ # contention. SQLite's file lock serializes writers; Postgres runs them concurrently, so a
45
+ # burst can cost several rounds back off with jitter instead of giving up early.
50
46
  attempts += 1
51
47
  raise if attempts >= 50
52
48
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class Message < ApplicationRecord
3
5
  acts_as_message chat_class: "Xeno::Chat"
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Messages that arrived while a turn was running. Drained into the next
3
- # turn when the session parks or the turn completes.
4
+ # Messages that arrived while a turn was running. Drained into the next turn when the session
5
+ # parks or the turn completes.
4
6
  class PendingMessage < ApplicationRecord
5
7
  belongs_to :session, class_name: "Xeno::Session"
6
8
  end
@@ -1,22 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The durable conversation: lives for days or weeks, owns the transcript
3
- # chat, the turn ledger, and the append-only event stream. Identified by
4
- # id (inspect/stream handle) and at most one continuation_token (the
5
- # channel-owned resume handle, unique among active sessions).
4
+ # = Sessions
5
+ #
6
+ # A session is one durable conversation with the agent. It can live for days or weeks and
7
+ # survive deploys, crashes, and restarts, because everything it is lives in rows: the transcript
8
+ # chat, the turn ledger, and the append-only event stream.
9
+ #
10
+ # A session is identified two ways. The id is the stable handle for inspection and streaming.
11
+ # The continuation_token is the resume handle a channel owns — this Slack thread, this HTTP
12
+ # client — unique among active sessions and released when the session ends, so the same token
13
+ # can start fresh.
6
14
  class Session < ApplicationRecord
7
- STATUSES = %w[running waiting completed failed].freeze
15
+ enum :status, %w[running waiting completed failed].index_with(&:itself), validate: true
8
16
 
9
17
  belongs_to :chat, class_name: "Xeno::Chat"
10
18
  has_many :turns, class_name: "Xeno::Turn", dependent: :destroy
11
19
  has_many :events, class_name: "Xeno::Event", dependent: :destroy
12
20
  has_many :pending_messages, class_name: "Xeno::PendingMessage", dependent: :destroy
13
21
 
14
- validates :status, inclusion: { in: STATUSES }
15
-
16
- # Opens a session for a first user message: builds the transcript chat
17
- # per the resolved agent definition, stamps channel + principal, and
18
- # stages turn 1. The caller enqueues the returned turn's job after the
19
- # transaction commits (Session.start! does both).
22
+ # Opens a session for a first user message: builds the transcript chat per the resolved agent
23
+ # definition, stamps channel + principal, and stages turn 1. The caller enqueues the returned
24
+ # turn's job after the transaction commits (Session.start! does both).
20
25
  def self.open!(message:, channel: nil, principal: nil, continuation_token: nil, definition: Xeno.definition)
21
26
  transaction do
22
27
  chat = Chat.new
@@ -46,27 +51,22 @@ module Xeno
46
51
  session
47
52
  end
48
53
 
49
- # Stages the next turn for one or more messages: persists each user
50
- # message into the transcript, refreshes instructions (each turn picks up
51
- # edited instructions without a restart), and
52
- # appends the turn row. Runs in the caller's process so TurnJob replays
53
- # never double-add the user message.
54
+ # Stages the next turn for one or more messages: persists each user message, refreshes
55
+ # instructions, and appends the turn row. Runs in the caller's process so TurnJob replays never
56
+ # double-add the user message.
54
57
  #
55
- # defer_transcript: the turn row carries the messages but nothing is
56
- # written into the transcript yet the runner stages it when the turn
57
- # is claimed. Required whenever an EARLIER turn may still be mid-flight
58
- # (drained turns): appending user rows behind a parked turn's unanswered
59
- # tool calls would wedge every later generate (providers demand tool
60
- # results immediately after the assistant's tool_calls message).
58
+ # defer_transcript: the turn row carries the messages and the runner writes them at claim time.
59
+ # Required whenever an earlier turn may still be mid-flight appending user rows behind
60
+ # unanswered tool calls would wedge every later generate.
61
61
  def stage_turn!(messages, definition: Xeno.definition, emit_received: true, defer_transcript: false)
62
62
  contents = Array(messages)
63
63
  transaction do
64
64
  unless defer_transcript
65
- # Finding K: with_instructions triggers the first to_llm build; a
66
- # fresh chat record must get its runtime options back first.
65
+ # with_instructions triggers the first to_llm build, so a fresh chat record must get its
66
+ # runtime options back first. nil clears: an agent whose instructions were removed sheds
67
+ # the stale system row.
67
68
  chat.apply_runtime_options!(definition.config.model_options)
68
- resolved = definition.instructions_for(session: self)
69
- chat.with_instructions(resolved) if resolved
69
+ chat.with_instructions(definition.instructions_for(session: self))
70
70
  contents.each { |content| chat.add_message(role: :user, content: content) }
71
71
  end
72
72
  turn = Turn.append!(self, user_message: { contents: contents }, transcript_deferred: defer_transcript)
@@ -78,19 +78,16 @@ module Xeno
78
78
  end
79
79
  end
80
80
 
81
- # The channel entry point for follow-up messages. While a turn is active
82
- # (one active turn per session) the message queues in pending_messages
83
- # and is folded into the next turn; a PARKED session drains immediately
84
- # (the message becomes a visible staged turn instead of sitting invisible
85
- # until someone resolves the input — mvp-design's delivery semantics).
86
- # An idle session stages and enqueues a turn right away.
81
+ # The channel entry point for follow-up messages. While a turn is active the message queues in
82
+ # pending_messages and folds into the next turn; a parked session drains immediately, so the
83
+ # message becomes a visible staged turn. An idle session stages and enqueues right away.
87
84
  def receive_message!(content)
88
- if turns.where(status: %w[pending running waiting]).exists?
85
+ if turns.active.exists?
89
86
  transaction do
90
87
  pending_messages.create!(payload: { "content" => content })
91
88
  emit("message.received", { content: content, queued: true })
92
89
  end
93
- drain_pending_messages! if reload.status == "waiting"
90
+ drain_pending_messages! if reload.waiting?
94
91
  nil
95
92
  else
96
93
  turn = stage_turn!(content)
@@ -99,13 +96,10 @@ module Xeno
99
96
  end
100
97
  end
101
98
 
102
- # Folds every queued message into ONE next turn. Called when a turn
103
- # parks or reaches a terminal status (and on messages to a parked
104
- # session). Always defers the transcript: the drained turn may sit
105
- # behind a parked turn whose tool calls are still unanswered, so its
106
- # user rows are written only when the runner claims it — turn order and
107
- # transcript order can never diverge. Row locks make a racing drain
108
- # (park vs. incoming message) fold each message exactly once.
99
+ # Folds every queued message into one next turn, called when a turn parks or ends and on
100
+ # messages to a parked session. Always defers the transcript: the drained turn may sit behind
101
+ # unanswered tool calls, so its user rows are written only at claim — turn order and transcript
102
+ # order can never diverge. Row locks make a racing drain fold each message exactly once.
109
103
  def drain_pending_messages!(definition: Xeno.definition)
110
104
  turn = nil
111
105
  transaction do
@@ -119,19 +113,15 @@ module Xeno
119
113
  turn
120
114
  end
121
115
 
122
- # Steering (opt-in per message): stop what the agent is doing and make
123
- # THIS message the next turn. The active turn dies safely a parked or
124
- # pending turn settles its dangling tool calls (recorded answers
125
- # injected, unapproved gates denied the H1 machinery) and cancels
126
- # immediately; a running turn cancels cooperatively (the steer message
127
- # queues and the runner's cancel path folds it into the next turn at the
128
- # next step boundary). If the cancel loses the race with a completing
129
- # turn, steering degrades to a normal follow-up — never an error.
116
+ # Steering (opt-in per message): stop what the agent is doing and make this message the next
117
+ # turn. A parked or pending turn settles its dangling tool calls and cancels immediately; a
118
+ # running turn cancels cooperatively at the next step boundary. If the cancel loses the race
119
+ # with a completing turn, steering degrades to a normal follow-up never an error.
130
120
  def steer!(content, definition: Xeno.definition)
131
- turn = turns.where(status: %w[pending running waiting]).order(:sequence).first
121
+ turn = turns.active.order(:sequence).first
132
122
 
133
123
  case turn&.status
134
- when nil # idle: steer is just a message
124
+ when nil # idle: steer degrades to a plain follow-up message
135
125
  staged = stage_turn!(content, definition: definition)
136
126
  staged.enqueue!
137
127
  staged
@@ -147,7 +137,7 @@ module Xeno
147
137
  settle_unanswered_tool_calls!(turn, reason: "steered by user")
148
138
  turn.update!(status: "cancelled")
149
139
  emit("turn.cancelled", { turn_id: turn.id, steer: true })
150
- update!(status: "running") if status == "waiting"
140
+ update!(status: "running") if waiting?
151
141
  end
152
142
  staged = stage_turn!(content, definition: definition)
153
143
  staged.enqueue!
@@ -155,11 +145,9 @@ module Xeno
155
145
  end
156
146
  end
157
147
 
158
- # Stages a compaction turn (summarize-and-replace, executed by the
159
- # runner under a normal claim). It queues behind any active or parked
160
- # turn via the ordinary session-ordering machinery — the
161
- # queued-behind-the-active-turn semantics — and never appends a
162
- # synthetic user message.
148
+ # Stages a compaction turn (summarize-and-replace, executed by the runner under a normal claim).
149
+ # It queues behind any active or parked turn via the ordinary session-ordering machinery and
150
+ # never appends a synthetic user message.
163
151
  def stage_compaction_turn!(reason:, used: nil, limit: nil)
164
152
  transaction do
165
153
  request = { "reason" => reason, "used" => used, "limit" => limit }.compact
@@ -174,13 +162,10 @@ module Xeno
174
162
  Event.append!(self, event_type, data)
175
163
  end
176
164
 
177
- # Called before a turn dies with tool calls still unanswered (cancel
178
- # while parked, schedule park failure): every dangling call gets a tool
179
- # result in the transcript. Without this the assistant tool_call rows
180
- # stay unanswered forever and every later generate is a provider 400 —
181
- # the session is bricked. A completed action (e.g. an answered question
182
- # whose resume never ran) injects its recorded output; everything else
183
- # gets a denial, recorded on the action row as the audit trail.
165
+ # Called before a turn dies with tool calls still unanswered: every dangling call gets a tool
166
+ # result in the transcript. Without this the unanswered rows make every later generate a
167
+ # provider 400 and the session is bricked. A completed action injects its recorded output;
168
+ # everything else gets a denial, recorded on the action row.
184
169
  def settle_unanswered_tool_calls!(turn, reason:)
185
170
  chat_record = Chat.find(chat_id)
186
171
  llm = chat_record.to_llm
@@ -212,11 +197,11 @@ module Xeno
212
197
  end
213
198
 
214
199
  def active?
215
- %w[running waiting].include?(status)
200
+ running? || waiting?
216
201
  end
217
202
 
218
- # Terminal states release the continuation token (a finished session must
219
- # not squat a channel thread's token); a copy stays in metadata for audit.
203
+ # Terminal states release the continuation token (a finished session must not squat a channel
204
+ # thread's token); a copy stays in metadata for audit.
220
205
  def finish!(new_status)
221
206
  raise ArgumentError, "not a terminal status: #{new_status}" unless %w[completed failed].include?(new_status)
222
207
 
@@ -1,29 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # One user message and all work until the agent responds. Runs as one
3
- # ActiveJob. Correctness lives here, in the database, not in the queue:
4
+ # One user message and all work until the agent responds, run as one ActiveJob. Correctness lives
5
+ # in these rows, not in the queue:
4
6
  #
5
- # 1. Atomic claim (CAS on claim_token) one worker owns the turn.
6
- # 2. Heartbeat the owner touches heartbeat_at at step boundaries; a
7
- # stale heartbeat lets the next attempt reclaim (checkpoint replay
8
- # makes takeover safe).
9
- # 3. Fencing token every write is conditioned on claim_token = mine, so
10
- # a zombie that wakes up after being reaped affects zero rows.
11
- # 4. Max attempts — poison turns become turn.failed, not infinite retries.
7
+ # 1. Atomic claim (CAS on claim_token): one worker owns the turn.
8
+ # 2. Heartbeat: a stale heartbeat lets the next attempt reclaim;
9
+ # checkpoint replay makes takeover safe.
10
+ # 3. Fencing token: every write is conditioned on claim_token = mine,
11
+ # so a reaped zombie affects zero rows.
12
+ # 4. Max attempts: poison turns fail instead of retrying forever.
12
13
  class Turn < ApplicationRecord
13
- STATUSES = %w[pending running waiting completed failed cancelled].freeze
14
- KINDS = %w[message compaction].freeze
14
+ enum :status, %w[pending running waiting completed failed cancelled].index_with(&:itself), validate: true
15
+ enum :kind, %w[message compaction].index_with(&:itself), prefix: true, validate: true
15
16
 
16
17
  belongs_to :session, class_name: "Xeno::Session"
17
18
  has_many :actions, class_name: "Xeno::Action", dependent: :destroy
18
19
 
19
- validates :status, inclusion: { in: STATUSES }
20
- validates :kind, inclusion: { in: KINDS }
20
+ scope :active, -> { where(status: %w[pending running waiting]) }
21
21
 
22
- # Appends the next turn to the session, sequence assigned race-safely.
23
- # The INSERT runs in its own savepoint (requires_new): stage_turn! calls
24
- # this inside a transaction, and on Postgres a failed INSERT otherwise
25
- # aborts the whole transaction — the retry would raise
26
- # PG::InFailedSqlTransaction instead of recovering.
22
+ # Appends the next turn with a race-safely assigned sequence. The INSERT runs in its own
23
+ # savepoint because stage_turn! calls this inside a transaction, and on Postgres a failed INSERT
24
+ # would otherwise abort the whole transaction.
27
25
  def self.append!(session, user_message:, transcript_deferred: false, kind: "message")
28
26
  attempts = 0
29
27
  begin
@@ -43,15 +41,13 @@ module Xeno
43
41
  TurnJob.perform_later(id)
44
42
  end
45
43
 
46
- # The atomic claim. Returns the fencing token on success, nil when
47
- # another worker owns the turn (exit quietly) or it isn't claimable.
48
- # Claimable: pending, or running with a stale heartbeat (dead owner).
49
- # Poison turns are failed here instead of retrying forever.
44
+ # The atomic claim. Returns the fencing token, or nil when another worker owns the turn or it
45
+ # isn't claimable. Claimable: pending, or running with a stale heartbeat. Poison turns fail here
46
+ # instead of retrying forever.
50
47
  #
51
- # `attempts` counts FAILURES, not claims: a stale-running reclaim is
52
- # crash evidence and counts here; a transient error counts in
53
- # release_for_retry!; an approval/question resume counts in `resumes`
54
- # and never against the poison ladder (H2).
48
+ # `attempts` counts failures, not claims: a stale-running reclaim is crash evidence and counts;
49
+ # a transient error counts in release_for_retry!; an approval or question resume counts in
50
+ # `resumes`, never against the poison ladder.
55
51
  def claim!
56
52
  current = self.class.find(id)
57
53
 
@@ -60,8 +56,8 @@ module Xeno
60
56
  .where.not(status: %w[completed failed cancelled])
61
57
  .update_all(status: "failed", error: { "message" => "max attempts (#{current.attempts}) exhausted" })
62
58
  if poisoned == 1
63
- # A poisoned TURN leaves the SESSION running (idle, can take the
64
- # next message); settle any dangling tool calls so it stays usable.
59
+ # A poisoned TURN leaves the SESSION running (idle, can take the next message); settle any
60
+ # dangling tool calls so it stays usable.
65
61
  session.settle_unanswered_tool_calls!(self, reason: "turn failed (max attempts exhausted)")
66
62
  session.emit("turn.failed", { turn_id: id, error: "max attempts exhausted" })
67
63
  end
@@ -72,7 +68,7 @@ module Xeno
72
68
  earlier_active = self.class
73
69
  .where(session_id: session_id)
74
70
  .where("sequence < ?", sequence)
75
- .where(status: %w[pending running waiting])
71
+ .active
76
72
 
77
73
  claimed = self.class
78
74
  .where(id: id, claim_token: current.claim_token)
@@ -88,9 +84,8 @@ module Xeno
88
84
  claimed == 1 ? current.claim_token + 1 : nil
89
85
  end
90
86
 
91
- # Transient failure: record the error, count the failure against the
92
- # poison ladder, and hand the claim back (status pending) so the queue
93
- # retry can claim immediately.
87
+ # Transient failure: record the error, count the failure against the poison ladder, and hand the
88
+ # claim back (status pending) so the queue retry can claim immediately.
94
89
  def release_for_retry!(token, error)
95
90
  current = self.class.find(id)
96
91
  fenced_update!(token,
@@ -99,8 +94,8 @@ module Xeno
99
94
  error: { "class" => error.class.name, "message" => error.message })
100
95
  end
101
96
 
102
- # Fenced write: touches the heartbeat iff we still own the claim.
103
- # Raises Xeno::Fenced when a zombie discovers it was reaped.
97
+ # Fenced write: touches the heartbeat iff we still own the claim. Raises Xeno::Fenced when a
98
+ # zombie discovers it was reaped.
104
99
  def heartbeat!(token)
105
100
  fenced_update!(token, heartbeat_at: Time.current)
106
101
  end
data/config/routes.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Xeno::Engine.routes.draw do
2
4
  scope "v1" do
3
5
  get "health", to: "health#show"
@@ -1,15 +1,16 @@
1
- # ruby_llm 2aaddf96: models, tool calls, usage, and batches become library
2
- # records on RubyLLM-owned ruby_llm_* tables (polymorphic toward the app's
3
- # chat/message classes); message rows stop carrying token/cost accounting
4
- # (the per-attempt ruby_llm_usage_entries ledger replaces it). Table shapes
5
- # mirror upstream's install templates.
6
- #
7
- # DESTRUCTIVE (pre-release, no installs to migrate): xeno_tool_calls and
8
- # xeno_models are dropped without moving their rows, and the accounting
9
- # columns on xeno_messages are removed with their data. Existing chats keep
10
- # their transcript text but lose tool-call linkage and usage history.
11
- class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
1
+ class CreateRubyLlmTables < ActiveRecord::Migration[8.1]
12
2
  def change
3
+ create_models
4
+ create_tool_calls
5
+ create_usage_entries
6
+ create_batches
7
+ end
8
+
9
+ private
10
+
11
+ def create_models
12
+ return if table_exists?(:ruby_llm_models)
13
+
13
14
  create_table :ruby_llm_models do |t|
14
15
  t.string :model_id, null: false
15
16
  t.string :name, null: false
@@ -29,6 +30,10 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
29
30
  t.index :provider
30
31
  t.index :family
31
32
  end
33
+ end
34
+
35
+ def create_tool_calls
36
+ return if table_exists?(:ruby_llm_tool_calls)
32
37
 
33
38
  create_table :ruby_llm_tool_calls do |t|
34
39
  t.references :message, polymorphic: true, null: false, index: false
@@ -36,6 +41,7 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
36
41
  t.string :tool_call_id, null: false
37
42
  t.string :name, null: false
38
43
  t.text :thought_signature
44
+ t.string :approval
39
45
  t.json :arguments, default: {}
40
46
  t.timestamps
41
47
 
@@ -44,6 +50,10 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
44
50
  t.index :tool_call_id, unique: true
45
51
  t.index :name
46
52
  end
53
+ end
54
+
55
+ def create_usage_entries
56
+ return if table_exists?(:ruby_llm_usage_entries)
47
57
 
48
58
  create_table :ruby_llm_usage_entries do |t|
49
59
  t.references :chat, polymorphic: true, null: false, index: false
@@ -71,6 +81,10 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
71
81
  t.check_constraint "operation IN ('chat', 'embedding', 'moderation', 'image', 'speech', 'transcription')"
72
82
  t.check_constraint "status IN ('pending', 'succeeded', 'failed', 'cancelled')"
73
83
  end
84
+ end
85
+
86
+ def create_batches
87
+ return if table_exists?(:ruby_llm_batches)
74
88
 
75
89
  create_table :ruby_llm_batches do |t|
76
90
  t.string :provider_batch_id, null: false
@@ -86,48 +100,5 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
86
100
  t.index [ :provider, :provider_batch_id ], unique: true
87
101
  t.index :status
88
102
  end
89
-
90
- # acts_as_chat hard-wires belongs_to :model with this foreign key.
91
- # Upstream's fresh-install template makes it null: false; here it stays
92
- # nullable because pre-existing xeno_chats rows have nothing to point at
93
- # (the association is optional and resolve_model fills it on save).
94
- add_reference :xeno_chats, :ruby_llm_model, foreign_key: { to_table: :ruby_llm_models }
95
-
96
- remove_reference :xeno_messages, :tool_call, foreign_key: { to_table: :xeno_tool_calls }
97
- remove_reference :xeno_messages, :model, foreign_key: { to_table: :xeno_models }
98
- remove_reference :xeno_chats, :model, foreign_key: { to_table: :xeno_models }
99
-
100
- remove_column :xeno_messages, :thinking_tokens, :integer
101
- remove_column :xeno_messages, :input_tokens, :integer
102
- remove_column :xeno_messages, :output_tokens, :integer
103
- remove_column :xeno_messages, :cache_read_tokens, :integer
104
- remove_column :xeno_messages, :cache_write_tokens, :integer
105
- remove_column :xeno_messages, :total_cost, :decimal, precision: 16, scale: 10
106
- remove_column :xeno_messages, :cost_details, :json
107
-
108
- drop_table :xeno_tool_calls do |t|
109
- t.string :tool_call_id, null: false
110
- t.string :name, null: false
111
- t.text :thought_signature
112
- t.json :arguments, default: {}
113
- t.references :message, null: false
114
- t.timestamps
115
- end
116
-
117
- drop_table :xeno_models do |t|
118
- t.string :model_id, null: false
119
- t.string :name, null: false
120
- t.string :provider, null: false
121
- t.string :family
122
- t.datetime :model_created_at
123
- t.integer :context_window
124
- t.integer :max_output_tokens
125
- t.date :knowledge_cutoff
126
- t.json :modalities, default: {}
127
- t.json :capabilities, default: []
128
- t.json :pricing, default: {}
129
- t.json :metadata, default: {}
130
- t.timestamps
131
- end
132
103
  end
133
104
  end
@@ -1,18 +1,36 @@
1
- # The orchestration layer above the transcript: sessions, turns (claim/
2
- # heartbeat/fencing), actions (tool-call checkpoint + approval state),
3
- # events (the append-only session stream), pending messages (drained into
4
- # the next turn). Per spike #2 there is no steps table — a step's output
5
- # lives in xeno_messages/xeno_tool_calls.
6
- class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
1
+ class CreateXenoTables < ActiveRecord::Migration[8.1]
7
2
  def change
3
+ create_table :xeno_chats do |t|
4
+ t.boolean :cancelled, null: false, default: false
5
+ t.references :ruby_llm_model, foreign_key: { to_table: :ruby_llm_models }
6
+ t.timestamps
7
+ end
8
+
9
+ create_table :xeno_messages do |t|
10
+ t.string :role, null: false
11
+ t.text :content
12
+ t.boolean :cache_until_here, null: false, default: false
13
+ t.text :thinking_text
14
+ t.text :thinking_signature
15
+ t.json :citations
16
+ t.json :server_tool_calls
17
+ t.json :raw_content
18
+ t.string :finish_reason
19
+ t.references :chat, null: false, foreign_key: { to_table: :xeno_chats }
20
+ t.timestamps
21
+
22
+ t.index :role
23
+ end
24
+
8
25
  create_table :xeno_sessions do |t|
9
26
  t.string :agent, null: false
10
- t.string :status, null: false, default: "running" # running|waiting|completed|failed
27
+ t.string :status, null: false, default: "running"
11
28
  t.string :channel
12
29
  t.string :continuation_token
13
30
  t.string :title
14
31
  t.json :principal
15
32
  t.json :metadata, default: {}
33
+ t.json :state, null: false, default: {}
16
34
  t.references :chat, null: false, foreign_key: { to_table: :xeno_chats }
17
35
  t.timestamps
18
36
 
@@ -23,11 +41,14 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
23
41
  create_table :xeno_turns do |t|
24
42
  t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
25
43
  t.integer :sequence, null: false
26
- t.string :status, null: false, default: "pending" # pending|running|waiting|completed|failed|cancelled
44
+ t.string :status, null: false, default: "pending"
45
+ t.string :kind, null: false, default: "message"
27
46
  t.integer :claim_token, null: false, default: 0
28
47
  t.datetime :heartbeat_at
29
48
  t.integer :attempts, null: false, default: 0
49
+ t.integer :resumes, null: false, default: 0
30
50
  t.integer :steps_count, null: false, default: 0
51
+ t.boolean :transcript_deferred, null: false, default: false
31
52
  t.json :user_message
32
53
  t.json :error
33
54
  t.timestamps
@@ -38,14 +59,14 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
38
59
 
39
60
  create_table :xeno_actions do |t|
40
61
  t.references :turn, null: false, foreign_key: { to_table: :xeno_turns }
41
- t.string :tool_call_id, null: false # the provider call id (xeno_tool_calls.tool_call_id)
62
+ t.string :tool_call_id, null: false
42
63
  t.string :tool_name, null: false
43
- t.string :kind, null: false, default: "tool" # tool|question
44
- t.string :status, null: false, default: "pending" # pending|pending_approval|approved|denied|completed|failed
64
+ t.string :kind, null: false, default: "tool"
65
+ t.string :status, null: false, default: "pending"
45
66
  t.json :input
46
67
  t.json :output
47
- t.datetime :resolved_at # when a human approved/denied/answered
48
- t.json :resolved_by # the resolving principal
68
+ t.datetime :resolved_at
69
+ t.json :resolved_by
49
70
  t.timestamps
50
71
 
51
72
  t.index [ :turn_id, :tool_call_id ], unique: true
@@ -53,7 +74,7 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
53
74
 
54
75
  create_table :xeno_events do |t|
55
76
  t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
56
- t.integer :index, null: false # per-session cursor (SSE start_index)
77
+ t.integer :index, null: false
57
78
  t.string :event_type, null: false
58
79
  t.json :data
59
80
  t.datetime :created_at, null: false
@@ -66,5 +87,14 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
66
87
  t.json :payload, null: false
67
88
  t.timestamps
68
89
  end
90
+
91
+ create_table :xeno_dedups do |t|
92
+ t.string :scope, null: false
93
+ t.string :key, null: false
94
+ t.json :metadata
95
+ t.datetime :created_at, null: false
96
+
97
+ t.index [ :scope, :key ], unique: true
98
+ end
69
99
  end
70
100
  end