ruby_llm-agents 3.15.0 → 3.16.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +29 -8
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +48 -44
  4. data/app/controllers/ruby_llm/agents/analytics_controller.rb +38 -72
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +69 -56
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +25 -41
  7. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -45
  8. data/app/controllers/ruby_llm/agents/tenants_controller.rb +67 -47
  9. data/app/models/ruby_llm/agents/execution/analytics.rb +174 -133
  10. data/app/models/ruby_llm/agents/execution/breakdown.rb +243 -0
  11. data/app/models/ruby_llm/agents/execution/scopes.rb +39 -3
  12. data/app/models/ruby_llm/agents/execution.rb +23 -5
  13. data/app/services/ruby_llm/agents/agent_registry.rb +75 -12
  14. data/app/views/ruby_llm/agents/agents/index.html.erb +24 -7
  15. data/app/views/ruby_llm/agents/agents/show.html.erb +3 -2
  16. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -7
  17. data/app/views/ruby_llm/agents/executions/_list.html.erb +1 -52
  18. data/app/views/ruby_llm/agents/requests/index.html.erb +8 -57
  19. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +1 -52
  20. data/app/views/ruby_llm/agents/shared/_pagination.html.erb +66 -0
  21. data/app/views/ruby_llm/agents/shared/query_timeout.html.erb +18 -0
  22. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -0
  23. data/app/views/ruby_llm/agents/tenants/index.html.erb +1 -1
  24. data/app/views/ruby_llm/agents/tenants/show.html.erb +6 -5
  25. data/config/routes.rb +2 -2
  26. data/lib/generators/ruby_llm_agents/templates/add_executions_analytics_index_migration.rb.tt +50 -0
  27. data/lib/generators/ruby_llm_agents/templates/add_root_execution_list_index_migration.rb.tt +16 -0
  28. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +5 -0
  29. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +26 -0
  30. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +44 -0
  31. data/lib/ruby_llm/agents/audio/transcriber.rb +7 -121
  32. data/lib/ruby_llm/agents/core/configuration.rb +20 -0
  33. data/lib/ruby_llm/agents/core/version.rb +1 -1
  34. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +39 -1
  35. data/lib/ruby_llm/agents/rails/engine.rb +87 -7
  36. metadata +7 -2
@@ -0,0 +1,18 @@
1
+ <div class="flex items-center gap-3 mb-6">
2
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">query timed out</span>
3
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
4
+ </div>
5
+
6
+ <div class="font-mono text-xs space-y-3" role="alert">
7
+ <p class="text-gray-900 dark:text-gray-100">
8
+ This view needed more than <%= @query_timeout %>s of database time, so the query was cancelled.
9
+ </p>
10
+ <p class="text-gray-500 dark:text-gray-400">
11
+ It aggregates every execution in the selected range. Try a shorter time range or a narrower filter.
12
+ The limit is <code class="text-gray-700 dark:text-gray-300">config.dashboard_query_timeout</code>.
13
+ </p>
14
+ <p>
15
+ <%= link_to "← back to dashboard", ruby_llm_agents.root_path,
16
+ class: "text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:underline" %>
17
+ </p>
18
+ </div>
@@ -106,6 +106,10 @@
106
106
  <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">recent limit</span>
107
107
  <span class="text-gray-900 dark:text-gray-200"><%= @config.recent_executions_limit %></span>
108
108
  </div>
109
+ <div class="flex items-center gap-3 py-0.5">
110
+ <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">query timeout</span>
111
+ <span class="text-gray-900 dark:text-gray-200"><%= @config.dashboard_query_timeout ? "#{@config.dashboard_query_timeout}s" : "off" %></span>
112
+ </div>
109
113
  <div class="flex items-center gap-3 py-0.5">
110
114
  <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">parent controller</span>
111
115
  <span class="text-gray-900 dark:text-gray-200"><%= @config.dashboard_parent_controller %></span>
@@ -51,7 +51,7 @@
51
51
  class: "w-20 flex-shrink-0 text-right hidden sm:block hover:text-gray-600 dark:hover:text-gray-300 #{sort_active.call('monthly_limit')}" %>
52
52
  <%= link_to "enforcement#{sort_arrow.call('enforcement')}".html_safe, sort_url.call("enforcement"),
53
53
  class: "w-16 flex-shrink-0 hidden md:block hover:text-gray-600 dark:hover:text-gray-300 #{sort_active.call('enforcement')}" %>
54
- <span class="w-16 flex-shrink-0 text-right hidden md:block">cost</span>
54
+ <span class="w-16 flex-shrink-0 text-right hidden md:block" title="Cost this month">cost/mo</span>
55
55
  <span class="w-24 flex-shrink-0 text-right">last run</span>
56
56
  </div>
57
57
 
@@ -8,7 +8,7 @@
8
8
  total_executions = @usage_stats[:total_executions]
9
9
  total_cost = @usage_stats[:total_cost]
10
10
  total_tokens = @usage_stats[:total_tokens]
11
- success_count = @tenant.executions.where(status: "success").count
11
+ success_count = @usage_stats[:success_count]
12
12
  success_rate = total_executions > 0 ? (success_count.to_f / total_executions * 100).round : 100
13
13
 
14
14
  has_any_limit = @tenant.effective_daily_limit || @tenant.effective_monthly_limit ||
@@ -64,6 +64,7 @@
64
64
  <!-- Right: stats -->
65
65
  <div class="flex-shrink-0 text-right font-mono text-xs text-gray-400 dark:text-gray-500 space-y-1">
66
66
  <div class="flex items-center justify-end gap-x-4">
67
+ <span class="text-gray-300 dark:text-gray-700">this month</span>
67
68
  <span><span class="text-gray-800 dark:text-gray-200"><%= number_with_delimiter(total_executions) %></span> runs</span>
68
69
  <span class="<%= success_rate >= 95 ? 'text-green-500' : success_rate >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= success_rate %>% ok</span>
69
70
  <span><span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(total_cost, precision: 4) %></span> cost</span>
@@ -127,10 +128,10 @@
127
128
  <% if @error_count > 0 %>
128
129
  <div class="mt-3 font-mono text-xs text-gray-500 dark:text-gray-400">
129
130
  <span class="text-red-500">$<%= number_with_precision(@error_cost, precision: 4) %></span>
130
- wasted on <span class="text-red-500"><%= number_with_delimiter(@error_count) %></span> failed executions
131
+ wasted on <span class="text-red-500"><%= number_with_delimiter(@error_count) %></span> failed executions this month
131
132
  <% error_pct = total_cost.to_f > 0 ? (@error_cost.to_f / total_cost * 100).round(1) : 0 %>
132
133
  <% if error_pct > 0 %>
133
- (<%= error_pct %>% of total cost)
134
+ (<%= error_pct %>% of this month's cost)
134
135
  <% end %>
135
136
  </div>
136
137
  <% end %>
@@ -321,7 +322,7 @@
321
322
  <% if @usage_by_agent.present? %>
322
323
  <!-- ── usage by agent ──────────────────── -->
323
324
  <div class="flex items-center gap-3 mt-6 mb-3">
324
- <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by agent</span>
325
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by agent &middot; this month</span>
325
326
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
326
327
  </div>
327
328
 
@@ -359,7 +360,7 @@
359
360
  <% if @usage_by_model.present? %>
360
361
  <!-- ── usage by model ──────────────────── -->
361
362
  <div class="flex items-center gap-3 mt-6 mb-3">
362
- <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by model</span>
363
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by model &middot; this month</span>
363
364
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
364
365
  </div>
365
366
 
data/config/routes.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  RubyLLM::Agents::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
- get "chart_data", to: "dashboard#chart_data"
5
+ get "chart_data", to: "dashboard#chart_data", defaults: {format: :json}
6
6
 
7
7
  resources :agents, only: [:index, :show, :update] do
8
8
  member do
@@ -26,6 +26,6 @@ RubyLLM::Agents::Engine.routes.draw do
26
26
  end
27
27
 
28
28
  get "analytics", to: "analytics#index", as: :analytics
29
- get "analytics/chart_data", to: "analytics#chart_data", as: :analytics_chart_data
29
+ get "analytics/chart_data", to: "analytics#chart_data", as: :analytics_chart_data, defaults: {format: :json}
30
30
  resource :system_config, only: [:show], controller: "system_config"
31
31
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to add the covering index behind the dashboard's aggregate queries
4
+ #
5
+ # Dashboard, analytics and agent stats all aggregate executions over a time
6
+ # range, reading a handful of narrow columns from each row. Fetching those
7
+ # rows from the table drags the wide ones along (metadata JSON, in practice
8
+ # most of each row), so the cost of every figure grows with the size of the
9
+ # range: seconds for 30 days on a table with millions of executions.
10
+ #
11
+ # This index stores the aggregated columns next to created_at, so those
12
+ # queries are answered from the index alone. PostgreSQL keeps them out of the
13
+ # key with INCLUDE; SQLite takes them as trailing key columns.
14
+ #
15
+ # The index is built CONCURRENTLY on PostgreSQL so executions keep being
16
+ # recorded while it builds. On a large table that takes a while; an
17
+ # interrupted build leaves an invalid index behind, which re-running this
18
+ # migration replaces.
19
+ class AddExecutionsAnalyticsIndex < ActiveRecord::Migration<%= migration_version %>
20
+ disable_ddl_transaction!
21
+
22
+ TABLE = :ruby_llm_agents_executions
23
+ INDEX = "idx_executions_analytics"
24
+ COVERED = %i[
25
+ agent_type model_id chosen_model_id status error_class tenant_id parent_execution_id
26
+ total_cost total_tokens input_tokens output_tokens duration_ms cache_hit streaming
27
+ ].freeze
28
+
29
+ def up
30
+ covered = COVERED.select { |column| column_exists?(TABLE, column) }
31
+
32
+ case connection.adapter_name.downcase
33
+ when /postg/
34
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX}"
35
+ execute "CREATE INDEX CONCURRENTLY #{INDEX} ON #{TABLE} (created_at) INCLUDE (#{covered.join(", ")})"
36
+ when /sqlite/
37
+ add_index TABLE, [:created_at, *covered], name: INDEX, if_not_exists: true
38
+ else
39
+ say "Skipping #{INDEX}: covering index is only defined for PostgreSQL and SQLite"
40
+ end
41
+ end
42
+
43
+ def down
44
+ if connection.adapter_name.downcase.include?("postg")
45
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX}"
46
+ else
47
+ remove_index TABLE, name: INDEX, if_exists: true
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to add the index behind the executions dashboard list
4
+ #
5
+ # The list page runs `WHERE parent_execution_id IS NULL ORDER BY created_at DESC`
6
+ # plus a COUNT over the same predicate. With only the single-column
7
+ # parent_execution_id and created_at indexes available, the planner has to pick
8
+ # one and filter the rest, which degrades to a sequential scan once child
9
+ # executions make up a meaningful share of the table.
10
+ class AddRootExecutionListIndex < ActiveRecord::Migration<%= migration_version %>
11
+ def change
12
+ add_index :ruby_llm_agents_executions, [:parent_execution_id, :created_at],
13
+ name: "idx_executions_parent_created_at",
14
+ if_not_exists: true
15
+ end
16
+ end
@@ -132,6 +132,11 @@ RubyLLM::Agents.configure do |config|
132
132
  # Number of recent executions shown on the dashboard home
133
133
  # config.recent_executions_limit = 10
134
134
 
135
+ # Seconds a single dashboard query may run before the database cancels it
136
+ # (PostgreSQL only). Keeps a wide time range on a large executions table from
137
+ # tying up a web worker and the database; nil disables it.
138
+ # config.dashboard_query_timeout = 5
139
+
135
140
  # ============================================
136
141
  # Reliability Defaults
137
142
  # ============================================
@@ -76,11 +76,37 @@ class CreateRubyLLMAgentsExecutions < ActiveRecord::Migration<%= migration_versi
76
76
  add_index :ruby_llm_agents_executions, :trace_id
77
77
  add_index :ruby_llm_agents_executions, :request_id
78
78
  add_index :ruby_llm_agents_executions, :parent_execution_id
79
+ add_index :ruby_llm_agents_executions, [:parent_execution_id, :created_at]
79
80
  add_index :ruby_llm_agents_executions, :root_execution_id
80
81
  add_index :ruby_llm_agents_executions, [:status, :created_at]
81
82
  add_index :ruby_llm_agents_executions, [:model_id, :status]
82
83
  add_index :ruby_llm_agents_executions, [:cache_hit, :created_at]
83
84
 
85
+ # Covering index behind the dashboard's aggregate queries. They read a
86
+ # time range and only these columns, so with this index they are answered
87
+ # without fetching the (much wider) table rows. PostgreSQL keeps the extra
88
+ # columns out of the key with INCLUDE; SQLite takes them as key columns.
89
+ analytics_columns = %w[
90
+ agent_type model_id chosen_model_id status error_class tenant_id parent_execution_id
91
+ total_cost total_tokens input_tokens output_tokens duration_ms cache_hit streaming
92
+ ]
93
+ case connection.adapter_name.downcase
94
+ when /postg/
95
+ reversible do |dir|
96
+ dir.up do
97
+ execute "CREATE INDEX idx_executions_analytics ON ruby_llm_agents_executions " \
98
+ "(created_at) INCLUDE (#{analytics_columns.join(", ")})"
99
+ end
100
+ # Dropped explicitly: both this and the plain created_at index are
101
+ # keyed on created_at alone, so reverting that one by column would
102
+ # otherwise be ambiguous.
103
+ dir.down { execute "DROP INDEX IF EXISTS idx_executions_analytics" }
104
+ end
105
+ when /sqlite/
106
+ add_index :ruby_llm_agents_executions, ["created_at", *analytics_columns],
107
+ name: "idx_executions_analytics"
108
+ end
109
+
84
110
  # Foreign keys for execution hierarchy
85
111
  add_foreign_key :ruby_llm_agents_executions, :ruby_llm_agents_executions,
86
112
  column: :parent_execution_id, on_delete: :nullify
@@ -117,6 +117,44 @@ module RubyLlmAgents
117
117
  )
118
118
  end
119
119
 
120
+ # Add the composite index behind the executions dashboard list
121
+ def create_add_root_execution_list_index_migration
122
+ unless table_exists?(:ruby_llm_agents_executions)
123
+ say_status :skip, "executions table does not exist yet", :yellow
124
+ return
125
+ end
126
+
127
+ if index_exists?(:ruby_llm_agents_executions, [:parent_execution_id, :created_at])
128
+ say_status :skip, "root execution list index already exists", :yellow
129
+ return
130
+ end
131
+
132
+ say_status :upgrade, "Adding root execution list index", :blue
133
+ migration_template(
134
+ "add_root_execution_list_index_migration.rb.tt",
135
+ File.join(db_migrate_path, "add_root_execution_list_index.rb")
136
+ )
137
+ end
138
+
139
+ # Add the covering index behind the dashboard's aggregate queries
140
+ def create_add_executions_analytics_index_migration
141
+ unless table_exists?(:ruby_llm_agents_executions)
142
+ say_status :skip, "executions table does not exist yet", :yellow
143
+ return
144
+ end
145
+
146
+ if index_name_exists?(:ruby_llm_agents_executions, "idx_executions_analytics")
147
+ say_status :skip, "executions analytics index already exists", :yellow
148
+ return
149
+ end
150
+
151
+ say_status :upgrade, "Adding executions analytics index", :blue
152
+ migration_template(
153
+ "add_executions_analytics_index_migration.rb.tt",
154
+ File.join(db_migrate_path, "add_executions_analytics_index.rb")
155
+ )
156
+ end
157
+
120
158
  # Create overrides table for dashboard-managed agent settings
121
159
  def create_overrides_migration
122
160
  if table_exists?(:ruby_llm_agents_overrides)
@@ -231,5 +269,11 @@ module RubyLlmAgents
231
269
  rescue
232
270
  false
233
271
  end
272
+
273
+ def index_name_exists?(table, name)
274
+ ActiveRecord::Base.connection.index_name_exists?(table, name)
275
+ rescue
276
+ false
277
+ end
234
278
  end
235
279
  end
@@ -117,37 +117,10 @@ module RubyLLM
117
117
 
118
118
  # @!endgroup
119
119
 
120
- # @!group Reliability DSL
121
-
122
- # Configures reliability options (retries, fallbacks)
123
- #
124
- # @yield Block for configuring reliability options
125
- # @return [ReliabilityConfig] The reliability configuration
126
- def reliability(&block)
127
- @reliability_config ||= ReliabilityConfig.new
128
- @reliability_config.instance_eval(&block) if block_given?
129
- @reliability_config
130
- end
131
-
132
- # Returns reliability configuration
133
- #
134
- # @return [ReliabilityConfig, nil] The reliability configuration
135
- def reliability_config
136
- @reliability_config || inherited_or_default(:reliability_config, nil)
137
- end
138
-
139
- # Sets fallback models directly (shorthand for reliability block)
140
- #
141
- # @param models [Array<String>] Model identifiers to try on failure
142
- # @return [Array<String>] The fallback models
143
- def fallback_models(*models)
144
- if models.any?
145
- @fallback_models = models.flatten
146
- end
147
- @fallback_models || inherited_or_default(:fallback_models, [])
148
- end
149
-
150
- # @!endgroup
120
+ # Reliability (retries, fallbacks, circuit breakers) comes from the
121
+ # shared DSL::Reliability on BaseAgent and is executed by the
122
+ # Reliability middleware — the middleware expects the Hash config that
123
+ # DSL produces, so Transcriber must not shadow it with its own object.
151
124
 
152
125
  # Factory method to instantiate and execute transcription
153
126
  #
@@ -197,40 +170,6 @@ module RubyLLM
197
170
  end
198
171
  end
199
172
 
200
- # Configuration class for reliability options
201
- class ReliabilityConfig
202
- attr_accessor :max_retries, :backoff, :fallback_models_list, :total_timeout_seconds
203
-
204
- def initialize
205
- @max_retries = 3
206
- @backoff = :exponential
207
- @fallback_models_list = []
208
- @total_timeout_seconds = nil
209
- end
210
-
211
- def retries(max: 3, backoff: :exponential)
212
- @max_retries = max
213
- @backoff = backoff
214
- end
215
-
216
- def fallback_models(*models)
217
- @fallback_models_list = models.flatten
218
- end
219
-
220
- def total_timeout(seconds)
221
- @total_timeout_seconds = seconds
222
- end
223
-
224
- def to_h
225
- {
226
- max_retries: max_retries,
227
- backoff: backoff,
228
- fallback_models: fallback_models_list,
229
- total_timeout: total_timeout_seconds
230
- }
231
- end
232
- end
233
-
234
173
  # @!attribute [r] audio
235
174
  # @return [String, File, IO] Audio input
236
175
  # @!attribute [r] audio_format
@@ -308,8 +247,9 @@ module RubyLLM
308
247
  audio_input = normalize_audio_input(@audio, @audio_format)
309
248
  validate_audio_input!(audio_input)
310
249
 
311
- # Execute transcription with reliability (retries, fallbacks)
312
- raw_result = execute_with_reliability(audio_input)
250
+ # context.model is set per-attempt by the Reliability middleware, so
251
+ # fallback models actually take effect here
252
+ raw_result = execute_transcription(audio_input, context.model || resolved_model)
313
253
 
314
254
  execution_completed_at = Time.current
315
255
  duration_ms = ((execution_completed_at - execution_started_at) * 1000).to_i
@@ -455,37 +395,6 @@ module RubyLLM
455
395
  end
456
396
  end
457
397
 
458
- # Executes transcription with reliability features
459
- #
460
- # @param audio_input [Hash] Normalized audio input
461
- # @return [Hash] Raw transcription result
462
- def execute_with_reliability(audio_input)
463
- models_to_try = [resolved_model] + self.class.fallback_models
464
- last_error = nil
465
-
466
- models_to_try.each do |model|
467
- retries = 0
468
- max_retries = reliability_max_retries
469
-
470
- begin
471
- return execute_transcription(audio_input, model)
472
- rescue => e
473
- last_error = e
474
- retries += 1
475
-
476
- if retryable_error?(e) && retries < max_retries
477
- sleep(calculate_backoff(retries))
478
- retry
479
- end
480
-
481
- # Try next model
482
- next
483
- end
484
- end
485
-
486
- raise last_error || StandardError.new("All transcription models exhausted")
487
- end
488
-
489
398
  # Executes the actual transcription API call
490
399
  #
491
400
  # @param audio_input [Hash] Normalized audio input
@@ -663,29 +572,6 @@ module RubyLLM
663
572
  def resolved_language
664
573
  @runtime_language || self.class.language
665
574
  end
666
-
667
- # Returns max retries from reliability config
668
- def reliability_max_retries
669
- config = self.class.reliability_config
670
- config&.max_retries || 3
671
- end
672
-
673
- # Checks if error is retryable
674
- def retryable_error?(error)
675
- message = error.message.to_s.downcase
676
- retryable_patterns = ["rate limit", "timeout", "503", "502", "429", "overloaded"]
677
- retryable_patterns.any? { |pattern| message.include?(pattern) }
678
- end
679
-
680
- # Calculates exponential backoff delay
681
- def calculate_backoff(attempt)
682
- config = self.class.reliability_config
683
- base = (config&.backoff == :constant) ? 1.0 : 0.4
684
- max_delay = 10.0
685
-
686
- delay = base * (2**(attempt - 1))
687
- [delay, max_delay].min
688
- end
689
575
  end
690
576
  end
691
577
  end
@@ -109,6 +109,14 @@ module RubyLLM
109
109
  # Number of recent executions shown on the dashboard home.
110
110
  # @return [Integer] Limit for recent executions (default: 10)
111
111
 
112
+ # @!attribute [rw] dashboard_query_timeout
113
+ # Seconds a single dashboard query may run before the database cancels
114
+ # it. A cancelled query renders a "narrow the time range" notice
115
+ # instead of leaving the request (and the database) grinding on a scan
116
+ # of the whole executions table. Enforced on PostgreSQL only; a
117
+ # stricter statement_timeout already set on the connection wins.
118
+ # @return [Numeric, nil] Timeout in seconds, or nil to disable (default: 5)
119
+
112
120
  # @!attribute [rw] job_retry_attempts
113
121
  # Number of retry attempts for the async logging job on failure.
114
122
  # @return [Integer] Retry attempts (default: 3)
@@ -494,6 +502,7 @@ module RubyLLM
494
502
  :anomaly_duration_threshold,
495
503
  :per_page,
496
504
  :recent_executions_limit,
505
+ :dashboard_query_timeout,
497
506
  :job_retry_attempts,
498
507
  :messages_summary_max_length,
499
508
  :dashboard_auth,
@@ -560,6 +569,15 @@ module RubyLLM
560
569
  @recent_executions_limit = value
561
570
  end
562
571
 
572
+ # Sets dashboard_query_timeout with validation
573
+ #
574
+ # @param value [Numeric, nil] Seconds (must be > 0), or nil to disable
575
+ # @raise [ArgumentError] If value is not positive
576
+ def dashboard_query_timeout=(value)
577
+ validate_positive!(:dashboard_query_timeout, value) unless value.nil?
578
+ @dashboard_query_timeout = value
579
+ end
580
+
563
581
  # Sets job_retry_attempts with validation
564
582
  #
565
583
  # @param value [Integer] Retry attempts (must be >= 0)
@@ -702,6 +720,7 @@ module RubyLLM
702
720
  @basic_auth_password = nil
703
721
  @per_page = 25
704
722
  @recent_executions_limit = 10
723
+ @dashboard_query_timeout = 5
705
724
  @job_retry_attempts = 3
706
725
 
707
726
  # Reliability defaults (all disabled by default for backward compatibility)
@@ -1031,6 +1050,7 @@ module RubyLLM
1031
1050
  dashboard: {
1032
1051
  per_page: per_page,
1033
1052
  recent_executions_limit: recent_executions_limit,
1053
+ dashboard_query_timeout: dashboard_query_timeout,
1034
1054
  dashboard_parent_controller: dashboard_parent_controller,
1035
1055
  basic_auth_username: basic_auth_username,
1036
1056
  dashboard_auth: dashboard_auth&.class&.name
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.15.0"
7
+ VERSION = "3.16.0"
8
8
  end
9
9
  end
@@ -172,6 +172,12 @@ module RubyLLM
172
172
  # Falls back to creating a new record if the initial record is nil.
173
173
  # Errors are re-raised to allow the ensure block to handle them.
174
174
  #
175
+ # The record is locked and reloaded before updating: the instance
176
+ # held here was created when the pipeline started and may be stale —
177
+ # a concurrent transition (e.g. operator cancellation or a timeout
178
+ # sweep) can have written a terminal status in the meantime, and the
179
+ # completion must not overwrite it.
180
+ #
175
181
  # @param execution [Execution, nil] The execution record to update
176
182
  # @param context [Context] The execution context
177
183
  # @param status [String] Final status ("success", "error", "timeout")
@@ -188,7 +194,7 @@ module RubyLLM
188
194
  end
189
195
 
190
196
  update_data = build_completion_data(context, status)
191
- execution.update!(update_data)
197
+ return unless complete_running_execution(execution, update_data)
192
198
 
193
199
  # Save detail data (prompts, responses, tool calls, etc.)
194
200
  save_execution_details(execution, context, status)
@@ -197,6 +203,31 @@ module RubyLLM
197
203
  raise # Re-raise for ensure block to handle via mark_execution_failed!
198
204
  end
199
205
 
206
+ # Applies the completion update unless the execution already left
207
+ # "running" state.
208
+ #
209
+ # Locks the row and reloads it, so the status check and the update
210
+ # are atomic with respect to concurrent status transitions: whichever
211
+ # side wins the row lock first keeps the row. A completion that finds
212
+ # a terminal status is dropped instead of overwriting it.
213
+ #
214
+ # @param execution [Execution] The execution record to update
215
+ # @param update_data [Hash] The completion attributes
216
+ # @return [Boolean] Whether the completion update was applied
217
+ def complete_running_execution(execution, update_data)
218
+ return execution.update!(update_data) unless execution.persisted?
219
+
220
+ execution.with_lock do
221
+ if execution.status == "running"
222
+ execution.update!(update_data)
223
+ true
224
+ else
225
+ debug("Skipped completion for execution #{execution.id}: already #{execution.status}")
226
+ false
227
+ end
228
+ end
229
+ end
230
+
200
231
  # Emergency fallback to mark execution as failed
201
232
  #
202
233
  # Uses update_all to bypass ActiveRecord callbacks and validations,
@@ -431,6 +462,13 @@ module RubyLLM
431
462
  data[:attempts_count] = context[:reliability_attempts].size
432
463
  end
433
464
 
465
+ # Carry the tracker request_id/tags that
466
+ # build_running_execution_data injected through the metadata
467
+ # rebuild — otherwise a completed execution loses them. Only
468
+ # merge into a rebuild that actually produced metadata: when
469
+ # none did, the running record's metadata stays untouched.
470
+ inject_tracker_data(context, data) if data[:metadata]
471
+
434
472
  data
435
473
  end
436
474