rubik 0.3.5 → 0.4.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +99 -3
  4. data/lib/rubik/ast/c_extractor.rb +1 -1
  5. data/lib/rubik/ast/fallback_cleaner.rb +3 -1
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +1 -1
  10. data/lib/rubik/ast/typescript_extractor.rb +3 -3
  11. data/lib/rubik/diagnostics/parser.rb +65 -13
  12. data/lib/rubik/llm/openai_compatible.rb +2 -1
  13. data/lib/rubik/llm/registry.rb +81 -74
  14. data/lib/rubik/lsp/manager.rb +15 -5
  15. data/lib/rubik/orchestrator/step_executor.rb +139 -155
  16. data/lib/rubik/orchestrator/subagent/completion.rb +1 -0
  17. data/lib/rubik/orchestrator/subagent/dependency.rb +1 -0
  18. data/lib/rubik/orchestrator/subagent/supervisor.rb +20 -3
  19. data/lib/rubik/orchestrator/subagent/waiting.rb +16 -9
  20. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +1 -1
  21. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -2
  22. data/lib/rubik/orchestrator/turn_executor.rb +3 -2
  23. data/lib/rubik/platform/path.rb +2 -1
  24. data/lib/rubik/platform/sanitize.rb +4 -1
  25. data/lib/rubik/prompt/token_estimator.rb +16 -12
  26. data/lib/rubik/repl/step_formatter.rb +2 -1
  27. data/lib/rubik/runtime/diagnostic_engine.rb +1 -1
  28. data/lib/rubik/security/blocked_guidance.rb +1 -1
  29. data/lib/rubik/security/classifier/classifier.rb +3 -1
  30. data/lib/rubik/security/redactor.rb +3 -3
  31. data/lib/rubik/storage/layout.rb +3 -1
  32. data/lib/rubik/storage/render.rb +1 -1
  33. data/lib/rubik/storage/vakanuvis/vocabulary.rb +5 -2
  34. data/lib/rubik/tools/base.rb +2 -1
  35. data/lib/rubik/tools/truncator.rb +21 -8
  36. data/lib/rubik/ui/renderer.rb +1 -0
  37. data/lib/rubik/version.rb +1 -1
  38. metadata +1 -1
@@ -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
@@ -19,20 +19,9 @@ module Rubik
19
19
  include MaestroGate
20
20
 
21
21
  ERROR_PREFIXES = [
22
- "-- ",
23
- "Error:",
24
- "Error ",
25
- "Usage:",
26
- "Pre-flight verification failed",
27
- "Bilinmeyen komut",
28
- "Failed",
29
- "Security blocked",
30
- "Command error",
31
- "[EXIT ",
32
- "Test run [FAILED]",
33
- "Lint [FAILED]",
34
- "[CONFLICT]",
35
- "[CONFLICT "
22
+ "-- ", "Error:", "Error ", "Usage:", "Pre-flight verification failed",
23
+ "Bilinmeyen komut", "Failed", "Security blocked", "Command error",
24
+ "[EXIT ", "Test run [FAILED]", "Lint [FAILED]", "[CONFLICT]", "[CONFLICT "
36
25
  ].freeze
37
26
 
38
27
  # Plan 87 E-1: state is reached through the ShellDeps façade. Accepts a
@@ -98,7 +87,6 @@ module Rubik
98
87
 
99
88
  def execute_registered_tool(tool_instance, tool_name, tool_args, call_id, auto_apply_mode = nil)
100
89
  @deps.on_step&.call(type: :tool_call, tool: tool_name, args: tool_args, step: @deps.step_count, max_steps: @deps.max_steps, call_id: call_id)
101
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
90
 
103
91
  # Opt-in circuit breaker (G19): when enabled and the circuit for this
104
92
  # tool is open, raise into the normal error-result plumbing below so
@@ -107,111 +95,13 @@ module Rubik
107
95
  breaker = circuit_breaker_for
108
96
  blocked_by_circuit = breaker && !breaker.allow?(tool_instance.name)
109
97
 
110
- tool_result = begin
111
- if blocked_by_circuit
112
- raise ToolCircuitBreaker::CircuitOpenError,
113
- "circuit open for '#{tool_instance.name}' — repeated failures; waiting out the cooldown before retrying"
114
- end
98
+ raw_output, tool_result = invoke_tool_and_normalize(tool_instance, tool_name, tool_args, call_id, blocked_by_circuit: blocked_by_circuit)
115
99
 
116
- raw_output = tool_instance.execute(tool_args)
117
- t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
- dur = ((t1 - t0) * 1000).round(1)
119
-
120
- max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
121
- max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
122
- trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
123
-
124
- if raw_output.is_a?(Rubik::Tools::Result)
125
- trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
126
- effective_dur = raw_output.duration_ms.positive? ? raw_output.duration_ms : dur
127
- first_line = raw_output.output.lines.first.to_s.strip
128
- first_line = nil if first_line.empty?
129
- Rubik::Tools::Result.new(
130
- tool_name: raw_output.tool_name.to_s.empty? ? tool_name : raw_output.tool_name,
131
- call_id: raw_output.call_id.to_s.empty? ? call_id : raw_output.call_id,
132
- status: raw_output.status,
133
- error: raw_output.error || (raw_output.error? ? first_line : nil),
134
- exit_code: raw_output.exit_code,
135
- output: trunc_res[:text],
136
- duration_ms: effective_dur,
137
- truncated: raw_output.truncated || trunc_res[:truncated],
138
- metadata: raw_output.metadata
139
- )
140
- elsif raw_output.respond_to?(:status) && raw_output.respond_to?(:output)
141
- out_str = raw_output.output.to_s
142
- trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
143
- st = raw_output.status.to_sym
144
- first_line = out_str.lines.first.to_s.strip
145
- first_line = nil if first_line.empty?
146
- err = (raw_output.respond_to?(:error) && raw_output.error) || (st == :error ? first_line : nil)
147
- Rubik::Tools::Result.new(
148
- tool_name: tool_name,
149
- call_id: call_id,
150
- status: st,
151
- error: err,
152
- output: trunc_res[:text],
153
- duration_ms: dur,
154
- truncated: trunc_res[:truncated],
155
- # Structured fields ride along when the tool provides them
156
- # (issue #127): the same Result contract for every carrier.
157
- exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
158
- metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
159
- )
160
- else
161
- out_str = raw_output.to_s
162
- trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
163
-
164
- is_err = if raw_output.respond_to?(:error?)
165
- raw_output.error?
166
- elsif raw_output.respond_to?(:failed?)
167
- raw_output.failed?
168
- elsif raw_output.is_a?(String)
169
- ERROR_PREFIXES.any? { |pfx| raw_output.start_with?(pfx) }
170
- else
171
- false
172
- end
173
-
174
- first_line = out_str.lines.first.to_s.strip
175
- first_line = nil if first_line.empty?
176
-
177
- Rubik::Tools::Result.new(
178
- tool_name: tool_name,
179
- call_id: call_id,
180
- status: is_err ? :error : :ok,
181
- error: is_err ? first_line : nil,
182
- output: trunc_res[:text],
183
- duration_ms: dur,
184
- truncated: trunc_res[:truncated]
185
- )
186
- end
187
- rescue StandardError => e
188
- t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
- dur = ((t1 - t0) * 1000).round(1)
190
- err_msg = "Error executing tool '#{tool_name}': #{e.class} - #{e.message}"
191
- Rubik::Tools::Result.new(
192
- tool_name: tool_name,
193
- call_id: call_id,
194
- status: :error,
195
- error: e.message,
196
- output: err_msg,
197
- duration_ms: dur
198
- )
199
- end
100
+ record_circuit_breaker_result(breaker, tool_instance.name, tool_result.status) if breaker && !blocked_by_circuit
200
101
 
201
102
  # always-mode flush after every tool — was historically silent; the
202
103
  # post_tool event makes the disk write visible like every other apply
203
- if breaker && !blocked_by_circuit
204
- if tool_result.status == :ok
205
- breaker.record_success(tool_instance.name)
206
- else
207
- breaker.record_failure(tool_instance.name)
208
- end
209
- end
210
-
211
- if AutoApply.apply?(@deps.config, trigger: :post_tool, mode: auto_apply_mode) && @deps.buffer_manager&.dirty?
212
- applied = AutoApply.apply_and_sync(@deps.buffer_manager, @deps.shell)
213
- @deps.on_step&.call(type: :auto_apply, reason: "post_tool", files: applied, step: @deps.step_count, max_steps: @deps.max_steps)
214
- end
104
+ perform_post_tool_auto_apply(auto_apply_mode)
215
105
 
216
106
  # Real-time LSP In-Buffer Diagnostics feedback loop (Plan 56 D-5,
217
107
  # extracted to LspFeedback for the line budget).
@@ -222,39 +112,9 @@ module Rubik
222
112
  # çıktısı tam içerikle kullanıcıya :user_output olarak akar; model,
223
113
  # history ve :tool_result özeti yalnız stub görür. Event birincil
224
114
  # teslimat olduğu için onsuz stub yalan olur — kaynak yoksa warn.
225
- if raw_output.respond_to?(:user_display?) && raw_output.user_display? && tool_result.status == :ok
226
- sink = @deps.respond_to?(:on_step) ? @deps.on_step : nil
227
- if sink
228
- sink.call(type: :user_output, content: raw_output.to_s, label: raw_output.display_stub,
229
- step: @deps.step_count, max_steps: @deps.max_steps)
230
- else
231
- warn("[show_file] no :user_output sink wired — display content dropped: #{raw_output.display_stub}")
232
- end
233
- tool_result = Rubik::Tools::Result.new(tool_name: tool_name, call_id: call_id, status: :ok,
234
- output: raw_output.display_stub, duration_ms: tool_result.duration_ms)
235
- end
115
+ tool_result = handle_user_display_output(raw_output, tool_result, tool_name, call_id)
236
116
 
237
- @deps.on_step&.call(
238
- type: :tool_result,
239
- tool: tool_name,
240
- output: tool_result.output,
241
- status: tool_result.status,
242
- duration_ms: tool_result.duration_ms,
243
- step: @deps.step_count,
244
- max_steps: @deps.max_steps,
245
- call_id: call_id,
246
- exit_code: tool_result.exit_code,
247
- metadata: tool_result.metadata,
248
- line_count: tool_result.output.to_s.lines.size,
249
- byte_size: tool_result.output.to_s.bytesize,
250
- tokens: @deps.total_tokens
251
- )
252
-
253
- Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
254
- status: tool_result.status, duration_ms: tool_result.duration_ms,
255
- content: tool_result.to_xml, exit_code: tool_result.exit_code,
256
- metadata: tool_result.metadata)
257
- @deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
117
+ record_tool_execution_results(tool_name, tool_args, call_id, tool_result)
258
118
  @deps.reset_invalid_tools!
259
119
  tool_result
260
120
  end
@@ -280,12 +140,8 @@ module Rubik
280
140
  diagnostic_msg = Rubik::Tools::DiagnosticReporter.render_unknown_tool(tool_name, candidate_tools: candidate_tools, all_tools: all_tool_names)
281
141
 
282
142
  err_result = Rubik::Tools::Result.new(
283
- tool_name: tool_name,
284
- call_id: call_id,
285
- status: :error,
286
- error: "Tool '#{tool_name}' does not exist.",
287
- output: diagnostic_msg,
288
- duration_ms: 0.0
143
+ tool_name: tool_name, call_id: call_id, status: :error,
144
+ error: "Tool '#{tool_name}' does not exist.", output: diagnostic_msg, duration_ms: 0.0
289
145
  )
290
146
  Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
291
147
  status: :error, duration_ms: 0.0, content: err_result.to_xml)
@@ -294,6 +150,134 @@ module Rubik
294
150
 
295
151
  private
296
152
 
153
+ def invoke_tool_and_normalize(tool_instance, tool_name, tool_args, call_id, blocked_by_circuit:)
154
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ raw_output = nil
156
+
157
+ tool_result = begin
158
+ if blocked_by_circuit
159
+ raise ToolCircuitBreaker::CircuitOpenError,
160
+ "circuit open for '#{tool_instance.name}' — repeated failures; waiting out the cooldown before retrying"
161
+ end
162
+
163
+ raw_output = tool_instance.execute(tool_args)
164
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ dur = ((t1 - t0) * 1000).round(1)
166
+
167
+ normalize_raw_output(raw_output, tool_name, call_id, dur)
168
+ rescue StandardError => e
169
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ dur = ((t1 - t0) * 1000).round(1)
171
+ err_msg = "Error executing tool '#{tool_name}': #{e.class} - #{e.message}"
172
+ Rubik::Tools::Result.new(tool_name: tool_name, call_id: call_id, status: :error, error: e.message, output: err_msg, duration_ms: dur)
173
+ end
174
+
175
+ [raw_output, tool_result]
176
+ end
177
+
178
+ def normalize_raw_output(raw_output, tool_name, call_id, dur)
179
+ max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
180
+ max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
181
+ trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
182
+
183
+ if raw_output.is_a?(Rubik::Tools::Result)
184
+ trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
185
+ effective_dur = raw_output.duration_ms.positive? ? raw_output.duration_ms : dur
186
+ first_line = raw_output.output.lines.first.to_s.strip
187
+ first_line = nil if first_line.empty?
188
+ Rubik::Tools::Result.new(
189
+ tool_name: raw_output.tool_name.to_s.empty? ? tool_name : raw_output.tool_name,
190
+ call_id: raw_output.call_id.to_s.empty? ? call_id : raw_output.call_id,
191
+ status: raw_output.status, error: raw_output.error || (raw_output.error? ? first_line : nil),
192
+ # Structured fields ride along when the tool provides them
193
+ # (issue #127): the same Result contract for every carrier.
194
+ exit_code: raw_output.exit_code, output: trunc_res[:text], duration_ms: effective_dur,
195
+ truncated: raw_output.truncated || trunc_res[:truncated], metadata: raw_output.metadata
196
+ )
197
+ elsif raw_output.respond_to?(:status) && raw_output.respond_to?(:output)
198
+ out_str = raw_output.output.to_s
199
+ trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
200
+ st = raw_output.status.to_sym
201
+ first_line = out_str.lines.first.to_s.strip
202
+ first_line = nil if first_line.empty?
203
+ err = (raw_output.respond_to?(:error) && raw_output.error) || (st == :error ? first_line : nil)
204
+ Rubik::Tools::Result.new(
205
+ tool_name: tool_name, call_id: call_id, status: st, error: err, output: trunc_res[:text],
206
+ duration_ms: dur, truncated: trunc_res[:truncated],
207
+ exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
208
+ metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
209
+ )
210
+ else
211
+ out_str = raw_output.to_s
212
+ trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
213
+
214
+ is_err = if raw_output.respond_to?(:error?)
215
+ raw_output.error?
216
+ elsif raw_output.respond_to?(:failed?)
217
+ raw_output.failed?
218
+ elsif raw_output.is_a?(String)
219
+ ERROR_PREFIXES.any? { |pfx| raw_output.start_with?(pfx) }
220
+ else
221
+ false
222
+ end
223
+
224
+ first_line = out_str.lines.first.to_s.strip
225
+ first_line = nil if first_line.empty?
226
+
227
+ Rubik::Tools::Result.new(
228
+ tool_name: tool_name, call_id: call_id, status: is_err ? :error : :ok,
229
+ error: is_err ? first_line : nil, output: trunc_res[:text], duration_ms: dur, truncated: trunc_res[:truncated]
230
+ )
231
+ end
232
+ end
233
+
234
+ def record_circuit_breaker_result(breaker, tool_name, status)
235
+ if status == :ok
236
+ breaker.record_success(tool_name)
237
+ else
238
+ breaker.record_failure(tool_name)
239
+ end
240
+ end
241
+
242
+ def perform_post_tool_auto_apply(auto_apply_mode)
243
+ return unless AutoApply.apply?(@deps.config, trigger: :post_tool, mode: auto_apply_mode) && @deps.buffer_manager&.dirty?
244
+
245
+ applied = AutoApply.apply_and_sync(@deps.buffer_manager, @deps.shell)
246
+ @deps.on_step&.call(type: :auto_apply, reason: "post_tool", files: applied, step: @deps.step_count, max_steps: @deps.max_steps)
247
+ end
248
+
249
+ def handle_user_display_output(raw_output, tool_result, tool_name, call_id)
250
+ return tool_result unless raw_output.respond_to?(:user_display?) && raw_output.user_display? && tool_result.status == :ok
251
+
252
+ sink = @deps.respond_to?(:on_step) ? @deps.on_step : nil
253
+ if sink
254
+ sink.call(type: :user_output, content: raw_output.to_s, label: raw_output.display_stub,
255
+ step: @deps.step_count, max_steps: @deps.max_steps)
256
+ else
257
+ warn("[show_file] no :user_output sink wired — display content dropped: #{raw_output.display_stub}")
258
+ end
259
+
260
+ Rubik::Tools::Result.new(
261
+ tool_name: tool_name, call_id: call_id, status: :ok, output: raw_output.display_stub, duration_ms: tool_result.duration_ms
262
+ )
263
+ end
264
+
265
+ def record_tool_execution_results(tool_name, tool_args, call_id, tool_result)
266
+ @deps.on_step&.call(
267
+ type: :tool_result, tool: tool_name, output: tool_result.output, status: tool_result.status,
268
+ duration_ms: tool_result.duration_ms, step: @deps.step_count, max_steps: @deps.max_steps,
269
+ call_id: call_id, exit_code: tool_result.exit_code, metadata: tool_result.metadata,
270
+ line_count: tool_result.output.to_s.lines.size, byte_size: tool_result.output.to_s.bytesize, tokens: @deps.total_tokens
271
+ )
272
+
273
+ Rubik::Orchestrator::ToolHistory.write(
274
+ @deps.history, tool_call_id: call_id, tool_name: tool_name, status: tool_result.status,
275
+ duration_ms: tool_result.duration_ms, content: tool_result.to_xml, exit_code: tool_result.exit_code, metadata: tool_result.metadata
276
+ )
277
+
278
+ @deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
279
+ end
280
+
297
281
  # G19: one breaker per executor, built from the first enabled config
298
282
  # sighting. Threshold/cooldown changes via live !config set are picked
299
283
  # up on the next executor instantiation — acceptable for an opt-in
@@ -328,7 +312,7 @@ module Rubik
328
312
  end
329
313
 
330
314
  # Strip ```json ... ``` blocks
331
- str.gsub(/```(?:json)?\s*\{.*?\}\s*```/m, "").strip
315
+ str.gsub(/```(?:json)?\s*\{[^`]*\}\s*```/m, "").strip
332
316
  end
333
317
  end
334
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
@@ -68,6 +68,7 @@ module Rubik
68
68
  @instances = {}
69
69
  @deferred = {}
70
70
  @monitor = Monitor.new
71
+ @wait_cond = @monitor.new_cond
71
72
  @counter = 0
72
73
  @max_depth = (config&.fetch("subagents.max_depth", 2) || 2).to_i
73
74
  @max_concurrency = (config&.fetch("subagents.max_concurrency", 10) || 10).to_i
@@ -83,6 +84,13 @@ module Rubik
83
84
  # redacted, paginated — see ResultArchive.
84
85
  @result_archive = ResultArchive.new(config: @config)
85
86
 
87
+ if @event_bus.respond_to?(:subscribe)
88
+ @event_bus.subscribe(Notices::ERROR_PAUSED_EVENT) { notify_wait! }
89
+ @event_bus.subscribe(Notices::BUDGET_EXHAUSTED_EVENT) { notify_wait! }
90
+ @event_bus.subscribe(Notices::COMPLETED_EVENT) { notify_wait! }
91
+ @event_bus.subscribe(Notices::QUESTION_EVENT) { notify_wait! } if defined?(Notices::QUESTION_EVENT)
92
+ end
93
+
86
94
  # Register inverse in root context (maestro hook)
87
95
  @root_context.register_disposable { shutdown_all } if @root_context.respond_to?(:register_disposable)
88
96
  end
@@ -93,20 +101,26 @@ module Rubik
93
101
  inst = get(id)
94
102
  return false unless inst.is_a?(Instance)
95
103
 
96
- inst.extend_budget(steps)
104
+ res = inst.extend_budget(steps)
105
+ notify_wait! if res
106
+ res
97
107
  end
98
108
 
99
109
  def resume(id, additional_steps: nil)
100
110
  inst = get(id)
101
- inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
111
+ res = inst.respond_to?(:resume!) ? inst.resume!(additional_steps: additional_steps) : false
112
+ notify_wait! if res
113
+ res
102
114
  end
103
115
 
104
116
  def resume_all
105
- @monitor.synchronize do
117
+ resumed = @monitor.synchronize do
106
118
  @instances.values.select(&:parked?).map do |i|
107
119
  i.resume! ? i.id : nil
108
120
  end.compact
109
121
  end
122
+ notify_wait! unless resumed.empty?
123
+ resumed
110
124
  end
111
125
 
112
126
  def list(parent_id: nil)
@@ -129,6 +143,7 @@ module Rubik
129
143
  # monitörü 2s'ye kadar bloklar (emit kuralı ihlali olurdu).
130
144
  announce_cancellations(cancelled)
131
145
  release_ready!
146
+ notify_wait!
132
147
  found
133
148
  end
134
149
 
@@ -142,6 +157,7 @@ module Rubik
142
157
  true
143
158
  end
144
159
  announce_cancellations(cancelled)
160
+ notify_wait!
145
161
  end
146
162
 
147
163
  def shutdown_all
@@ -151,6 +167,7 @@ module Rubik
151
167
  @instances.clear
152
168
  @deferred.clear
153
169
  end
170
+ notify_wait!
154
171
  end
155
172
 
156
173
  def reap_orphans
@@ -20,25 +20,32 @@ module Rubik
20
20
  wait(ids, timeout: timeout, wait_for_budget: wait_for_budget).rows
21
21
  end
22
22
 
23
+ def notify_wait!
24
+ @monitor.synchronize { @wait_cond&.broadcast }
25
+ end
26
+
23
27
  def wait(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
24
28
  target_ids = Array(ids).compact.map(&:to_s)
25
29
  deadline = Time.now + [timeout.to_i, 1].max
26
30
 
27
- loop do
28
- active_targets = @monitor.synchronize do
31
+ @monitor.synchronize do
32
+ loop do
29
33
  candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
30
- candidates.select do |inst|
34
+ active_targets = candidates.select do |inst|
31
35
  inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
32
36
  end
33
- end
34
37
 
35
- # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
36
- break if (active_targets.empty? && !deferred_pending?(target_ids)) || Time.now >= deadline
38
+ # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
39
+ rem = deadline - Time.now
40
+ break if (active_targets.empty? && !deferred_pending?(target_ids)) || rem <= 0
37
41
 
38
- sleep 0.1
39
- end
42
+ if @wait_cond
43
+ @wait_cond.wait([rem, 0].max)
44
+ else
45
+ sleep [rem, 0.1].min
46
+ end
47
+ end
40
48
 
41
- @monitor.synchronize do
42
49
  candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
43
50
  paused = candidates.select { |c| parked_candidate?(c) }
44
51
  active = candidates.select { |c| c.respond_to?(:active?) && c.active? }
@@ -62,7 +62,7 @@ module Rubik
62
62
  @inst.emit_compacted_event(reason, layer)
63
63
  end
64
64
 
65
- def announce_llm_thought(_text) = nil
65
+ def announce_llm_thought(_text, **) = nil
66
66
 
67
67
  # ── terminals & dispatch ──
68
68
  # The shared two-strike nudge policy (Channel#empty_response_turn)
@@ -107,8 +107,8 @@ module Rubik
107
107
  max_steps: @deps.max_steps, layer: layer)
108
108
  end
109
109
 
110
- def announce_llm_thought(text)
111
- @deps.on_step&.call(type: :llm_thought, content: text, step: @deps.step_count, max_steps: @deps.max_steps)
110
+ def announce_llm_thought(text, streamed: false)
111
+ @deps.on_step&.call(type: :llm_thought, content: text, streamed: streamed, step: @deps.step_count, max_steps: @deps.max_steps)
112
112
  end
113
113
 
114
114
  # ── terminals & dispatch ──
@@ -218,7 +218,8 @@ module Rubik
218
218
  def tool_turn(res, text_resp, tool_calls)
219
219
  channel.nudge_policy.reset!
220
220
  channel.append_tool_call_row(channel.final_text(res, text_resp), tool_calls)
221
- channel.announce_llm_thought(text_resp) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
221
+ streamed = res.respond_to?(:streamed) && res.streamed == true
222
+ channel.announce_llm_thought(text_resp, streamed: streamed) if !text_resp.empty? && tool_calls.none? { |tc| self.class.finish_call?(channel.tool_registry, tc) }
222
223
 
223
224
  tool_calls.each do |tc|
224
225
  # finish is exempt (pinned by test_orchestrator_stream): its args
@@ -300,7 +301,7 @@ module Rubik
300
301
  # ── announce ──
301
302
  def announce_compaction(_reason, _layer) = nil
302
303
 
303
- def announce_llm_thought(_text) = nil
304
+ def announce_llm_thought(_text, **) = nil
304
305
 
305
306
  # Goal-ledger observation projection (plan D): related-optional
306
307
  # capability — base channel is structurally inert (subagent planes
@@ -228,7 +228,8 @@ module Rubik
228
228
  # drive root?" depend on the process cwd (which drive the workspace
229
229
  # lives on). Security callers pass already-expanded absolute paths;
230
230
  # compare the literal form here.
231
- cleaned = path.to_s.tr("\\", "/").downcase.sub(%r{/+\z}, "")
231
+ cleaned = path.to_s.tr("\\", "/").downcase
232
+ cleaned = cleaned.delete_suffix("/") while cleaned.end_with?("/")
232
233
  cleaned =~ /^[a-z]:$/
233
234
  end
234
235
  end
@@ -108,14 +108,17 @@ module Rubik
108
108
  end
109
109
  end
110
110
 
111
+ VALID_SYMBOL_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
112
+
111
113
  # Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
112
114
  # bare validity check would fast-path them through UNCLEANED — the old
113
115
  # clean_utf8 path strips their >= 0x80 bytes and the byte loss would
114
116
  # instead surface later, at the JSON/display layer. Only UTF-8 (or the
115
117
  # lossless US-ASCII subset) symbols may take the fast path.
118
+ # PERF: Hoist encoding array to frozen constant to eliminate array allocations per symbol check (~26% faster, ~99.99% fewer allocations).
116
119
  def fast_clean_symbol(sym)
117
120
  name = sym.name
118
- return sym if [Encoding::UTF_8, Encoding::US_ASCII].include?(name.encoding) && name.valid_encoding?
121
+ return sym if VALID_SYMBOL_ENCODINGS.include?(name.encoding) && name.valid_encoding?
119
122
 
120
123
  clean_utf8(name).to_sym
121
124
  end
@@ -22,27 +22,31 @@ module Rubik
22
22
  ((len * 10) + 37) / 38
23
23
  end
24
24
 
25
+ # PERF: Fast-path message token estimator respecting ConversationRows abstraction.
25
26
  def estimate_message(msg)
26
27
  return 0 unless ConversationRows.row?(msg)
27
28
 
28
29
  tokens = 4 # Base message overhead (role delimiter, headers)
29
- role = ConversationRows.value_of(msg, :role)
30
- tokens += estimate_text(role) if role
31
30
 
32
- content = ConversationRows.value_of(msg, :content)
33
- tokens += estimate_text(content) if content
31
+ if (role = ConversationRows.value_of(msg, :role))
32
+ tokens += estimate_text(role)
33
+ end
34
34
 
35
- tool_calls = msg[:tool_calls] || msg["tool_calls"]
36
- if tool_calls && !tool_calls.empty?
37
- tool_calls_str = serialize_for_estimation(tool_calls)
38
- tokens += estimate_text(tool_calls_str)
35
+ if (content = ConversationRows.value_of(msg, :content))
36
+ tokens += estimate_text(content)
39
37
  end
40
38
 
41
- tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
42
- tokens += estimate_text(tool_call_id) if tool_call_id
39
+ if (tool_calls = ConversationRows.value_of(msg, :tool_calls)) && !tool_calls.empty?
40
+ tokens += estimate_text(serialize_for_estimation(tool_calls))
41
+ end
43
42
 
44
- tool_name = msg[:tool_name] || msg["tool_name"]
45
- tokens += estimate_text(tool_name) if tool_name
43
+ if (tool_call_id = ConversationRows.value_of(msg, :tool_call_id))
44
+ tokens += estimate_text(tool_call_id)
45
+ end
46
+
47
+ if (tool_name = ConversationRows.value_of(msg, :tool_name))
48
+ tokens += estimate_text(tool_name)
49
+ end
46
50
 
47
51
  tokens
48
52
  end