legionio 1.9.0 → 1.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69a0d25a5f8ac2e2327e09ca2185099bd87e7b8575d8c941abc807a0b6c03dfb
4
- data.tar.gz: f9cb3bcdb322e670a235cc7761e43377d11ed8fed8e7e34b235b8ff77773ef8a
3
+ metadata.gz: 1dee00872a482d84ca7cffed624add4a568595383b31d93ffe1983d5bfb89d91
4
+ data.tar.gz: 0e8eaba327808c586a3cdf6d9e8da177831ac0ce9f5fb1919de115f48b63346f
5
5
  SHA512:
6
- metadata.gz: 2d7fe093823d8a62b3bbf31f1cb9733b18d56a208990f314e7b9c9ec9ec1b51af2faf18716b48032fd57039b71aec7f32629d0d2218a9500f4746c0525da432f
7
- data.tar.gz: 51b33926ea63e730ee6407e61da81808bb058030fcb1f1f2d3cd87bcafe25b4c82b21d8a914dabb02771795329e601cc1d14f2b147efccc274895da78c11cfc0
6
+ metadata.gz: 7b7124a1ed4fa62aaa5da4e55391961973435b324cc17142245c52af7b32d43d6f7c161341f12d27aca47310dc9e6289a3d1a4d781561b001baa724c03ddc8ba
7
+ data.tar.gz: 952d0045d985d1f14ac76e15e015fa8bd279ed92ae123cd43e7f5139ed7efbee9a3cba93856ce2f6c8603e74f9549495e415fcd8459a27d3b35cb169eebec617
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.9.2] - 2026-04-27
6
+
7
+ ### Fixed
8
+ - `POST /api/knowledge/status` no longer silently defaults to the daemon's cwd. Uses `knowledge.default_corpus_path` setting or `LEGION_CORPUS_PATH` env var; returns 400 when unresolvable. Prevents `Errno::EPERM` crashes on macOS when the daemon is launched from `~` and `Find.find` walks into TCC-protected subdirs like `~/Library/Accounts`.
9
+
10
+ ## [1.9.1] - 2026-04-25
11
+
12
+ ### Added
13
+ - Extension runtime handles now expose authoritative active/latest versions, reload state, pending-reload status, hot-reload eligibility, and owned runtime resources through `/api/extension_catalog`.
14
+ - Extension dispatch quiescing now blocks API, ingress, and subscription runner dispatch while an extension is stopping or actively reloading.
15
+ - `Legion::Tools::Registry.unregister_extension` removes callable tools owned by an extension during unload/reload cleanup.
16
+
17
+ ### Fixed
18
+ - Runtime handle `loaded?` no longer reports `stopped` or `failed` extensions as loaded.
19
+ - Extension registration publication now happens after extension autobuild and runtime side effects complete, avoiding durable registration of failed loads.
20
+ - Extension runtime handles now transition to loaded only after `require` and extension side effects succeed, and multi-segment extension modules keep their hyphenated lex identity.
21
+
5
22
  ## [1.9.0] - 2026-04-24
6
23
 
7
24
  ### Added
@@ -22,33 +22,21 @@ module Legion
22
22
 
23
23
  def self.register_extension_routes(app)
24
24
  app.get '/api/extension_catalog' do
25
- entries = Legion::Extensions::Catalog.all.map do |name, entry|
26
- { name: name, state: entry[:state].to_s,
27
- registered_at: entry[:registered_at]&.iso8601,
28
- started_at: entry[:started_at]&.iso8601 }
29
- end
25
+ entries = Routes::Extensions.extension_entries
30
26
  entries = entries.select { |e| e[:state] == params[:state] } if params[:state]
31
27
  json_response(entries)
32
28
  end
33
29
 
34
30
  app.get '/api/extension_catalog/:name' do
35
31
  name = params[:name]
36
- entry = Legion::Extensions::Catalog.entry(name)
32
+ entry = Routes::Extensions.extension_entry(name)
37
33
  halt_not_found("extension '#{name}' not found") unless entry
38
34
 
39
35
  ext_mod = find_extension_module(name)
40
- version = ext_mod&.const_defined?(:VERSION) ? ext_mod::VERSION : nil
41
36
 
42
37
  runners = ext_mod ? runner_summaries(ext_mod) : []
43
38
 
44
- json_response({
45
- name: name,
46
- state: entry[:state].to_s,
47
- version: version,
48
- registered_at: entry[:registered_at]&.iso8601,
49
- started_at: entry[:started_at]&.iso8601,
50
- runners: runners
51
- }.compact)
39
+ json_response(entry.merge(runners: runners).compact)
52
40
  end
53
41
  end
54
42
 
@@ -154,6 +142,52 @@ module Legion
154
142
  end
155
143
 
156
144
  class << self
145
+ def extension_entries
146
+ handles = if Legion::Extensions.respond_to?(:extension_handles)
147
+ Legion::Extensions.extension_handles
148
+ else
149
+ []
150
+ end
151
+ return handles.map { |handle| serialize_handle(handle) } unless handles.empty?
152
+
153
+ Legion::Extensions::Catalog.all.filter_map do |name, entry|
154
+ serialize_catalog_entry(name, entry)
155
+ end
156
+ end
157
+
158
+ def extension_entry(name)
159
+ handle = Legion::Extensions.extension_handle(name) if Legion::Extensions.respond_to?(:extension_handle)
160
+ return serialize_handle(handle) if handle
161
+
162
+ serialize_catalog_entry(name, Legion::Extensions::Catalog.entry(name))
163
+ end
164
+
165
+ def serialize_handle(handle)
166
+ {
167
+ name: handle.lex_name,
168
+ state: handle.state.to_s,
169
+ active_version: handle.active_version&.to_s,
170
+ latest_installed_version: handle.latest_installed_version&.to_s,
171
+ reload_state: handle.reload_state.to_s,
172
+ pending_reload: handle.pending_reload?,
173
+ hot_reloadable: handle.hot_reloadable,
174
+ loaded_at: handle.loaded_at&.iso8601,
175
+ last_error: handle.last_error,
176
+ routes: handle.routes,
177
+ tools: handle.tools,
178
+ absorbers: handle.absorbers,
179
+ owned_runners: handle.runners
180
+ }.compact
181
+ end
182
+
183
+ def serialize_catalog_entry(name, entry)
184
+ return nil unless entry
185
+
186
+ { name: name, state: entry[:state].to_s,
187
+ registered_at: entry[:registered_at]&.iso8601,
188
+ started_at: entry[:started_at]&.iso8601 }
189
+ end
190
+
157
191
  private :register_available_route, :register_extension_routes,
158
192
  :register_runner_routes, :register_function_routes, :register_invoke_route
159
193
  end
@@ -67,7 +67,15 @@ module Legion
67
67
  app.post '/api/knowledge/status' do
68
68
  require_knowledge_ingest!
69
69
  body = parse_request_body
70
- path = body[:path] || Dir.pwd
70
+ path = body[:path] ||
71
+ Legion::Settings.dig(:knowledge, :default_corpus_path) ||
72
+ ENV.fetch('LEGION_CORPUS_PATH', nil)
73
+
74
+ if path.nil? || path.to_s.empty?
75
+ halt 400, json_error('missing_param',
76
+ 'path is required (no knowledge.default_corpus_path configured)')
77
+ end
78
+
71
79
  result = Legion::Extensions::Knowledge::Runners::Ingest.scan_corpus(path: path)
72
80
  json_response(result)
73
81
  end
@@ -20,21 +20,23 @@ module Legion
20
20
  end
21
21
  end
22
22
 
23
- EXTENSION_IVARS = {
24
- loaded: :@loaded_extensions,
23
+ EXTENSION_TASK_IVARS = {
25
24
  discovered: :@extensions,
26
25
  subscription: :@subscription_tasks,
27
26
  every: :@timer_tasks,
28
27
  poll: :@poll_tasks,
29
28
  once: :@once_tasks,
30
29
  loop: :@loop_tasks,
31
- running: :@running_instances
30
+ actors: :@running_instances
32
31
  }.freeze
33
32
 
34
33
  class << self
35
34
  def collect_extensions
36
35
  ext = Legion::Extensions
37
- EXTENSION_IVARS.transform_values { |ivar| ext.instance_variable_get(ivar)&.count || 0 }
36
+ {
37
+ loaded: ext.extension_handle_registry.loaded.count,
38
+ running: ext.extension_handle_registry.running.count
39
+ }.merge(EXTENSION_TASK_IVARS.transform_values { |ivar| ext.instance_variable_get(ivar)&.count || 0 })
38
40
  rescue StandardError => e
39
41
  { error: e.message }
40
42
  end
@@ -214,6 +214,11 @@ module Legion
214
214
  end
215
215
 
216
216
  def dispatch_runner(message, runner_cls, function, check_subtask, generate_task)
217
+ unless extension_dispatch_allowed?
218
+ log.warn "[Subscription] rejecting #{lex_name}/#{function}: extension is not accepting new work" if defined?(log)
219
+ return { success: false, status: 'task.blocked', error: { code: 'extension_quiescing' } }
220
+ end
221
+
217
222
  run_block = lambda {
218
223
  ctx = message.merge(runner_class: runner_cls.to_s, function: function.to_s)
219
224
  Legion::Context.with_task_context(ctx) do
@@ -232,6 +237,12 @@ module Legion
232
237
  end
233
238
  end
234
239
 
240
+ def extension_dispatch_allowed?
241
+ return true unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:dispatch_allowed?)
242
+
243
+ Legion::Extensions.dispatch_allowed?(lex_name)
244
+ end
245
+
235
246
  def reject_or_retry(delivery_info, metadata, payload)
236
247
  headers = metadata&.headers || {}
237
248
  retry_count = RetryPolicy.extract_retry_count(headers)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ class Handle
6
+ STATES = %i[registered loaded starting running stopping stopped failed].freeze
7
+ LOADED_STATES = %i[loaded starting running stopping].freeze
8
+ DISPATCHABLE_STATES = %i[loaded starting running].freeze
9
+ RELOAD_STATES = %i[idle pending updating rolling_back failed].freeze
10
+ DISPATCH_BLOCKING_RELOAD_STATES = %i[updating rolling_back].freeze
11
+
12
+ attr_reader :lex_name, :gem_name, :active_version, :state, :reload_state, :hot_reloadable,
13
+ :latest_installed_version, :spec, :gem_dir, :loaded_features, :actors, :routes, :tools, :absorbers,
14
+ :runners, :loaded_at, :last_error
15
+
16
+ def initialize(**attrs)
17
+ lex_name = attrs.fetch(:lex_name)
18
+ spec = attrs[:spec]
19
+ @lex_name = lex_name.to_s
20
+ @gem_name = (attrs[:gem_name] || lex_name).to_s
21
+ @spec = spec
22
+ @active_version = normalize_version(attrs[:active_version] || spec&.version)
23
+ @latest_installed_version = normalize_version(attrs[:latest_installed_version] || attrs[:installed_version] || @active_version)
24
+ @state = normalize_state(attrs.fetch(:state, :registered))
25
+ @reload_state = normalize_reload_state(attrs.fetch(:reload_state, :idle))
26
+ @hot_reloadable = attrs[:hot_reloadable] == true
27
+ @gem_dir = attrs[:gem_dir] || spec&.gem_dir
28
+ @loaded_features = Array(attrs.fetch(:loaded_features, [])).dup.freeze
29
+ @actors = Array(attrs.fetch(:actors, [])).dup.freeze
30
+ @routes = Array(attrs.fetch(:routes, [])).dup.freeze
31
+ @tools = Array(attrs.fetch(:tools, [])).dup.freeze
32
+ @absorbers = Array(attrs.fetch(:absorbers, [])).dup.freeze
33
+ @runners = Array(attrs.fetch(:runners, [])).dup.freeze
34
+ @loaded_at = attrs.fetch(:loaded_at, Time.now)
35
+ @last_error = attrs[:last_error]
36
+ end
37
+
38
+ def loaded?
39
+ LOADED_STATES.include?(state)
40
+ end
41
+
42
+ def running?
43
+ state == :running
44
+ end
45
+
46
+ def pending_reload?
47
+ return false if active_version.nil? || latest_installed_version.nil?
48
+
49
+ latest_installed_version > active_version
50
+ end
51
+
52
+ def dispatchable?
53
+ DISPATCHABLE_STATES.include?(state) && !DISPATCH_BLOCKING_RELOAD_STATES.include?(reload_state)
54
+ end
55
+
56
+ def with(**attrs)
57
+ self.class.new(**to_h, **attrs)
58
+ end
59
+
60
+ def to_h
61
+ {
62
+ lex_name: lex_name,
63
+ gem_name: gem_name,
64
+ active_version: active_version,
65
+ latest_installed_version: latest_installed_version,
66
+ state: state,
67
+ reload_state: reload_state,
68
+ hot_reloadable: hot_reloadable,
69
+ spec: spec,
70
+ gem_dir: gem_dir,
71
+ loaded_features: loaded_features,
72
+ actors: actors,
73
+ routes: routes,
74
+ tools: tools,
75
+ absorbers: absorbers,
76
+ runners: runners,
77
+ loaded_at: loaded_at,
78
+ last_error: last_error
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ def normalize_version(value)
85
+ return nil if value.nil?
86
+ return value if value.is_a?(Gem::Version)
87
+
88
+ Gem::Version.new(value.to_s)
89
+ end
90
+
91
+ def normalize_state(value)
92
+ normalized = value.to_sym
93
+ return normalized if STATES.include?(normalized)
94
+
95
+ raise ArgumentError, "unknown extension state: #{value.inspect}"
96
+ end
97
+
98
+ def normalize_reload_state(value)
99
+ normalized = value.to_sym
100
+ return normalized if RELOAD_STATES.include?(normalized)
101
+
102
+ raise ArgumentError, "unknown extension reload state: #{value.inspect}"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/handle'
4
+
5
+ module Legion
6
+ module Extensions
7
+ class HandleRegistry
8
+ def initialize
9
+ @handles = {}
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def register(lex_name, **attrs)
14
+ key = normalize_name(lex_name)
15
+ @mutex.synchronize do
16
+ current = @handles[key]
17
+ @handles[key] = current ? current.with(**attrs) : Handle.new(lex_name: key, **attrs)
18
+ end
19
+ end
20
+
21
+ def transition(lex_name, state)
22
+ update(lex_name, state: state)
23
+ end
24
+
25
+ def update(lex_name, **attrs)
26
+ key = normalize_name(lex_name)
27
+ @mutex.synchronize do
28
+ current = @handles[key] || Handle.new(lex_name: key)
29
+ @handles[key] = current.with(**attrs)
30
+ end
31
+ end
32
+
33
+ def fetch(lex_name)
34
+ @mutex.synchronize { @handles[normalize_name(lex_name)] }
35
+ end
36
+
37
+ def all
38
+ @mutex.synchronize { @handles.values.dup }
39
+ end
40
+
41
+ def running
42
+ all.select(&:running?)
43
+ end
44
+
45
+ def loaded
46
+ all.select(&:loaded?)
47
+ end
48
+
49
+ def dispatch_allowed?(lex_name)
50
+ handle = fetch(lex_name)
51
+ return true unless handle
52
+
53
+ handle.dispatchable?
54
+ end
55
+
56
+ def delete(lex_name)
57
+ @mutex.synchronize { @handles.delete(normalize_name(lex_name)) }
58
+ end
59
+
60
+ def reset!
61
+ @mutex.synchronize { @handles.clear }
62
+ end
63
+
64
+ private
65
+
66
+ def normalize_name(lex_name)
67
+ lex_name.to_s
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/extensions/core'
4
4
  require 'legion/extensions/catalog'
5
+ require 'legion/extensions/handle_registry'
5
6
  require 'legion/extensions/permissions'
6
7
  require 'legion/runner'
7
8
 
@@ -22,6 +23,7 @@ module Legion
22
23
  @actors = []
23
24
  @running_instances = Concurrent::Array.new
24
25
  @loaded_extensions = []
26
+ reset_runtime_handles!
25
27
  @pending_registrations = Concurrent::Array.new
26
28
 
27
29
  find_extensions
@@ -33,7 +35,7 @@ module Legion
33
35
  hook_phase_actors(phase_num)
34
36
  end
35
37
 
36
- @loaded_extensions&.each { |name| Catalog.transition(name, :running) }
38
+ transition_loaded_extensions(:running)
37
39
  Catalog.flush_persisted_transitions
38
40
 
39
41
  load_yaml_agents
@@ -47,7 +49,7 @@ module Legion
47
49
  deadline = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
48
50
  shutdown_start = Time.now
49
51
 
50
- @loaded_extensions.each { |name| Catalog.transition(name, :stopping) }
52
+ transition_loaded_extensions(:stopping)
51
53
 
52
54
  if @subscription_pool
53
55
  @subscription_pool.shutdown
@@ -100,10 +102,7 @@ module Legion
100
102
 
101
103
  Legion::Dispatch.shutdown if defined?(Legion::Dispatch) && Legion::Dispatch.instance_variable_get(:@dispatcher)
102
104
 
103
- @loaded_extensions.each do |name|
104
- Catalog.transition(name, :stopped)
105
- unregister_capabilities(name)
106
- end
105
+ transition_loaded_extensions(:stopped) { |name| unregister_capabilities(name) }
107
106
  Legion::Logging.info "Successfully shut down all actors (#{(Time.now - shutdown_start).round(1)}s)"
108
107
  end
109
108
 
@@ -146,6 +145,8 @@ module Legion
146
145
  end
147
146
 
148
147
  Catalog.register(gem_name)
148
+ register_extension_handle(gem_name, state: :registered,
149
+ latest_installed_version: latest_installed_version(gem_name))
149
150
  entry
150
151
  end
151
152
 
@@ -211,9 +212,11 @@ module Legion
211
212
  results.each_with_index do |result, idx|
212
213
  if result
213
214
  Catalog.transition(result[:gem_name], :loaded)
215
+ transition_extension_handle(result[:gem_name], :loaded)
214
216
  register_in_registry(gem_name: result[:gem_name], version: result[:version])
215
217
  @loaded_extensions.push(result[:gem_name])
216
218
  else
219
+ transition_extension_handle(eligible[idx][:gem_name], :failed)
217
220
  Legion::Logging.warn("#{eligible[idx][:gem_name]} failed to load")
218
221
  end
219
222
  end
@@ -275,14 +278,6 @@ module Legion
275
278
  has_logger = extension.respond_to?(:log)
276
279
  extension.autobuild
277
280
 
278
- require 'legion/transport/messages/lex_register'
279
- registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
280
- if @pending_registrations
281
- @pending_registrations << registration
282
- else
283
- registration.publish
284
- end
285
-
286
281
  register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners)
287
282
  write_lex_cli_manifest(entry, extension)
288
283
  register_absorber_capabilities(entry[:gem_name], extension.absorbers) if extension.respond_to?(:absorbers)
@@ -301,6 +296,14 @@ module Legion
301
296
  extension.log.info "Loaded v#{extension::VERSION}"
302
297
  Legion::Events.emit('extension.loaded', name: ext_name, version: entry[:gem_name])
303
298
 
299
+ require 'legion/transport/messages/lex_register'
300
+ registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
301
+ if @pending_registrations
302
+ @pending_registrations << registration
303
+ else
304
+ registration.publish
305
+ end
306
+
304
307
  begin
305
308
  if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
306
309
  worker_id = "lex-#{ext_name}"
@@ -320,6 +323,8 @@ module Legion
320
323
  Legion::Logging.debug "Extensions#load_extension failed to register digital worker for #{ext_name}: #{e.message}" if defined?(Legion::Logging)
321
324
  nil
322
325
  end
326
+ register_extension_handle(entry[:gem_name], spec: entry[:spec], state: :loaded, loaded_at: Time.now,
327
+ latest_installed_version: latest_installed_version(entry[:gem_name]))
323
328
  true
324
329
  rescue StandardError => e
325
330
  Legion::Logging.log_exception(e, lex: entry[:gem_name], component_type: :boot)
@@ -599,9 +604,12 @@ module Legion
599
604
  public
600
605
 
601
606
  def loaded_extension_modules
607
+ handles = extension_handles
608
+ active_names = handles.select(&:dispatchable?).map(&:lex_name)
602
609
  constants(false).filter_map do |const_name|
603
610
  mod = const_get(const_name, false)
604
611
  next nil unless mod.is_a?(Module) && mod.respond_to?(:runner_modules)
612
+ next nil if handles.any? && !active_names.include?(module_lex_name(mod))
605
613
 
606
614
  mod
607
615
  rescue StandardError => e
@@ -611,7 +619,11 @@ module Legion
611
619
  end
612
620
 
613
621
  # Legacy capability registration - now handled by Tools::Discovery
614
- def unregister_capabilities(_gem_name); end
622
+ def unregister_capabilities(gem_name)
623
+ return unless defined?(Legion::Tools::Registry) && Legion::Tools::Registry.respond_to?(:unregister_extension)
624
+
625
+ Legion::Tools::Registry.unregister_extension(gem_name)
626
+ end
615
627
 
616
628
  def register_absorber_capabilities(_gem_name, _absorbers); end
617
629
 
@@ -620,7 +632,10 @@ module Legion
620
632
  def gem_load(entry)
621
633
  gem_name = entry[:gem_name]
622
634
  require_path = entry[:require_path]
623
- gem_dir = Gem::Specification.find_by_name(gem_name).gem_dir
635
+ spec = Gem::Specification.find_by_name(gem_name)
636
+ gem_dir = spec.gem_dir
637
+ entry[:spec] = spec
638
+ entry[:version] = spec.version.to_s
624
639
  require "#{gem_dir}/lib/#{require_path}"
625
640
  true
626
641
  rescue Gem::MissingSpecError => e
@@ -762,6 +777,82 @@ module Legion
762
777
  @extensions
763
778
  end
764
779
 
780
+ def loaded_extensions
781
+ extension_handle_registry.loaded.map(&:lex_name)
782
+ end
783
+
784
+ def extension_handles
785
+ extension_handle_registry.all
786
+ end
787
+
788
+ def extension_handle(name)
789
+ extension_handle_registry.fetch(name)
790
+ end
791
+
792
+ def register_extension_handle(name, **attrs)
793
+ extension_handle_registry.register(name, **attrs)
794
+ end
795
+
796
+ def transition_extension_handle(name, state)
797
+ extension_handle_registry.transition(name, state)
798
+ end
799
+
800
+ def update_extension_handle(name, **attrs)
801
+ extension_handle_registry.update(name, **attrs)
802
+ end
803
+
804
+ def reset_runtime_handles!
805
+ extension_handle_registry.reset!
806
+ end
807
+
808
+ def dispatch_allowed?(lex_name)
809
+ extension_handle_registry.dispatch_allowed?(normalize_lex_name(lex_name))
810
+ end
811
+
812
+ def dispatch_allowed_for_runner?(runner_class)
813
+ lex_name = lex_name_for_runner_class(runner_class)
814
+ return true unless lex_name
815
+
816
+ dispatch_allowed?(lex_name)
817
+ end
818
+
819
+ def record_extension_resource(lex_name, resource_type, value)
820
+ handle = extension_handle(lex_name) || register_extension_handle(normalize_lex_name(lex_name))
821
+ values = Array(handle.public_send(resource_type))
822
+ return handle if values.include?(value)
823
+
824
+ update_extension_handle(handle.lex_name, resource_type => values + [value])
825
+ end
826
+
827
+ def reload_extension(name)
828
+ gem_name = normalize_lex_name(name)
829
+ update_extension_handle(gem_name, reload_state: :updating)
830
+ unregister_capabilities(gem_name)
831
+ reset_runner_cache
832
+
833
+ entry = @extensions&.find { |candidate| candidate[:gem_name] == gem_name }
834
+ raise "#{gem_name} failed to reload" if entry && !load_extension(entry)
835
+
836
+ update_extension_handle(gem_name, state: :running, reload_state: :idle, last_error: nil,
837
+ latest_installed_version: latest_installed_version(gem_name))
838
+ true
839
+ rescue StandardError => e
840
+ update_extension_handle(gem_name, reload_state: :failed, last_error: e.message)
841
+ raise
842
+ end
843
+
844
+ def extension_handle_registry
845
+ @extension_handle_registry ||= HandleRegistry.new
846
+ end
847
+
848
+ def transition_loaded_extensions(state)
849
+ @loaded_extensions&.each do |name|
850
+ Catalog.transition(name, state)
851
+ transition_extension_handle(name, state)
852
+ yield name if block_given?
853
+ end
854
+ end
855
+
765
856
  def load_yaml_agents
766
857
  @load_yaml_agents ||= begin
767
858
  require 'legion/settings/agent_loader'
@@ -777,6 +868,57 @@ module Legion
777
868
 
778
869
  private
779
870
 
871
+ def latest_installed_version(gem_name)
872
+ Gem::Specification.find_all_by_name(gem_name).map(&:version).max
873
+ rescue StandardError
874
+ nil
875
+ end
876
+
877
+ def reset_runner_cache
878
+ return unless defined?(Legion::Ingress) && Legion::Ingress.respond_to?(:reset_runner_cache!)
879
+
880
+ Legion::Ingress.reset_runner_cache!
881
+ end
882
+
883
+ def normalize_lex_name(name)
884
+ str = name.to_s
885
+ str.start_with?('lex-') ? str : "lex-#{str.tr('.', '-').tr('_', '-')}"
886
+ end
887
+
888
+ def module_lex_name(mod)
889
+ parts = mod.name.to_s.split('::')
890
+ idx = parts.index('Extensions')
891
+ return nil unless idx
892
+
893
+ extension_parts = extension_parts_from_const(parts, idx)
894
+ return nil if extension_parts.empty?
895
+
896
+ "lex-#{extension_parts.join('-')}"
897
+ end
898
+
899
+ def lex_name_for_runner_class(runner_class)
900
+ parts = runner_class.to_s.split('::')
901
+ idx = parts.index('Extensions')
902
+ return nil unless idx
903
+
904
+ extension_parts = extension_parts_from_const(parts, idx)
905
+ return nil if extension_parts.empty?
906
+
907
+ "lex-#{extension_parts.join('-')}"
908
+ end
909
+
910
+ def extension_parts_from_const(parts, idx)
911
+ parts[(idx + 1)..].to_a.each_with_object([]) do |part, extension_parts|
912
+ break extension_parts if %w[Actor Actors Runners Helpers Transport Data Hooks Skills].include?(part)
913
+
914
+ extension_parts << camel_to_snake(part).tr('_', '-')
915
+ end
916
+ end
917
+
918
+ def camel_to_snake(value)
919
+ value.to_s.gsub(/(?<!^)[A-Z]/) { "_#{Regexp.last_match(0)}" }.downcase
920
+ end
921
+
780
922
  def default_agents_directory
781
923
  custom = Legion::Settings.dig(:agents, :directory)
782
924
  return custom if custom && Dir.exist?(custom)
@@ -64,6 +64,14 @@ module Legion
64
64
  fn_str = fn.to_s
65
65
  raise InvalidFunction, "invalid function format: #{fn_str}" unless fn_str.match?(FUNCTION_PATTERN)
66
66
 
67
+ unless extension_dispatch_allowed?(rc)
68
+ return {
69
+ success: false,
70
+ status: 'task.blocked',
71
+ error: { code: 'extension_quiescing', message: "extension for #{rc} is not accepting new work" }
72
+ }
73
+ end
74
+
67
75
  # RAI invariant #2: registration precedes permission
68
76
  if defined?(Legion::DigitalWorker::Registry) && message[:worker_id]
69
77
  Legion::DigitalWorker::Registry.validate_execution!(
@@ -138,8 +146,18 @@ module Legion
138
146
  false
139
147
  end
140
148
 
149
+ def reset_runner_cache!
150
+ @registered_runner_modules = nil
151
+ end
152
+
141
153
  private
142
154
 
155
+ def extension_dispatch_allowed?(runner_class)
156
+ return true unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:dispatch_allowed_for_runner?)
157
+
158
+ Legion::Extensions.dispatch_allowed_for_runner?(runner_class)
159
+ end
160
+
143
161
  def resolve_runner_class(runner_class)
144
162
  return runner_class unless runner_class.is_a?(String)
145
163
 
@@ -169,10 +187,6 @@ module Legion
169
187
  @registered_runner_modules = modules
170
188
  end
171
189
 
172
- def reset_runner_cache!
173
- @registered_runner_modules = nil
174
- end
175
-
176
190
  def parse_payload(payload)
177
191
  case payload
178
192
  when Hash
@@ -123,7 +123,9 @@ module Legion
123
123
  ext: ext, runner_mod: runner_mod, func_name: func_name,
124
124
  meta: meta, defn: defn, deferred: is_deferred
125
125
  )
126
- Legion::Tools::Registry.register(tool_class)
126
+ return unless Legion::Tools::Registry.register(tool_class)
127
+
128
+ record_tool_owner(ext, tool_class)
127
129
  end
128
130
 
129
131
  def resolve_mcp_tools_enabled(ext, runner_mod)
@@ -214,6 +216,15 @@ module Legion
214
216
  end
215
217
  end
216
218
 
219
+ def record_tool_owner(ext, tool_class)
220
+ return unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:record_extension_resource)
221
+
222
+ ext_name = derive_extension_name(ext)
223
+ Legion::Extensions.record_extension_resource("lex-#{ext_name.tr('_', '-')}", :tools, tool_class.tool_name)
224
+ rescue StandardError => e
225
+ handle_exception(e, level: :warn, handled: true, operation: :record_tool_owner)
226
+ end
227
+
217
228
  def merge_trigger_words(ext, runner_mod)
218
229
  ext_words = ext.respond_to?(:trigger_words) ? Array(ext.trigger_words) : []
219
230
 
@@ -56,7 +56,8 @@ module Legion
56
56
  end
57
57
 
58
58
  def for_extension(ext_name)
59
- all_tools.select { |t| t.respond_to?(:extension) && t.extension == ext_name }
59
+ normalized = normalize_extension(ext_name)
60
+ all_tools.select { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized }
60
61
  end
61
62
 
62
63
  def for_runner(runner_name)
@@ -73,6 +74,22 @@ module Legion
73
74
  @deferred.clear
74
75
  end
75
76
  end
77
+
78
+ def unregister_extension(ext_name)
79
+ normalized = normalize_extension(ext_name)
80
+ @mutex.synchronize do
81
+ before = @always.size + @deferred.size
82
+ @always.reject! { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized }
83
+ @deferred.reject! { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized }
84
+ before - (@always.size + @deferred.size)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_extension(ext_name)
91
+ ext_name.to_s.delete_prefix('lex-').tr('-', '_')
92
+ end
76
93
  end
77
94
  end
78
95
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.9.0'
4
+ VERSION = '1.9.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0
4
+ version: 1.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -830,6 +830,8 @@ files:
830
830
  - lib/legion/extensions/data/model.rb
831
831
  - lib/legion/extensions/definitions.rb
832
832
  - lib/legion/extensions/gem_source.rb
833
+ - lib/legion/extensions/handle.rb
834
+ - lib/legion/extensions/handle_registry.rb
833
835
  - lib/legion/extensions/helpers/base.rb
834
836
  - lib/legion/extensions/helpers/cache.rb
835
837
  - lib/legion/extensions/helpers/core.rb