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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +118 -1
  4. data/lib/rubik/ast/c_extractor.rb +1 -1
  5. data/lib/rubik/ast/cache_store.rb +36 -5
  6. data/lib/rubik/ast/extraction_result.rb +6 -0
  7. data/lib/rubik/ast/fallback_cleaner.rb +3 -1
  8. data/lib/rubik/ast/graph.rb +31 -5
  9. data/lib/rubik/ast/ruby_extractor.rb +8 -2
  10. data/lib/rubik/ast/rust_extractor.rb +1 -1
  11. data/lib/rubik/ast/typescript_extractor.rb +3 -3
  12. data/lib/rubik/buffer/apply_contract.rb +6 -0
  13. data/lib/rubik/buffer/manager.rb +37 -27
  14. data/lib/rubik/diagnostics/parser.rb +16 -16
  15. data/lib/rubik/llm/openai_compatible.rb +2 -1
  16. data/lib/rubik/lsp/manager.rb +15 -5
  17. data/lib/rubik/mcp/client.rb +4 -0
  18. data/lib/rubik/mcp/manager.rb +51 -35
  19. data/lib/rubik/mcp/proxy_tool.rb +17 -0
  20. data/lib/rubik/mcp/transport/stdio.rb +142 -17
  21. data/lib/rubik/orchestrator/step_executor.rb +1 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
  23. data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
  25. data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
  26. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
  27. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
  28. data/lib/rubik/orchestrator/turn_executor.rb +3 -2
  29. data/lib/rubik/platform/path.rb +2 -1
  30. data/lib/rubik/platform/sanitize.rb +4 -1
  31. data/lib/rubik/plugin/manager.rb +39 -15
  32. data/lib/rubik/plugin/package_inventory.rb +11 -1
  33. data/lib/rubik/plugin/storage.rb +5 -4
  34. data/lib/rubik/repair/loop.rb +29 -15
  35. data/lib/rubik/repl/step_formatter.rb +2 -1
  36. data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
  37. data/lib/rubik/security/blocked_guidance.rb +1 -1
  38. data/lib/rubik/security/redactor.rb +3 -3
  39. data/lib/rubik/storage/layout.rb +3 -1
  40. data/lib/rubik/storage/render.rb +1 -1
  41. data/lib/rubik/storage/vakanuvis/vocabulary.rb +36 -2
  42. data/lib/rubik/tools/base.rb +2 -1
  43. data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
  44. data/lib/rubik/ui/capability.rb +19 -5
  45. data/lib/rubik/ui/renderer.rb +1 -0
  46. data/lib/rubik/version.rb +1 -1
  47. 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
- if (m = line.match(/^([^:]+):(\d+):(\d+):\s*(undefined:\s*.*)$/))
57
- entries << Entry.new(file: m[1], line: m[2], col: m[3], message: m[4], category: :undefined_ref, raw_text: line)
58
- elsif (m = line.match(/^([^:]+):(\d+):(\d+):\s*(.*)$/))
59
- entries << Entry.new(file: m[1], line: m[2], col: m[3], message: m[4], category: :syntax, raw_text: line)
60
- end
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+)\]:\s*(.*)$/))
71
- current_error = { code: m[1], message: m[2], category: :type }
72
- elsif current_error && (m = line.match(/-->\s*([^:]+):(\d+):(\d+)/))
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:\s*(.*)$/))
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):\s*(.*)$/))
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+)):?\s*-?\s*error\s*(TS\d+)?:?\s*(.*)$}i))
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+:)?\s*(.*)$/))
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.sub(%r{/+\z}, "")
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"
@@ -200,18 +200,16 @@ module Rubik
200
200
 
201
201
  client.stop
202
202
  @clients.delete(lang_str)
203
- effective_ctx = @context || @runtime_manager&.context
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
- effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
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
- @clients.delete(lang) if stored.equal?(client)
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
@@ -147,6 +147,10 @@ module Rubik
147
147
  @transport&.running? || false
148
148
  end
149
149
 
150
+ def stderr_tail
151
+ @transport.respond_to?(:stderr_tail) ? @transport.stderr_tail : []
152
+ end
153
+
150
154
  private
151
155
 
152
156
  def ensure_running!
@@ -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
@@ -312,7 +312,7 @@ module Rubik
312
312
  end
313
313
 
314
314
  # Strip ```json ... ``` blocks
315
- str.gsub(/```(?:json)?\s*\{.*?\}\s*```/m, "").strip
315
+ str.gsub(/```(?:json)?\s*\{[^`]*\}\s*```/m, "").strip
316
316
  end
317
317
  end
318
318
  end
@@ -21,6 +21,7 @@ module Rubik
21
21
  reap_orphans
22
22
  release_ready!
23
23
  emit_completion_notice(inst)
24
+ notify_wait!
24
25
  end
25
26
 
26
27
  private
@@ -150,6 +150,7 @@ module Rubik
150
150
  end
151
151
  announce_cancellations(cancelled)
152
152
  claimed.each { |spec| launch_claimed!(spec) }
153
+ notify_wait!
153
154
  end
154
155
 
155
156
  # wait_for'un döngü koşulu için: hedef id'ler arasında hâlâ kuyrukta