rubik 0.4.1 → 0.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +118 -1
  4. data/lib/rubik/ast/cache_store.rb +69 -5
  5. data/lib/rubik/ast/extraction_result.rb +6 -0
  6. data/lib/rubik/ast/graph.rb +32 -5
  7. data/lib/rubik/ast/ruby_extractor.rb +8 -2
  8. data/lib/rubik/buffer/apply_contract.rb +6 -0
  9. data/lib/rubik/buffer/manager.rb +37 -27
  10. data/lib/rubik/config/defaults.rb +7 -1
  11. data/lib/rubik/mcp/client.rb +4 -0
  12. data/lib/rubik/mcp/manager.rb +51 -35
  13. data/lib/rubik/mcp/proxy_tool.rb +17 -0
  14. data/lib/rubik/mcp/transport/stdio.rb +142 -17
  15. data/lib/rubik/orchestrator/budget_manager.rb +2 -0
  16. data/lib/rubik/orchestrator/loop.rb +1 -0
  17. data/lib/rubik/orchestrator/shell_deps.rb +4 -0
  18. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -0
  19. data/lib/rubik/plugin/manager.rb +39 -15
  20. data/lib/rubik/plugin/package_inventory.rb +11 -1
  21. data/lib/rubik/plugin/storage.rb +5 -4
  22. data/lib/rubik/prompt/doctrine.rb +10 -15
  23. data/lib/rubik/prompt/tool_teachings.rb +29 -35
  24. data/lib/rubik/repair/loop.rb +29 -15
  25. data/lib/rubik/repl/commands/agent_context.rb +1 -0
  26. data/lib/rubik/repl/commands/system.rb +2 -0
  27. data/lib/rubik/repl/turn_recorder.rb +3 -0
  28. data/lib/rubik/security/redactor.rb +13 -9
  29. data/lib/rubik/storage/vakanuvis/gate.rb +13 -8
  30. data/lib/rubik/storage/vakanuvis/harness.rb +115 -112
  31. data/lib/rubik/storage/vakanuvis/orientation.rb +277 -0
  32. data/lib/rubik/storage/vakanuvis/service.rb +5 -1
  33. data/lib/rubik/storage/vakanuvis/vocabulary.rb +33 -2
  34. data/lib/rubik/storage/vakanuvis.rb +8 -1
  35. data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
  36. data/lib/rubik/tools/builtins/vakanuvis.rb +13 -15
  37. data/lib/rubik/ui/capability.rb +19 -5
  38. data/lib/rubik/version.rb +1 -1
  39. metadata +2 -1
@@ -51,6 +51,7 @@ module Rubik
51
51
  # boot epoch that owns them (@boot_marks).
52
52
  @stopped = false
53
53
  @boot_thread = nil
54
+ @boot_slot = nil
54
55
  @boot_marks = {}
55
56
  @monitor = Monitor.new
56
57
  end
@@ -68,18 +69,13 @@ module Rubik
68
69
  name_str = name.to_s
69
70
  parsed_defn = defn.is_a?(Hash) ? defn.transform_keys(&:to_s) : {}
70
71
  persist_res = { ok: true }
71
- tools_count = 0
72
72
 
73
73
  @monitor.synchronize do
74
74
  @servers[name_str] = parsed_defn
75
75
  @statuses[name_str] ||= :disabled
76
76
 
77
77
  if @config && persist && @config.persisting?(persist)
78
- server_hash = {
79
- "command" => parsed_defn["command"],
80
- "args" => Array(parsed_defn["args"]),
81
- "env" => parsed_defn["env"] || {}
82
- }
78
+ server_hash = { "command" => parsed_defn["command"], "args" => Array(parsed_defn["args"]), "env" => parsed_defn["env"] || {} }
83
79
  # Per-server handshake override round-trips only when present,
84
80
  # so `!mcp add` output stays minimal (the knob is hand-written
85
81
  # config, not a CLI flag).
@@ -87,14 +83,12 @@ module Rubik
87
83
  persist_res = @config.set("mcp.servers.#{name_str}", server_hash, persist: persist)
88
84
  end
89
85
 
90
- if auto_start && enabled?
91
- enable_server(name_str)
92
- else
93
- transition_server!(name_str, :disabled)
94
- end
95
- tools_count = (@tools[name_str] || []).size
86
+ transition_server!(name_str, :disabled) unless auto_start && enabled?
96
87
  end
97
88
 
89
+ enable_server(name_str) if auto_start && enabled?
90
+
91
+ tools_count = @monitor.synchronize { (@tools[name_str] || []).size }
98
92
  persist_res.merge(tools: tools_count)
99
93
  end
100
94
 
@@ -219,30 +213,42 @@ module Rubik
219
213
  # Post-wiring failures stopped the client via the ctx drain above;
220
214
  # pre-wiring failures (stale-context dispose, ctx.extend) reach
221
215
  # only this line — idempotent Client#stop makes it exactly-once.
222
- begin
223
- client.stop
224
- rescue StandardError
225
- nil
226
- end
216
+ safely_stop_rolled_back_client(client, name_str)
227
217
  raise e
228
218
  end
229
219
  end
230
220
 
231
221
  def enable_server(name)
232
222
  name_str = name.to_s
223
+ server_defn = nil
224
+ epoch = nil
233
225
  @monitor.synchronize do
234
226
  server_defn = @servers[name_str]
235
227
  raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless server_defn
236
228
 
237
229
  return @clients[name_str] if @statuses[name_str] == :active && @clients[name_str]&.running?
238
230
 
239
- connected = begin
240
- connect_server(name_str, server_defn)
241
- rescue StandardError
242
- transition_server!(name_str, :failed)
243
- raise
231
+ epoch = Object.new
232
+ transition_server!(name_str, :connecting)
233
+ @boot_marks[name_str] = epoch
234
+ end
235
+
236
+ connected = begin
237
+ connect_server(name_str, server_defn)
238
+ rescue StandardError => e
239
+ @monitor.synchronize { transition_server!(name_str, :failed, epoch: epoch) }
240
+ raise e
241
+ end
242
+
243
+ @monitor.synchronize do
244
+ if @stopped || @statuses[name_str] != :connecting || !@boot_marks[name_str].equal?(epoch)
245
+ safely_stop_rolled_back_client(connected[:client], name_str)
246
+ return @clients[name_str]
244
247
  end
248
+
245
249
  apply_connected_server(name_str, server_defn, **connected)
250
+ transition_server!(name_str, :active, epoch: epoch)
251
+ @clients[name_str]
246
252
  end
247
253
  end
248
254
 
@@ -301,18 +307,29 @@ module Rubik
301
307
  end
302
308
 
303
309
  join_boot_safely(2.0)
310
+ epoch = Object.new
304
311
  @monitor.synchronize { @stopped = false }
305
312
  thread = Thread.new do
306
313
  perform_load_from_config(cfg)
307
314
  rescue StandardError, Interrupt => e
308
315
  Rubik::Log.warn(:mcp, "background MCP boot failed (#{e.class}: #{e.message})")
309
316
  ensure
310
- # Only clear OUR reference: a wedged boot A expiring past the drain
311
- # bound must not clobber the handle to a newer boot B's thread.
312
- @monitor.synchronize { @boot_thread = nil if @boot_thread.equal?(Thread.current) }
317
+ # Epoch-tagged clear: only clear OUR reference so a wedged boot A
318
+ # expiring past the drain bound cannot clobber a newer boot B's slot.
319
+ @monitor.synchronize do
320
+ @boot_slot = @boot_thread = nil if @boot_slot && @boot_slot[:epoch].equal?(epoch)
321
+ end
313
322
  end
314
323
  thread.name = "rubik-mcp-boot" if thread.respond_to?(:name=)
315
- @boot_thread = thread
324
+ @monitor.synchronize do
325
+ if thread.alive?
326
+ @boot_slot = { thread: thread, epoch: epoch }
327
+ @boot_thread = thread
328
+ elsif @boot_slot && @boot_slot[:epoch].equal?(epoch)
329
+ @boot_slot = @boot_thread = nil
330
+ end
331
+ end
332
+ thread
316
333
  end
317
334
 
318
335
  private
@@ -509,7 +526,6 @@ module Rubik
509
526
  disable_server(name)
510
527
  rescue StandardError => e
511
528
  Rubik::Log.warn(:mcp, "failed to stop MCP server '#{name}' during teardown (#{e.class}: #{e.message})")
512
- nil
513
529
  end
514
530
  end
515
531
  end
@@ -517,11 +533,7 @@ module Rubik
517
533
  alias dispose stop_all
518
534
 
519
535
  def status_summary
520
- @monitor.synchronize do
521
- @servers.map do |name, _defn|
522
- server_status(name)
523
- end.compact
524
- end
536
+ @monitor.synchronize { @servers.keys.map { |name| server_status(name) }.compact }
525
537
  end
526
538
 
527
539
  def server_status(name)
@@ -602,9 +614,7 @@ module Rubik
602
614
  # falls through to the next source — a miswritten knob must never fail
603
615
  # the boot at the config boundary.
604
616
  def resolve_connect_timeout(server_defn, explicit)
605
- sources = [explicit, server_defn["timeout"] || server_defn[:timeout]]
606
- cfg_timeout = @config&.fetch("mcp.timeout", nil)
607
- sources << cfg_timeout unless cfg_timeout.nil?
617
+ sources = [explicit, server_defn["timeout"] || server_defn[:timeout], @config&.fetch("mcp.timeout", nil)]
608
618
 
609
619
  sources.each do |candidate|
610
620
  return candidate if candidate.is_a?(Numeric) && candidate.positive?
@@ -614,6 +624,12 @@ module Rubik
614
624
  end
615
625
  Client::DEFAULT_TIMEOUT
616
626
  end
627
+
628
+ def safely_stop_rolled_back_client(client, name)
629
+ client&.stop
630
+ rescue StandardError => e
631
+ Rubik::Log.warn(:mcp, "failed to stop rolled-back MCP client '#{name}' (#{e.class}: #{e.message})")
632
+ end
617
633
  end
618
634
  end
619
635
  end
@@ -91,6 +91,23 @@ module Rubik
91
91
  output: output_text
92
92
  )
93
93
  end
94
+ rescue ConnectionError, TimeoutError => e
95
+ tail = if @client.respond_to?(:stderr_tail)
96
+ @client.stderr_tail
97
+ elsif @client.respond_to?(:transport) && @client.transport.respond_to?(:stderr_tail)
98
+ @client.transport.stderr_tail
99
+ else
100
+ []
101
+ end
102
+ tail_lines = Array(tail).last(3)
103
+ stderr_detail = tail_lines.empty? ? "" : "; server stderr: #{tail_lines.join(' | ')}"
104
+ err_msg = "MCP server '#{@server_name}' is unreachable (#{e.class}: #{e.message}); its proxy tools are unavailable until reconnect or `!mcp` re-add#{stderr_detail}"
105
+ Rubik::Tools::Result.new(
106
+ tool_name: @name,
107
+ status: :error,
108
+ output: err_msg,
109
+ error: err_msg
110
+ )
94
111
  rescue StandardError => e
95
112
  Rubik::Tools::Result.new(
96
113
  tool_name: @name,
@@ -11,6 +11,11 @@ module Rubik
11
11
  module MCP
12
12
  module Transport
13
13
  class Stdio
14
+ # The child's stderr pipe is drained continuously (see
15
+ # #start_stderr_thread); this many recent lines stay queryable via
16
+ # #stderr_tail for diagnostics.
17
+ STDERR_TAIL_LINES = 20
18
+
14
19
  attr_reader :command, :args, :env, :root_dir, :running, :wait_thr
15
20
 
16
21
  def initialize(command, args: [], env: {}, root_dir: Dir.pwd, guard: nil)
@@ -26,14 +31,25 @@ module Rubik
26
31
  @stderr = nil
27
32
  @wait_thr = nil
28
33
  @reader_thr = nil
34
+ @stderr_thr = nil
35
+ @stderr_mutex = Mutex.new
36
+ @stderr_tail = []
29
37
  @mutex = Mutex.new
30
38
  @cv = ConditionVariable.new
31
39
  @responses = {}
32
40
  @closed = false
41
+ @generation = 0
42
+ @pending_requests = Set.new
33
43
  end
34
44
 
35
45
  def open
36
- return if @running
46
+ return if running?
47
+
48
+ # A previous incarnation whose child died on its own (the reader's
49
+ # ensure cleared @running) still holds pipe FDs and an unreaped
50
+ # wait thread; re-spawning over them leaks both. close is
51
+ # idempotent and a no-op when nothing lingers.
52
+ close if @wait_thr || @reader_thr || @stderr_thr
37
53
 
38
54
  cmd_parts = if @args.empty? && @command.is_a?(String)
39
55
  begin
@@ -78,21 +94,29 @@ module Rubik
78
94
  raise ConnectionError, "Failed to spawn MCP process '#{bin}': #{e.message}"
79
95
  end
80
96
 
81
- @running = true
82
- @closed = false
83
- @req_id = 0
84
- @responses.clear
97
+ @mutex.synchronize do
98
+ @running = true
99
+ @closed = false
100
+ @generation += 1
101
+ @req_id = 0
102
+ @responses.clear
103
+ @pending_requests.clear
104
+ end
105
+ @stderr_mutex.synchronize { @stderr_tail.clear }
85
106
 
107
+ start_stderr_thread
86
108
  start_reader_thread
87
109
  self
88
110
  end
89
111
  alias start open
90
112
 
91
113
  def send_request(method, params = {}, timeout: 10)
92
- raise ConnectionError, "MCP transport is not running" unless @running && @stdin
114
+ raise ConnectionError, "MCP transport is not running" unless running? && @stdin
93
115
 
94
116
  req_id = @mutex.synchronize do
95
117
  @req_id += 1
118
+ @pending_requests.add(@req_id)
119
+ @pending_requests.add(@req_id.to_s)
96
120
  @req_id
97
121
  end
98
122
 
@@ -103,8 +127,17 @@ module Rubik
103
127
  "params" => params || {}
104
128
  }
105
129
 
106
- write_message(msg)
107
- wait_for_response(req_id, timeout: timeout)
130
+ begin
131
+ write_message(msg)
132
+ wait_for_response(req_id, timeout: timeout)
133
+ ensure
134
+ @mutex.synchronize do
135
+ @pending_requests.delete(req_id)
136
+ @pending_requests.delete(req_id.to_s)
137
+ @responses.delete(req_id)
138
+ @responses.delete(req_id.to_s)
139
+ end
140
+ end
108
141
  end
109
142
 
110
143
  def send_notification(method, params = {})
@@ -120,10 +153,14 @@ module Rubik
120
153
  end
121
154
 
122
155
  def close
123
- return if @closed
156
+ child_died = false
157
+ @mutex.synchronize do
158
+ return if @closed
124
159
 
125
- @closed = true
126
- @running = false
160
+ child_died = !@running
161
+ @closed = true
162
+ @running = false
163
+ end
127
164
 
128
165
  begin
129
166
  @reader_thr&.kill
@@ -131,13 +168,32 @@ module Rubik
131
168
  nil
132
169
  end
133
170
 
171
+ begin
172
+ @stderr_thr&.kill
173
+ rescue StandardError
174
+ nil
175
+ end
176
+
134
177
  if @wait_thr&.alive?
135
- Rubik::Platform.kill_tree(@wait_thr.pid)
178
+ pid_to_kill = @wait_thr.pid
179
+ Rubik::Platform.kill_tree(pid_to_kill)
136
180
  begin
137
181
  @wait_thr.join(0.5)
138
182
  rescue StandardError
139
183
  nil
140
184
  end
185
+
186
+ if @wait_thr.alive?
187
+ abandoned_waiter = @wait_thr
188
+ Thread.new do
189
+ begin
190
+ abandoned_waiter.join(2.5)
191
+ rescue StandardError
192
+ nil
193
+ end
194
+ Rubik::Log.warn(:mcp, "abandoned child process pid #{pid_to_kill} did not exit within reaper window") if abandoned_waiter.alive?
195
+ end
196
+ end
141
197
  end
142
198
  @wait_thr = nil
143
199
 
@@ -155,8 +211,22 @@ module Rubik
155
211
  @reader_thr = nil
156
212
  end
157
213
 
214
+ begin
215
+ @stderr_thr&.join(0.5)
216
+ rescue StandardError
217
+ nil
218
+ ensure
219
+ @stderr_thr = nil
220
+ end
221
+
222
+ if child_died
223
+ hint = stderr_tail.last(5).join(" | ")[0, 512]
224
+ Rubik::Log.warn(:mcp, "MCP child process (pid #{pid}) had already exited; recent stderr: #{hint}") unless hint.empty?
225
+ end
226
+
158
227
  @mutex.synchronize do
159
228
  @responses.clear
229
+ @pending_requests.clear
160
230
  @cv.broadcast
161
231
  end
162
232
  end
@@ -164,7 +234,17 @@ module Rubik
164
234
  alias dispose close
165
235
 
166
236
  def running?
167
- @running && (@wait_thr.nil? || @wait_thr.alive?)
237
+ @mutex.synchronize do
238
+ @running && (@wait_thr.nil? || @wait_thr.alive?)
239
+ end
240
+ end
241
+
242
+ # Recent stderr output from the child (last STDERR_TAIL_LINES lines).
243
+ # The drain keeps the OS pipe buffer empty so a chatty server can
244
+ # never block on stderr write; the tail is what makes that output
245
+ # diagnosable instead of silently discarded.
246
+ def stderr_tail
247
+ @stderr_mutex.synchronize { @stderr_tail.dup }
168
248
  end
169
249
 
170
250
  def pid
@@ -198,7 +278,12 @@ module Rubik
198
278
  remaining = timeout - elapsed
199
279
  if remaining <= 0
200
280
  @responses.delete(req_id)
201
- raise TimeoutError, "Timed out waiting for MCP response to request #{req_id} after #{timeout}s"
281
+ # The stderr tail is the one place a wedged server explains
282
+ # itself (crash traces, logging storms); attach it so the
283
+ # timeout is diagnosable instead of opaque.
284
+ hint = stderr_tail.last(3).join(" | ")[0, 512]
285
+ suffix = hint.empty? ? "" : "; server stderr: #{hint}"
286
+ raise TimeoutError, "Timed out waiting for MCP response to request #{req_id} after #{timeout}s#{suffix}"
202
287
  end
203
288
 
204
289
  @cv.wait(@mutex, remaining)
@@ -217,8 +302,9 @@ module Rubik
217
302
  end
218
303
 
219
304
  def start_reader_thread
305
+ gen = @mutex.synchronize { @generation }
220
306
  @reader_thr = Thread.new do
221
- while @running && @stdout && !@stdout.closed?
307
+ while @mutex.synchronize { @running } && @stdout && !@stdout.closed?
222
308
  line = begin
223
309
  @stdout.gets
224
310
  rescue IOError, Errno::EBADF
@@ -238,9 +324,46 @@ module Rubik
238
324
  end
239
325
  ensure
240
326
  @mutex.synchronize do
241
- @running = false
242
- @cv.broadcast
327
+ if @generation == gen
328
+ @running = false
329
+ @cv.broadcast
330
+ end
331
+ end
332
+ end
333
+ end
334
+
335
+ # Drain the child's stderr on its own thread. Without a reader, a
336
+ # server that logs more than the OS pipe buffer holds (~64KB on
337
+ # Linux, far less on Windows) blocks inside its write() and stops
338
+ # answering requests — every call then times out with no hint that
339
+ # the child is wedged on its own logging. EOF on stderr must NOT
340
+ # stop the transport: a child may close stderr and keep serving.
341
+ def start_stderr_thread
342
+ @stderr_thr = Thread.new do
343
+ while @running && @stderr && !@stderr.closed?
344
+ line = begin
345
+ @stderr.gets
346
+ rescue IOError, Errno::EBADF
347
+ nil
348
+ end
349
+ break unless line
350
+
351
+ line = line.chomp
352
+ # Cap retained size per line: a newline-less flood must not
353
+ # turn the diagnostic tail into a memory hog.
354
+ line = line[0, 4096] if line.length > 4096
355
+
356
+ @stderr_mutex.synchronize do
357
+ @stderr_tail << line
358
+ @stderr_tail.shift while @stderr_tail.length > STDERR_TAIL_LINES
359
+ end
243
360
  end
361
+ rescue IOError, Errno::EBADF
362
+ nil
363
+ rescue StandardError => e
364
+ # A silently dead drainer would recreate the exact wedge this
365
+ # thread exists to prevent — make its death greppable.
366
+ Rubik::Log.warn(:mcp, "stderr drain thread exited unexpectedly (#{e.class}: #{e.message}); the MCP child may block on a full stderr pipe")
244
367
  end
245
368
  end
246
369
 
@@ -248,6 +371,8 @@ module Rubik
248
371
  return unless msg.is_a?(Hash) && msg.key?("id")
249
372
 
250
373
  @mutex.synchronize do
374
+ return unless @pending_requests.include?(msg["id"])
375
+
251
376
  @responses[msg["id"]] = msg
252
377
  @cv.broadcast
253
378
  end
@@ -57,6 +57,8 @@ module Rubik
57
57
  max_output_tokens: @deps.max_output_tokens, config: context_cfg,
58
58
  absorb: GoalLedger::ObservationProjection.directive_for(deps: @deps)
59
59
  ))
60
+ manager = @deps.runtime_manager if @deps.respond_to?(:runtime_manager)
61
+ Rubik::Storage::Vakanuvis::Orientation.inject_after_compaction!(@deps.history, manager, config: @deps.config)
60
62
  end
61
63
 
62
64
  def track_token_usage!(res)
@@ -177,6 +177,7 @@ module Rubik
177
177
  end
178
178
  end
179
179
 
180
+ Rubik::Storage::Vakanuvis::Orientation.inject_session_start!(@history, @runtime_manager, config: @config)
180
181
  @history << { role: "user", content: formatted_task }
181
182
 
182
183
  execute_steps(opts: opts, plan_only: plan_only)
@@ -71,6 +71,10 @@ module Rubik
71
71
  @loop.emergency_compact_and_notify!(reason)
72
72
  end
73
73
 
74
+ def runtime_manager
75
+ @loop.runtime_manager if @loop.respond_to?(:runtime_manager)
76
+ end
77
+
74
78
  def respond_to_missing?(name, include_private = false)
75
79
  (SURFACE.include?(name) && @loop.respond_to?(name, include_private)) || super
76
80
  end
@@ -103,6 +103,8 @@ module Rubik
103
103
 
104
104
  # ── announce ──
105
105
  def announce_compaction(reason, layer)
106
+ manager = @deps.runtime_manager if @deps.respond_to?(:runtime_manager)
107
+ Rubik::Storage::Vakanuvis::Orientation.inject_after_compaction!(@deps.history, manager, config: @deps.config)
106
108
  @deps.on_step&.call(type: :emergency_compact, reason: reason, step: @deps.step_count,
107
109
  max_steps: @deps.max_steps, layer: layer)
108
110
  end
@@ -100,30 +100,44 @@ module Rubik
100
100
  def load_dropin_plugins(runtime_manager:, storage:)
101
101
  plugins = storage.discover_plugins
102
102
  plugins.each do |plugin_meta|
103
- integrity = storage.verify_integrity(plugin_meta[:path], scope: plugin_meta[:scope])
103
+ content = File.exist?(plugin_meta[:path]) ? File.binread(plugin_meta[:path]) : nil
104
+ integrity = storage.verify_integrity(plugin_meta[:path], scope: plugin_meta[:scope], content: content)
104
105
  if integrity == :rejected
105
106
  @logger.warn("Skipping drop-in plugin '#{plugin_meta[:name]}' due to integrity check failure.")
106
107
  next
107
108
  end
108
109
 
109
- load_plugin_file(plugin_meta, runtime_manager: runtime_manager)
110
+ load_plugin_file(plugin_meta, runtime_manager: runtime_manager, content: content)
110
111
  end
111
112
  end
112
113
 
113
- def load_plugin_file(plugin_meta, runtime_manager:)
114
+ def load_plugin_file(plugin_meta, runtime_manager:, content: nil)
114
115
  name = plugin_meta[:name]
115
116
  component_name = "plugin_#{name}"
116
117
 
117
- unload_plugin(name, runtime_manager: runtime_manager) if runtime_manager.fibers.key?(component_name)
118
+ # If plugin is already running, prepare the replacement first (issue #309).
119
+ # Unloading before prepare would destroy the live plugin if the replacement has a syntax/prepare error.
120
+ already_running = runtime_manager.fibers.key?(component_name)
121
+ previous_def = Rubik::Plugin::Registry.get(name) if already_running
122
+ previous_fiber = runtime_manager.fibers[component_name] if already_running
123
+ was_active = previous_fiber&.state == :active
118
124
 
119
- definition = prepare_plugin_definition(plugin_meta)
125
+ definition = prepare_plugin_definition(plugin_meta, content: content)
120
126
  return false unless definition
121
127
 
128
+ unload_plugin(name, runtime_manager: runtime_manager) if already_running
129
+ Rubik::Plugin::Registry.register(definition)
130
+
122
131
  fiber = register_plugin_component(name, definition, plugin_meta, runtime_manager: runtime_manager)
123
- return false unless fiber
132
+ if fiber.nil? || fiber.state == :failed || (was_active && fiber.state == :pending)
133
+ if already_running && previous_def
134
+ @logger.error("Failed to activate replacement for plugin '#{name}' (state: #{fiber&.state}); rolling back to previous version")
135
+ runtime_manager.unregister_component(component_name)
136
+ restore_previous_plugin(name, previous_def, plugin_meta, runtime_manager: runtime_manager)
137
+ end
138
+ return false
139
+ end
124
140
 
125
- # Honest load result (issue #144): a failed activation (factory raised)
126
- # is reported as failure, not as a successful load.
127
141
  fiber.state != :failed
128
142
  end
129
143
 
@@ -159,14 +173,19 @@ module Rubik
159
173
  return false
160
174
  end
161
175
 
162
- integrity = storage.verify_integrity(target[:path], scope: target[:scope])
176
+ content = File.exist?(target[:path]) ? File.binread(target[:path]) : nil
177
+ integrity = storage.verify_integrity(target[:path], scope: target[:scope], content: content)
163
178
  if integrity == :rejected
164
179
  @logger.error("Reload of drop-in plugin '#{name}' refused: integrity check failed; previous version kept")
165
180
  return false
166
181
  end
167
182
 
183
+ component_name = "plugin_#{name}"
168
184
  previous = Rubik::Plugin::Registry.get(name)
169
- definition = prepare_plugin_definition(target)
185
+ previous_fiber = runtime_manager.fibers[component_name]
186
+ was_active = previous_fiber&.state == :active
187
+
188
+ definition = prepare_plugin_definition(target, content: content)
170
189
  unless definition
171
190
  @logger.error("Reload of drop-in plugin '#{name}' failed during preparation; previous version kept")
172
191
  return false
@@ -175,10 +194,11 @@ module Rubik
175
194
  unload_plugin(name, runtime_manager: runtime_manager)
176
195
  Rubik::Plugin::Registry.register(definition)
177
196
  fiber = register_plugin_component(name, definition, target, runtime_manager: runtime_manager)
178
- return true if fiber && fiber.state != :failed
197
+ return true if fiber && fiber.state != :failed && (!was_active || fiber.state == :active)
179
198
 
180
- @logger.error("Reload of drop-in plugin '#{name}' failed during activation; rolling back")
181
- runtime_manager.unregister_component("plugin_#{name}")
199
+ state_reason = fiber ? fiber.state.to_s : "nil fiber"
200
+ @logger.error("Reload of drop-in plugin '#{name}' failed during activation (ended #{state_reason}); rolling back")
201
+ runtime_manager.unregister_component(component_name)
182
202
  restore_previous_plugin(name, previous, target, runtime_manager: runtime_manager)
183
203
  false
184
204
  end
@@ -190,7 +210,7 @@ module Rubik
190
210
  # replacement can never cost the old version. Returns the new
191
211
  # Definition on success; nil on failure, with the previous registry
192
212
  # entry restored and the load-path claim released.
193
- def prepare_plugin_definition(plugin_meta)
213
+ def prepare_plugin_definition(plugin_meta, content: nil)
194
214
  name = plugin_meta[:name]
195
215
  path = plugin_meta[:path]
196
216
  load_path = plugin_meta[:load_path]
@@ -199,7 +219,11 @@ module Rubik
199
219
  claim_load_path(load_path)
200
220
 
201
221
  begin
202
- load path
222
+ if content
223
+ TOPLEVEL_BINDING.eval(content, path)
224
+ else
225
+ load path
226
+ end
203
227
  rescue StandardError, ScriptError => e
204
228
  @logger.error("Failed to parse drop-in plugin '#{name}': #{e.message}")
205
229
  rollback_prepared_definition(name, previous)
@@ -33,12 +33,22 @@ module Rubik
33
33
  e.message
34
34
  end
35
35
 
36
+ def secure_compare(first, second)
37
+ return false unless first.is_a?(String) && second.is_a?(String)
38
+ return false unless first.bytesize == second.bytesize
39
+
40
+ bytes = first.unpack("C*")
41
+ res = 0
42
+ second.each_byte { |byte| res |= byte ^ bytes.shift }
43
+ res.zero?
44
+ end
45
+
36
46
  # Audits additions, deletions, and modifications: any drift means the
37
47
  # package no longer matches the consented inventory.
38
48
  def drift(expected, current)
39
49
  added = current.keys - expected.keys
40
50
  removed = expected.keys - current.keys
41
- modified = (current.keys & expected.keys).reject { |f| current[f] == expected[f] }
51
+ modified = (current.keys & expected.keys).reject { |f| secure_compare(current[f], expected[f]) }
42
52
 
43
53
  parts = []
44
54
  parts << "added: #{added.sort.join(', ')}" unless added.empty?