rubik 0.4.0 → 0.4.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 +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +118 -1
- data/lib/rubik/ast/c_extractor.rb +1 -1
- data/lib/rubik/ast/cache_store.rb +36 -5
- data/lib/rubik/ast/extraction_result.rb +6 -0
- data/lib/rubik/ast/fallback_cleaner.rb +3 -1
- data/lib/rubik/ast/graph.rb +31 -5
- data/lib/rubik/ast/ruby_extractor.rb +8 -2
- data/lib/rubik/ast/rust_extractor.rb +1 -1
- data/lib/rubik/ast/typescript_extractor.rb +3 -3
- data/lib/rubik/buffer/apply_contract.rb +6 -0
- data/lib/rubik/buffer/manager.rb +37 -27
- data/lib/rubik/diagnostics/parser.rb +16 -16
- data/lib/rubik/llm/openai_compatible.rb +2 -1
- data/lib/rubik/lsp/manager.rb +15 -5
- 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/step_executor.rb +1 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
- data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
- data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
- data/lib/rubik/orchestrator/turn_executor.rb +3 -2
- data/lib/rubik/platform/path.rb +2 -1
- data/lib/rubik/platform/sanitize.rb +4 -1
- 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/repair/loop.rb +29 -15
- data/lib/rubik/repl/step_formatter.rb +2 -1
- data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
- data/lib/rubik/security/blocked_guidance.rb +1 -1
- data/lib/rubik/security/redactor.rb +3 -3
- data/lib/rubik/storage/layout.rb +3 -1
- data/lib/rubik/storage/render.rb +1 -1
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +36 -2
- data/lib/rubik/tools/base.rb +2 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
- data/lib/rubik/ui/capability.rb +19 -5
- data/lib/rubik/ui/renderer.rb +1 -0
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
|
@@ -53,11 +53,11 @@ module Rubik
|
|
|
53
53
|
def parse_go(text)
|
|
54
54
|
entries = []
|
|
55
55
|
text.each_line do |line|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
next unless (m = line.match(/^([^:]+):(\d+):(\d+):(.*)$/))
|
|
57
|
+
|
|
58
|
+
msg = m[4].strip
|
|
59
|
+
cat = msg.start_with?("undefined:") ? :undefined_ref : :syntax
|
|
60
|
+
entries << Entry.new(file: m[1], line: m[2], col: m[3], message: msg, category: cat, raw_text: line)
|
|
61
61
|
end
|
|
62
62
|
entries
|
|
63
63
|
end
|
|
@@ -67,9 +67,9 @@ module Rubik
|
|
|
67
67
|
current_error = nil
|
|
68
68
|
|
|
69
69
|
text.each_line do |line|
|
|
70
|
-
if (m = line.match(/^error\[(E\d+)\]
|
|
71
|
-
current_error = { code: m[1], message: m[2], category: :type }
|
|
72
|
-
elsif current_error && (m = line.match(/-->\s*([
|
|
70
|
+
if (m = line.match(/^error\[(E\d+)\]:(.*)$/))
|
|
71
|
+
current_error = { code: m[1], message: m[2].strip, category: :type }
|
|
72
|
+
elsif current_error && (m = line.match(/-->\s*([^:\s]+):(\d+):(\d+)/))
|
|
73
73
|
entries << Entry.new(
|
|
74
74
|
file: m[1],
|
|
75
75
|
line: m[2],
|
|
@@ -88,9 +88,9 @@ module Rubik
|
|
|
88
88
|
def parse_zig_c(text)
|
|
89
89
|
entries = []
|
|
90
90
|
text.each_line do |line|
|
|
91
|
-
next unless (m = line.match(/^([^:]+):(\d+):(\d+):\s*error
|
|
91
|
+
next unless (m = line.match(/^([^:]+):(\d+):(\d+):\s*error:(.*)$/))
|
|
92
92
|
|
|
93
|
-
msg = m[4]
|
|
93
|
+
msg = m[4].strip
|
|
94
94
|
cat = msg.include?("undeclared identifier") || msg.include?("use of undeclared") ? :undefined_ref : :syntax
|
|
95
95
|
entries << Entry.new(file: m[1], line: m[2], col: m[3], message: msg, category: cat, raw_text: line)
|
|
96
96
|
end
|
|
@@ -106,12 +106,12 @@ module Rubik
|
|
|
106
106
|
if (m = line.match(/File "([^"]+)", line (\d+)(?:, in .*)?/))
|
|
107
107
|
current_file = m[1]
|
|
108
108
|
current_line = m[2].to_i
|
|
109
|
-
elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception)
|
|
109
|
+
elsif (m = line.match(/^([A-Za-z0-9_]+Error|Exception):(.*)$/))
|
|
110
110
|
cat = m[1] =~ /\A(?:Syntax|Indentation|Tab)Error\z/ ? :syntax : :type
|
|
111
111
|
entries << Entry.new(
|
|
112
112
|
file: current_file,
|
|
113
113
|
line: current_line,
|
|
114
|
-
message: "#{m[1]}: #{m[2]}",
|
|
114
|
+
message: "#{m[1]}: #{m[2].strip}",
|
|
115
115
|
category: cat,
|
|
116
116
|
raw_text: line
|
|
117
117
|
)
|
|
@@ -125,13 +125,13 @@ module Rubik
|
|
|
125
125
|
def parse_ts_js(text)
|
|
126
126
|
entries = []
|
|
127
127
|
text.each_line do |line|
|
|
128
|
-
next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+))
|
|
128
|
+
next unless (m = line.match(%r{^((?:[a-zA-Z]:[\\/])?[^(:\s]+)(?:\((\d+),(\d+)\)|:(\d+):(\d+))(?::\s*-\s*|:\s*|\s*-\s*|\s+)error(?:\s+(TS\d+))?:?(.*)$}i))
|
|
129
129
|
|
|
130
130
|
file = m[1]
|
|
131
131
|
line_num = (m[2] || m[4]).to_i
|
|
132
132
|
col_num = (m[3] || m[5]).to_i
|
|
133
133
|
code = m[6]
|
|
134
|
-
msg = m[7]
|
|
134
|
+
msg = m[7].strip
|
|
135
135
|
entries << Entry.new(
|
|
136
136
|
file: file,
|
|
137
137
|
line: line_num,
|
|
@@ -148,8 +148,8 @@ module Rubik
|
|
|
148
148
|
def parse_generic(text)
|
|
149
149
|
entries = []
|
|
150
150
|
text.each_line do |line|
|
|
151
|
-
if (m = line.match(/^([^:\s]+):(\d+):(?:\d+:)
|
|
152
|
-
entries << Entry.new(file: m[1], line: m[2], message: m[3], category: :unknown, raw_text: line)
|
|
151
|
+
if (m = line.match(/^([^:\s]+):(\d+):(?:\d+:)?(.*)$/))
|
|
152
|
+
entries << Entry.new(file: m[1], line: m[2], message: m[3].strip, category: :unknown, raw_text: line)
|
|
153
153
|
end
|
|
154
154
|
end
|
|
155
155
|
entries
|
|
@@ -14,7 +14,8 @@ module Rubik
|
|
|
14
14
|
raw = url.to_s.strip
|
|
15
15
|
return raw if raw.empty?
|
|
16
16
|
|
|
17
|
-
clean = raw
|
|
17
|
+
clean = raw
|
|
18
|
+
clean = clean.delete_suffix("/") while clean.end_with?("/")
|
|
18
19
|
return clean if clean.end_with?("/chat/completions") || clean.end_with?("/completions")
|
|
19
20
|
|
|
20
21
|
if clean == "https://api.deepseek.com"
|
data/lib/rubik/lsp/manager.rb
CHANGED
|
@@ -200,18 +200,16 @@ module Rubik
|
|
|
200
200
|
|
|
201
201
|
client.stop
|
|
202
202
|
@clients.delete(lang_str)
|
|
203
|
-
|
|
204
|
-
effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
|
|
203
|
+
unregister_client_unlocked(lang_str)
|
|
205
204
|
{ ok: true, message: "Stopped LSP server for '#{lang_str}'" }
|
|
206
205
|
end
|
|
207
206
|
end
|
|
208
207
|
|
|
209
208
|
def stop_all
|
|
210
209
|
@monitor.synchronize do
|
|
211
|
-
effective_ctx = @context || @runtime_manager&.context
|
|
212
210
|
@clients.each do |lang_str, client|
|
|
213
211
|
client.stop
|
|
214
|
-
|
|
212
|
+
unregister_client_unlocked(lang_str)
|
|
215
213
|
end
|
|
216
214
|
@clients.clear
|
|
217
215
|
end
|
|
@@ -387,7 +385,10 @@ module Rubik
|
|
|
387
385
|
recovering = true
|
|
388
386
|
record_crash(lang)
|
|
389
387
|
stop_client_safely(client)
|
|
390
|
-
|
|
388
|
+
if stored.equal?(client)
|
|
389
|
+
@clients.delete(lang)
|
|
390
|
+
unregister_client_unlocked(lang)
|
|
391
|
+
end
|
|
391
392
|
client = nil
|
|
392
393
|
end
|
|
393
394
|
|
|
@@ -418,6 +419,7 @@ module Rubik
|
|
|
418
419
|
else
|
|
419
420
|
record_crash(lang)
|
|
420
421
|
stop_client_safely(client)
|
|
422
|
+
unregister_client_unlocked(lang)
|
|
421
423
|
@recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
|
|
422
424
|
end
|
|
423
425
|
elsif recovering
|
|
@@ -439,6 +441,7 @@ module Rubik
|
|
|
439
441
|
else
|
|
440
442
|
record_crash(lang)
|
|
441
443
|
stop_client_safely(client)
|
|
444
|
+
unregister_client_unlocked(lang)
|
|
442
445
|
@recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
|
|
443
446
|
end
|
|
444
447
|
end
|
|
@@ -465,6 +468,13 @@ module Rubik
|
|
|
465
468
|
end
|
|
466
469
|
end
|
|
467
470
|
|
|
471
|
+
def unregister_client_unlocked(lang)
|
|
472
|
+
lang_str = lang.to_s.downcase
|
|
473
|
+
@registered_disposable_langs.delete(lang_str)
|
|
474
|
+
effective_ctx = @context || @runtime_manager&.context
|
|
475
|
+
effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
|
|
476
|
+
end
|
|
477
|
+
|
|
468
478
|
def stop_client_safely(client)
|
|
469
479
|
client.stop
|
|
470
480
|
rescue StandardError
|
data/lib/rubik/mcp/client.rb
CHANGED
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
|