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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +102 -0
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- data/lib/rubik/ast/rust_extractor.rb +9 -2
- data/lib/rubik/ast/typescript_extractor.rb +13 -7
- data/lib/rubik/buffer/manager.rb +99 -69
- data/lib/rubik/config/defaults.rb +17 -20
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/context/git_lister.rb +3 -1
- data/lib/rubik/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- data/lib/rubik/lsp/client.rb +320 -96
- data/lib/rubik/lsp/manager.rb +83 -12
- data/lib/rubik/lsp/server_command.rb +102 -0
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
- data/lib/rubik/platform/process.rb +6 -6
- data/lib/rubik/platform/sanitize.rb +37 -16
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_context.rb +126 -104
- data/lib/rubik/repl/commands/agent_execution.rb +183 -155
- data/lib/rubik/repl/commands/agent_models.rb +112 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/buffer.rb +122 -88
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/mcp.rb +120 -92
- data/lib/rubik/repl/commands/session.rb +90 -59
- data/lib/rubik/repl/commands/workspace.rb +266 -246
- data/lib/rubik/repl/shell.rb +10 -1
- data/lib/rubik/runtime/discovery.rb +12 -6
- data/lib/rubik/runtime/overlay_storage.rb +59 -2
- data/lib/rubik/sandbox/driver/docker.rb +2 -2
- data/lib/rubik/session/replayer.rb +1 -2
- data/lib/rubik/storage/store.rb +3 -2
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
- data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
- data/lib/rubik/tools/builtins/lsp.rb +36 -8
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +2 -1
data/lib/rubik/lsp/manager.rb
CHANGED
|
@@ -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] && (
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
167
|
-
)]]
|
|
184
|
+
hint: "the ledger root is not replaceable; address sections individually (/tasks/3/status, /findings/-, ...)"
|
|
185
|
+
)
|
|
168
186
|
end
|
|
169
187
|
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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}#{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|