rubik 0.3.3 → 0.3.5

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +102 -0
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/rust_extractor.rb +9 -2
  7. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  8. data/lib/rubik/buffer/manager.rb +99 -69
  9. data/lib/rubik/config/defaults.rb +17 -20
  10. data/lib/rubik/config.rb +0 -6
  11. data/lib/rubik/context/git_lister.rb +3 -1
  12. data/lib/rubik/llm/anthropic.rb +5 -2
  13. data/lib/rubik/llm/client.rb +102 -72
  14. data/lib/rubik/llm/openai_compatible.rb +83 -68
  15. data/lib/rubik/lsp/client.rb +320 -96
  16. data/lib/rubik/lsp/manager.rb +83 -12
  17. data/lib/rubik/lsp/server_command.rb +102 -0
  18. data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
  19. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
  21. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  22. data/lib/rubik/platform/process.rb +6 -6
  23. data/lib/rubik/platform/sanitize.rb +37 -16
  24. data/lib/rubik/prompt/doctrine.rb +4 -0
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +183 -155
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/agent_providers.rb +190 -156
  29. data/lib/rubik/repl/commands/buffer.rb +122 -88
  30. data/lib/rubik/repl/commands/lsp.rb +212 -179
  31. data/lib/rubik/repl/commands/mcp.rb +120 -92
  32. data/lib/rubik/repl/commands/session.rb +90 -59
  33. data/lib/rubik/repl/commands/workspace.rb +266 -246
  34. data/lib/rubik/repl/shell.rb +10 -1
  35. data/lib/rubik/runtime/discovery.rb +12 -6
  36. data/lib/rubik/runtime/overlay_storage.rb +59 -2
  37. data/lib/rubik/sandbox/driver/docker.rb +2 -2
  38. data/lib/rubik/session/replayer.rb +1 -2
  39. data/lib/rubik/storage/store.rb +3 -2
  40. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  41. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  42. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  43. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  44. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  45. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  46. data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
  47. data/lib/rubik/version.rb +1 -1
  48. metadata +2 -1
@@ -29,8 +29,18 @@ module Rubik
29
29
  ".vue" => "vue"
30
30
  }.freeze
31
31
 
32
+ DOCUMENT_LANGUAGE_ID_MAP = {
33
+ ".cpp" => "cpp",
34
+ ".hpp" => "cpp",
35
+ ".tsx" => "typescriptreact",
36
+ ".jsx" => "javascriptreact",
37
+ ".sh" => "shellscript",
38
+ ".bash" => "shellscript"
39
+ }.freeze
40
+
32
41
  MAX_CONSECUTIVE_CRASHES = 3
33
42
  CIRCUIT_COOLDOWN_SECONDS = 300
43
+ CIRCUIT_STABILITY_SECONDS = 60
34
44
 
35
45
  attr_reader :config, :root_dir, :clients, :crash_stats
36
46
  attr_accessor :runtime_manager, :context
@@ -42,6 +52,7 @@ module Rubik
42
52
  @context = context || runtime_manager&.context
43
53
  @clients = {}
44
54
  @crash_stats = {}
55
+ @healthy_since = {}
45
56
  @recovery_errors = {}
46
57
  @registered_disposable_langs = {}
47
58
  @monitor = Monitor.new
@@ -60,12 +71,24 @@ module Rubik
60
71
  EXTENSION_MAP[ext]
61
72
  end
62
73
 
74
+ def document_language_id(file_path)
75
+ ext = File.extname(file_path).downcase
76
+ DOCUMENT_LANGUAGE_ID_MAP[ext] || detect_language(file_path) || "plaintext"
77
+ end
78
+
63
79
  def circuit_open?(lang)
64
80
  @monitor.synchronize do
65
81
  stat = @crash_stats[lang.to_s.downcase]
82
+ healthy_since = @healthy_since[lang.to_s.downcase]
83
+ if healthy_since && monotonic_now - healthy_since >= CIRCUIT_STABILITY_SECONDS
84
+ @crash_stats.delete(lang.to_s.downcase)
85
+ @healthy_since.delete(lang.to_s.downcase)
86
+ return false
87
+ end
88
+
66
89
  return false unless stat && stat[:circuit_open]
67
90
 
68
- if stat[:last_crash_at] && (Time.now - stat[:last_crash_at] > CIRCUIT_COOLDOWN_SECONDS)
91
+ if stat[:last_crash_at] && (monotonic_now - stat[:last_crash_at] > CIRCUIT_COOLDOWN_SECONDS)
69
92
  stat[:circuit_open] = false
70
93
  stat[:count] = 0
71
94
  false
@@ -78,9 +101,11 @@ module Rubik
78
101
  def record_crash(lang)
79
102
  @monitor.synchronize do
80
103
  lang_str = lang.to_s.downcase
104
+ healthy_since = @healthy_since.delete(lang_str)
105
+ @crash_stats.delete(lang_str) if healthy_since && monotonic_now - healthy_since >= CIRCUIT_STABILITY_SECONDS
81
106
  stat = (@crash_stats[lang_str] ||= { count: 0, last_crash_at: nil, circuit_open: false })
82
107
  stat[:count] += 1
83
- stat[:last_crash_at] = Time.now
108
+ stat[:last_crash_at] = monotonic_now
84
109
  stat[:circuit_open] = true if stat[:count] >= MAX_CONSECUTIVE_CRASHES
85
110
  end
86
111
  end
@@ -89,6 +114,7 @@ module Rubik
89
114
  @monitor.synchronize do
90
115
  lang_str = lang.to_s.downcase
91
116
  @crash_stats.delete(lang_str)
117
+ @healthy_since[lang_str] = monotonic_now
92
118
  end
93
119
  end
94
120
 
@@ -125,13 +151,13 @@ module Rubik
125
151
 
126
152
  unless client.available?
127
153
  failure = Runtime::DiagnosticEngine.diagnose(
128
- cmd: client.server_cmd,
154
+ cmd: ServerCommand.display(client.server_cmd),
129
155
  exit_code: 127,
130
- stderr: "Server binary '#{client.server_cmd}' not found in PATH"
156
+ stderr: "Server binary '#{ServerCommand.display(client.server_cmd)}' not found in PATH"
131
157
  )
132
158
  return {
133
159
  ok: false,
134
- error: "Server binary '#{client.server_cmd}' not found",
160
+ error: "Server binary '#{ServerCommand.display(client.server_cmd)}' not found",
135
161
  diagnostic: failure,
136
162
  suggestion: failure.suggestion
137
163
  }
@@ -146,12 +172,12 @@ module Rubik
146
172
  reset_circuit!(lang_str)
147
173
  @recovery_errors.delete(lang_str)
148
174
  register_client_unlocked(lang_str, client)
149
- { ok: true, message: "Started LSP server for '#{lang_str}' (#{client.server_cmd})" }
175
+ { ok: true, message: "Started LSP server for '#{lang_str}' (#{ServerCommand.display(client.server_cmd)})" }
150
176
  else
151
177
  record_crash(lang_str)
152
178
  stop_client_safely(client)
153
179
  failure = Runtime::DiagnosticEngine.diagnose(
154
- cmd: client.server_cmd,
180
+ cmd: ServerCommand.display(client.server_cmd),
155
181
  exit_code: 1,
156
182
  stderr: "Failed to initialize LSP server handshake for '#{lang_str}'"
157
183
  )
@@ -201,10 +227,19 @@ module Rubik
201
227
  if client.document_open?(file_path)
202
228
  client.did_change(file_path, content)
203
229
  else
204
- client.did_open(file_path, content)
230
+ client.did_open(file_path, content, document_language_id(file_path))
205
231
  end
206
232
  end
207
233
 
234
+ def did_save_file(file_path, content = nil)
235
+ return false unless enabled?
236
+
237
+ client = client_for(file_path)
238
+ return false unless client&.running
239
+
240
+ client.did_save(file_path, content)
241
+ end
242
+
208
243
  def sync_buffer_file_if_needed(file_path, buffer_manager)
209
244
  return false unless enabled? && buffer_manager
210
245
 
@@ -242,6 +277,20 @@ module Rubik
242
277
  client.diagnostics_for(file_path)
243
278
  end
244
279
 
280
+ def diagnostics_snapshot_for(file_path)
281
+ client = client_for(file_path)
282
+ return { state: :unavailable, diagnostics: [], document_version: nil, published_version: nil } unless client&.running
283
+
284
+ client.diagnostics_snapshot_for(file_path)
285
+ end
286
+
287
+ def wait_for_diagnostics(file_path, timeout: 0.2)
288
+ client = client_for(file_path)
289
+ return { state: :unavailable, diagnostics: [], document_version: nil, published_version: nil } unless client&.running
290
+
291
+ client.wait_for_diagnostics(file_path, timeout: timeout)
292
+ end
293
+
245
294
  def all_diagnostics
246
295
  result = {}
247
296
  @monitor.synchronize do
@@ -254,6 +303,18 @@ module Rubik
254
303
  result
255
304
  end
256
305
 
306
+ def all_diagnostic_snapshots
307
+ result = {}
308
+ @monitor.synchronize do
309
+ @clients.each_value do |client|
310
+ next unless client.running
311
+
312
+ result.merge!(client.all_diagnostic_snapshots)
313
+ end
314
+ end
315
+ result
316
+ end
317
+
257
318
  def hover(file_path, line, character)
258
319
  client = client_for(file_path)
259
320
  return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
@@ -285,11 +346,12 @@ module Rubik
285
346
  probed = avail ? cmd : Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir)
286
347
 
287
348
  client_statuses[lang] = {
288
- server_cmd: cmd,
349
+ server_cmd: ServerCommand.display(cmd),
289
350
  probed_cmd: probed,
290
351
  available: avail || !probed.nil?,
291
352
  running: active_c&.running || false,
292
- diagnostics_count: active_c ? active_c.all_diagnostics.values.flatten.size : 0
353
+ diagnostics_count: active_c ? active_c.all_diagnostics.values.flatten.size : 0,
354
+ diagnostics_pending_count: active_c ? active_c.all_diagnostic_snapshots.values.count { |s| s[:state] == :pending } : 0
293
355
  }
294
356
  end
295
357
  end
@@ -316,6 +378,7 @@ module Rubik
316
378
  # Caller MUST hold @monitor. Dead-client removal and replacement startup
317
379
  # happen in one critical section so concurrent requests share one client.
318
380
  def client_for_unlocked(lang, preferred = nil)
381
+ circuit_open?(lang) # also retires crash history after sustained healthy operation
319
382
  stored = @clients[lang]
320
383
  client = stored || preferred
321
384
  recovering = false
@@ -349,7 +412,7 @@ module Rubik
349
412
 
350
413
  if client.available? && @config.fetch("lsp.auto_start", true) && !circuit_open?(lang)
351
414
  if client.start
352
- reset_circuit!(lang)
415
+ note_healthy_start(lang)
353
416
  @recovery_errors.delete(lang)
354
417
  register_client_unlocked(lang, client)
355
418
  else
@@ -370,7 +433,7 @@ module Rubik
370
433
  elsif !client.running && @config.fetch("lsp.auto_start", true) &&
371
434
  client.available? && !circuit_open?(lang)
372
435
  if client.start
373
- reset_circuit!(lang)
436
+ note_healthy_start(lang)
374
437
  @recovery_errors.delete(lang)
375
438
  register_client_unlocked(lang, client)
376
439
  else
@@ -408,6 +471,14 @@ module Rubik
408
471
  nil
409
472
  end
410
473
 
474
+ def monotonic_now
475
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
476
+ end
477
+
478
+ def note_healthy_start(lang)
479
+ @healthy_since[lang.to_s.downcase] = monotonic_now
480
+ end
481
+
411
482
  def inactive_client_error(file_path, client)
412
483
  lang = client&.language || detect_language(file_path)
413
484
  @monitor.synchronize do
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../platform"
5
+
6
+ module Rubik
7
+ module LSP
8
+ # Converts configured LSP commands to argv without invoking a shell.
9
+ # Structured commands are portable; legacy strings use the native
10
+ # platform's command-line quoting rules.
11
+ module ServerCommand
12
+ module_function
13
+
14
+ def argv(command)
15
+ parts = case command
16
+ when Hash
17
+ executable = command["executable"] || command[:executable]
18
+ args = command["args"] || command[:args] || []
19
+ raise ArgumentError, "LSP command executable must be a non-empty string" unless executable.is_a?(String) && !executable.strip.empty?
20
+ raise ArgumentError, "LSP command args must be an array" unless args.is_a?(Array)
21
+ raise ArgumentError, "LSP command args must contain only strings" unless args.all?(String)
22
+
23
+ [executable, *args]
24
+ when Array
25
+ raise ArgumentError, "LSP argv must contain only strings" unless command.all?(String)
26
+
27
+ command
28
+ when String
29
+ Rubik::Platform.windows? ? split_windows(command) : Shellwords.shellsplit(command)
30
+ else
31
+ []
32
+ end
33
+
34
+ parts = parts.map(&:to_s)
35
+ return [] if parts.empty? || parts.first.to_s.strip.empty?
36
+ raise ArgumentError, "LSP command arguments cannot be nil" if parts.any?(&:empty?)
37
+
38
+ parts
39
+ rescue ArgumentError => e
40
+ raise ArgumentError, "Invalid LSP server command: #{e.message}"
41
+ end
42
+
43
+ def display(command)
44
+ argv(command).join(" ")
45
+ rescue ArgumentError
46
+ command.to_s
47
+ end
48
+
49
+ # Implements the Windows command-line quoting rules used by
50
+ # CommandLineToArgvW: backslashes only escape quotes when immediately
51
+ # preceding one; whitespace separates arguments outside quotes.
52
+ def split_windows(command)
53
+ args = []
54
+ arg = +""
55
+ arg_started = false
56
+ quote_char = nil
57
+ i = 0
58
+
59
+ while i < command.length
60
+ if command[i] == "\\" && quote_char != "'"
61
+ start = i
62
+ i += 1 while command[i] == "\\"
63
+ slashes = i - start
64
+ arg_started = true
65
+ if command[i] == '"'
66
+ arg << ("\\" * (slashes / 2))
67
+ if slashes.odd?
68
+ arg << '"'
69
+ else
70
+ quote_char = quote_char == '"' ? nil : '"'
71
+ end
72
+ i += 1
73
+ else
74
+ arg << ("\\" * slashes)
75
+ end
76
+ elsif ['"', "'"].include?(command[i]) && (quote_char.nil? || quote_char == command[i])
77
+ arg_started = true
78
+ quote_char = quote_char ? nil : command[i]
79
+ i += 1
80
+ elsif command[i] =~ /\s/ && quote_char.nil?
81
+ if arg_started
82
+ args << arg
83
+ arg = +""
84
+ arg_started = false
85
+ end
86
+ i += 1 while command[i] =~ /\s/
87
+ else
88
+ arg_started = true
89
+ arg << command[i]
90
+ i += 1
91
+ end
92
+ end
93
+
94
+ raise ArgumentError, "unmatched quote" if quote_char
95
+
96
+ args << arg if arg_started
97
+ args
98
+ end
99
+ private_class_method :split_windows
100
+ end
101
+ end
102
+ end
@@ -121,81 +121,98 @@ module Rubik
121
121
  end
122
122
 
123
123
  def self.normalize_op(raw, idx)
124
- return [nil, [op_shape_reason(idx)]] unless raw.is_a?(Hash)
124
+ err = validate_op_keys(raw, idx)
125
+ return [nil, [err]] if err
126
+
127
+ verb, path, err = validate_op_verb_and_path(raw, idx)
128
+ return [nil, [err]] if err
129
+
130
+ tokens, err = tokenize(path, idx)
131
+ return [nil, [err]] if err
132
+
133
+ err = validate_op_value_and_hyphen(raw, verb, path, tokens, idx)
134
+ return [nil, [err]] if err
135
+
136
+ value = verb == "remove" ? nil : deep_freeze(raw["value"])
137
+ [{ op: verb.to_sym, path: path, tokens: tokens, value: value, index: idx }.freeze, []]
138
+ end
139
+
140
+ def self.validate_op_keys(raw, idx)
141
+ return op_shape_reason(idx) unless raw.is_a?(Hash)
125
142
 
126
143
  unknown = raw.keys - OP_KEYS
127
- unless unknown.empty?
128
- return [nil, [GoalLedger.reason(
129
- :schema, op_index: idx, path: "",
130
- message: "op #{idx} carries unknown key(s) #{unknown.join(', ')}",
131
- hint: "allowed op keys: op, path, value"
132
- )]]
133
- end
144
+ return nil if unknown.empty?
145
+
146
+ GoalLedger.reason(
147
+ :schema, op_index: idx, path: "",
148
+ message: "op #{idx} carries unknown key(s) #{unknown.join(', ')}",
149
+ hint: "allowed op keys: op, path, value"
150
+ )
151
+ end
134
152
 
153
+ def self.validate_op_verb_and_path(raw, idx)
135
154
  verb = raw["op"]
136
155
  unless OP_VERBS.include?(verb)
137
- return [nil, [GoalLedger.reason(
156
+ return nil, nil, GoalLedger.reason(
138
157
  :schema, op_index: idx, path: "",
139
158
  message: "op #{idx} has unknown verb #{GoalLedger.json_type_name(verb)}#{verb_inspect(verb)}",
140
159
  hint: "allowed verbs: add, replace, remove"
141
- )]]
160
+ )
142
161
  end
143
162
 
144
163
  path = raw["path"]
145
164
  unless path.is_a?(String)
146
- return [nil, [GoalLedger.reason(
165
+ return nil, nil, GoalLedger.reason(
147
166
  :schema, op_index: idx, path: "",
148
167
  message: "op #{idx} path must be a JSON string (got #{GoalLedger.json_type_name(path)})",
149
168
  hint: 'address sections with JSON pointers like "/tasks/3/status" or "/findings/-"'
150
- )]]
169
+ )
151
170
  end
152
171
 
153
172
  unless path.start_with?("/")
154
- return [nil, [GoalLedger.reason(
173
+ return nil, nil, GoalLedger.reason(
155
174
  :schema, op_index: idx, path: path,
156
175
  message: "op #{idx} path '#{path}' is not a JSON pointer",
157
176
  hint: 'pointers start with "/" — use /section, /section/idx, /section/idx/field or /section/-'
158
- )]]
177
+ )
159
178
  end
160
179
 
161
180
  if path == "/"
162
- return [nil, [GoalLedger.reason(
181
+ return nil, nil, GoalLedger.reason(
163
182
  :schema, op_index: idx, path: path,
164
183
  message: "op #{idx} addresses the whole ledger root",
165
- hint: "the ledger root is not replaceable; address sections individually " \
166
- "(/tasks/3/status, /findings/-, ...)"
167
- )]]
184
+ hint: "the ledger root is not replaceable; address sections individually (/tasks/3/status, /findings/-, ...)"
185
+ )
168
186
  end
169
187
 
170
- tokens, err = tokenize(path, idx)
171
- return [nil, [err]] if err
188
+ [verb, path, nil]
189
+ end
172
190
 
191
+ def self.validate_op_value_and_hyphen(raw, verb, path, tokens, idx)
173
192
  if verb == "remove" && raw.key?("value")
174
- return [nil, [GoalLedger.reason(
193
+ return GoalLedger.reason(
175
194
  :schema, op_index: idx, path: path,
176
195
  message: "op #{idx} (remove) must not carry a value",
177
196
  hint: "to change a value use replace; remove deletes the item outright"
178
- )]]
197
+ )
179
198
  end
180
199
  unless verb == "remove" || raw.key?("value")
181
- return [nil, [GoalLedger.reason(
200
+ return GoalLedger.reason(
182
201
  :schema, op_index: idx, path: path,
183
202
  message: "op #{idx} (#{verb}) is missing its value",
184
203
  hint: 'carry the value in the op: {"op": "add", "path": "/findings/-", "value": ...}'
185
- )]]
204
+ )
186
205
  end
187
206
 
188
207
  if hyphen_violation(tokens, verb)
189
- return [nil, [GoalLedger.reason(
208
+ return GoalLedger.reason(
190
209
  :schema, op_index: idx, path: path,
191
210
  message: "op #{idx} path '#{path}' uses '-' outside an append position",
192
- hint: "'-' appends and may only be the final token of an add path " \
193
- "(e.g. /findings/-); use a numeric index for existing items"
194
- )]]
211
+ hint: "'-' appends and may only be the final token of an add path (e.g. /findings/-); use a numeric index for existing items"
212
+ )
195
213
  end
196
214
 
197
- value = verb == "remove" ? nil : deep_freeze(raw["value"])
198
- [{ op: verb.to_sym, path: path, tokens: tokens, value: value, index: idx }.freeze, []]
215
+ nil
199
216
  end
200
217
 
201
218
  def self.op_shape_reason(idx)
@@ -261,6 +278,7 @@ module Rubik
261
278
  end
262
279
 
263
280
  private_class_method :header_reasons, :normalize_op, :op_shape_reason, :verb_inspect,
281
+ :validate_op_keys, :validate_op_verb_and_path, :validate_op_value_and_hyphen,
264
282
  :tokenize, :hyphen_violation, :deep_freeze
265
283
  end
266
284
  end
@@ -36,17 +36,33 @@ module Rubik
36
36
  end
37
37
 
38
38
  def append_diagnostics(deps, written_file, tool_result)
39
- sleep 0.05
40
- diags = deps.lsp_manager.diagnostics_for(written_file)
39
+ snapshot = if deps.lsp_manager.respond_to?(:wait_for_diagnostics)
40
+ deps.lsp_manager.wait_for_diagnostics(written_file, timeout: 0.2)
41
+ else
42
+ deps.lsp_manager.diagnostics_snapshot_for(written_file)
43
+ end
44
+ return append_message(tool_result, "\n\n⏳ LSP diagnostics are pending for '#{written_file}'; no clean result is available yet.") if snapshot[:state] == :pending
45
+ return append_message(tool_result, "\n\nℹ️ LSP diagnostics are not available for '#{written_file}'.") if snapshot[:state] == :unavailable
46
+
47
+ diags = snapshot[:diagnostics]
41
48
  errors = diags.select { |d| d["severity"] == 1 }
42
- return tool_result if errors.empty?
49
+ if errors.empty?
50
+ return tool_result if snapshot[:state] == :fresh
51
+
52
+ return append_message(tool_result, "\n\nℹ️ LSP returned no errors for '#{written_file}', but omitted the document version; freshness is unverified.")
53
+ end
43
54
 
44
55
  err_lines = errors.map { |e| " • Line #{(e.dig('range', 'start', 'line') || 0) + 1}: #{e['message']}" }.join("\n")
45
56
  lsp_msg = "\n\n⚠️ [LSP Real-Time Compiler Diagnostics - #{errors.size} error(s)]:\n#{err_lines}\n" \
46
57
  "Please fix these compilation/type errors before proceeding."
58
+ lsp_msg = "\n\nℹ️ LSP omitted the document version; diagnostic freshness is unverified.#{lsp_msg}" if snapshot[:state] == :unversioned
59
+ append_message(tool_result, lsp_msg)
60
+ end
61
+
62
+ def append_message(tool_result, message)
47
63
  Rubik::Tools::Result.new(
48
64
  tool_name: tool_result.tool_name, call_id: tool_result.call_id, status: tool_result.status,
49
- output: "#{tool_result.output}#{lsp_msg}", duration_ms: tool_result.duration_ms,
65
+ output: "#{tool_result.output}#{message}", duration_ms: tool_result.duration_ms,
50
66
  truncated: tool_result.truncated
51
67
  )
52
68
  end
@@ -114,8 +114,8 @@ module Rubik
114
114
  @instances[inst.id] = inst
115
115
  activate_instance!(inst, child_ctx: child_ctx, owner_token: eff_owner)
116
116
  end
117
- # SS bırakma kancası: redeploy da bir başlangıçtır — taze :running
118
- # flip'i, bu id'yi SS ile bekleyen kuyruk spesifikasyonlarını
117
+ # Start-to-Start (SS) bırakma kancası: redeploy da bir başlangıçtır — taze :running
118
+ # flip'i, bu id'yi Start-to-Start (SS) ile bekleyen kuyruk spesifikasyonlarını
119
119
  # bırakır (kilit DIŞINDA — emit kuralı; spawn ile aynı düzen).
120
120
  release_ready!
121
121
  redeployed
@@ -20,10 +20,7 @@ module Rubik
20
20
  def spawn(role:, prompt:, type_name: "research", system_prompt: nil,
21
21
  parent_id: ConversationId::ROOT_ID, workspace: "inherit", model: "inherit",
22
22
  max_steps: nil, provider: nil, depends_on: nil, reserved_id: nil, owner: nil)
23
- role_str = role.to_s.strip
24
- prompt_str = prompt.to_s.strip
25
- raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
26
- raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
23
+ role_str, prompt_str = validate_spawn_args!(role, prompt)
27
24
 
28
25
  # Issue #125: the owner's task was cancelled — refuse before any
29
26
  # reservation, slot or context work happens.
@@ -44,7 +41,6 @@ module Rubik
44
41
 
45
42
  profile_snapshot = resolve_profile_snapshot(type_name: type_name, role: role_str,
46
43
  system_prompt: system_prompt)
47
- effective_sys_prompt = profile_snapshot.effective_system_prompt
48
44
  # A profile update must not redirect rollback to a newly provided
49
45
  # init supervisor after this spawn registered its unit.
50
46
  activation_init = init_for_activation
@@ -52,40 +48,23 @@ module Rubik
52
48
  # unchanged built-in prompt can be reconstructed from its type.
53
49
  prompt_reconstructible = system_prompt.to_s.strip.empty? && !profile_snapshot.custom_prompt
54
50
 
55
- # Queued specs already reserved this id; fresh spawns reserve the
56
- # next sequence number here and return it if activation rolls back.
57
- generated_id = reserved_id.nil?
58
- subagent_id = generated_id ? "subagent-#{@counter + 1}" : reserved_id.to_s
59
- id_in_use = @instances.key?(subagent_id) || (@finished || {}).key?(subagent_id)
60
- raise ArgumentError, "Subagent id '#{subagent_id}' is already in use." if id_in_use
61
-
62
- @counter += 1 if generated_id
51
+ subagent_id, generated_id = reserve_subagent_id!(reserved_id)
63
52
 
64
53
  child_ctx = nil
65
54
  inst = nil
66
55
  begin
67
56
  child_ctx = extend_root_context!(:spawn)
68
- effective_buffer = branch_or_inherit_buffer(workspace)
69
- scoped_registry = build_scoped_registry(profile_snapshot, child_ctx, effective_buffer)
70
- prov, effective_model = resolve_provider_and_model(model)
71
- prov = provider if provider
72
-
73
- inst = Instance.new(
74
- id: subagent_id, parent_id: parent_id, role: role_str, type_name: profile_snapshot.name,
75
- prompt: prompt_str, system_prompt: effective_sys_prompt, workspace: workspace,
76
- model: effective_model, context: child_ctx, tool_registry: scoped_registry,
77
- provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
78
- provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
79
- context_limit: (@config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768),
80
- event_bus: @event_bus, transcript_storage: absorb_storage, owner_task: owner,
81
- prompt_reconstructible: prompt_reconstructible, profile_snapshot: profile_snapshot
57
+ inst = build_subagent_instance(
58
+ subagent_id: subagent_id, parent_id: parent_id, role_str: role_str, prompt_str: prompt_str,
59
+ profile_snapshot: profile_snapshot, workspace: workspace, model: model, max_steps: max_steps,
60
+ provider: provider, owner: owner, prompt_reconstructible: prompt_reconstructible,
61
+ child_ctx: child_ctx
82
62
  )
83
-
84
63
  @instances[subagent_id] = inst
85
- activate_instance!(inst, child_ctx: child_ctx, owner_token: owner,
86
- init_supervisor: activation_init, defer_start: true)
87
- raise "Subagent worker start gate could not be released." unless inst.release_start!
88
64
 
65
+ activate_and_release_subagent!(
66
+ inst: inst, child_ctx: child_ctx, owner: owner, activation_init: activation_init
67
+ )
89
68
  inst
90
69
  rescue StandardError
91
70
  rollback_spawn!(subagent_id: subagent_id, inst: inst, child_ctx: child_ctx,
@@ -93,6 +72,7 @@ module Rubik
93
72
  raise
94
73
  end
95
74
  end
75
+
96
76
  # SS bırakma kancası: yeni başlayan instance, onu bekleyen kuyruk
97
77
  # spesifikasyonlarını serbest bırakır (kilit DIŞINDA — emit kuralı).
98
78
  begin
@@ -105,6 +85,55 @@ module Rubik
105
85
 
106
86
  private
107
87
 
88
+ def validate_spawn_args!(role, prompt)
89
+ role_str = role.to_s.strip
90
+ prompt_str = prompt.to_s.strip
91
+ raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
92
+ raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
93
+
94
+ [role_str, prompt_str]
95
+ end
96
+
97
+ def reserve_subagent_id!(reserved_id)
98
+ # Queued specs already reserved this id; fresh spawns reserve the
99
+ # next sequence number here and return it if activation rolls back.
100
+ generated_id = reserved_id.nil?
101
+ subagent_id = generated_id ? "subagent-#{@counter + 1}" : reserved_id.to_s
102
+ id_in_use = @instances.key?(subagent_id) || (@finished || {}).key?(subagent_id)
103
+ raise ArgumentError, "Subagent id '#{subagent_id}' is already in use." if id_in_use
104
+
105
+ @counter += 1 if generated_id
106
+ [subagent_id, generated_id]
107
+ end
108
+
109
+ def build_subagent_instance(subagent_id:, parent_id:, role_str:, prompt_str:,
110
+ profile_snapshot:, workspace:, model:, max_steps:,
111
+ provider:, owner:, prompt_reconstructible:, child_ctx:)
112
+ effective_buffer = branch_or_inherit_buffer(workspace)
113
+ scoped_registry = build_scoped_registry(profile_snapshot, child_ctx, effective_buffer)
114
+ prov, effective_model = resolve_provider_and_model(model)
115
+ prov = provider if provider
116
+
117
+ context_limit = @config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768
118
+
119
+ Instance.new(
120
+ id: subagent_id, parent_id: parent_id, role: role_str, type_name: profile_snapshot.name,
121
+ prompt: prompt_str, system_prompt: profile_snapshot.effective_system_prompt, workspace: workspace,
122
+ model: effective_model, context: child_ctx, tool_registry: scoped_registry,
123
+ provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
124
+ provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
125
+ context_limit: context_limit, event_bus: @event_bus,
126
+ transcript_storage: absorb_storage, owner_task: owner,
127
+ prompt_reconstructible: prompt_reconstructible, profile_snapshot: profile_snapshot
128
+ )
129
+ end
130
+
131
+ def activate_and_release_subagent!(inst:, child_ctx:, owner:, activation_init:)
132
+ activate_instance!(inst, child_ctx: child_ctx, owner_token: owner,
133
+ init_supervisor: activation_init, defer_start: true)
134
+ raise "Subagent worker start gate could not be released." unless inst.release_start!
135
+ end
136
+
108
137
  def resolve_profile_snapshot(type_name:, role:, system_prompt:)
109
138
  reserved_name = RESERVED_ROLE_PROFILES[role.to_s.downcase.strip]
110
139
  if reserved_name
@@ -162,17 +162,17 @@ module Rubik
162
162
  return pids.reject(&:zero?) unless pids.empty?
163
163
  end
164
164
 
165
- out = begin
166
- `pgrep -P #{pid} 2>/dev/null`
165
+ out, = begin
166
+ Open3.capture2("pgrep", "-P", pid.to_s, err: File::NULL)
167
167
  rescue StandardError
168
- nil
168
+ [nil, nil]
169
169
  end
170
170
  return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
171
171
 
172
- out = begin
173
- `ps --ppid #{pid} -o pid= 2>/dev/null`
172
+ out, = begin
173
+ Open3.capture2("ps", "--ppid", pid.to_s, "-o", "pid=", err: File::NULL)
174
174
  rescue StandardError
175
- nil
175
+ [nil, nil]
176
176
  end
177
177
  return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
178
178