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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +118 -1
- data/lib/rubik/ast/cache_store.rb +69 -5
- data/lib/rubik/ast/extraction_result.rb +6 -0
- data/lib/rubik/ast/graph.rb +32 -5
- data/lib/rubik/ast/ruby_extractor.rb +8 -2
- data/lib/rubik/buffer/apply_contract.rb +6 -0
- data/lib/rubik/buffer/manager.rb +37 -27
- data/lib/rubik/config/defaults.rb +7 -1
- data/lib/rubik/mcp/client.rb +4 -0
- data/lib/rubik/mcp/manager.rb +51 -35
- data/lib/rubik/mcp/proxy_tool.rb +17 -0
- data/lib/rubik/mcp/transport/stdio.rb +142 -17
- data/lib/rubik/orchestrator/budget_manager.rb +2 -0
- data/lib/rubik/orchestrator/loop.rb +1 -0
- data/lib/rubik/orchestrator/shell_deps.rb +4 -0
- data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -0
- data/lib/rubik/plugin/manager.rb +39 -15
- data/lib/rubik/plugin/package_inventory.rb +11 -1
- data/lib/rubik/plugin/storage.rb +5 -4
- data/lib/rubik/prompt/doctrine.rb +10 -15
- data/lib/rubik/prompt/tool_teachings.rb +29 -35
- data/lib/rubik/repair/loop.rb +29 -15
- data/lib/rubik/repl/commands/agent_context.rb +1 -0
- data/lib/rubik/repl/commands/system.rb +2 -0
- data/lib/rubik/repl/turn_recorder.rb +3 -0
- data/lib/rubik/security/redactor.rb +13 -9
- data/lib/rubik/storage/vakanuvis/gate.rb +13 -8
- data/lib/rubik/storage/vakanuvis/harness.rb +115 -112
- data/lib/rubik/storage/vakanuvis/orientation.rb +277 -0
- data/lib/rubik/storage/vakanuvis/service.rb +5 -1
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +33 -2
- data/lib/rubik/storage/vakanuvis.rb +8 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
- data/lib/rubik/tools/builtins/vakanuvis.rb +13 -15
- data/lib/rubik/ui/capability.rb +19 -5
- data/lib/rubik/version.rb +1 -1
- metadata +2 -1
data/lib/rubik/mcp/manager.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
#
|
|
311
|
-
#
|
|
312
|
-
@monitor.synchronize
|
|
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
|
-
@
|
|
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
|
|
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
|
data/lib/rubik/mcp/proxy_tool.rb
CHANGED
|
@@ -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
|
|
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
|
-
@
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
156
|
+
child_died = false
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
return if @closed
|
|
124
159
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
@
|
|
242
|
-
|
|
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
|
data/lib/rubik/plugin/manager.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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]
|
|
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?
|