rubik 0.3.1 → 0.3.3
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 +79 -0
- data/lib/rubik/ast/cache_store.rb +4 -2
- data/lib/rubik/ast/graph.rb +49 -94
- data/lib/rubik/ast/graph_extensions.rb +202 -0
- data/lib/rubik/ast/ruby_extractor.rb +8 -3
- data/lib/rubik/ast/symbol.rb +7 -0
- data/lib/rubik/ast/treesitter/base_walker.rb +4 -3
- data/lib/rubik/ast/treesitter/c_walker.rb +10 -8
- data/lib/rubik/ast/treesitter/go_walker.rb +4 -2
- data/lib/rubik/ast/treesitter/rust_walker.rb +9 -7
- data/lib/rubik/ast/treesitter/typescript_walker.rb +7 -5
- data/lib/rubik/ast/treesitter/zig_walker.rb +44 -6
- data/lib/rubik/config/defaults.rb +3 -6
- data/lib/rubik/config/store.rb +1 -1
- data/lib/rubik/lsp/client.rb +102 -24
- data/lib/rubik/lsp/manager.rb +136 -83
- data/lib/rubik/orchestrator/subagent/budget.rb +17 -10
- data/lib/rubik/orchestrator/subagent/instance.rb +14 -2
- data/lib/rubik/orchestrator/subagent/journal.rb +56 -13
- data/lib/rubik/orchestrator/subagent/reuse.rb +1 -1
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +8 -5
- data/lib/rubik/orchestrator/subagent/supervisor.rb +7 -1
- data/lib/rubik/orchestrator/tool_circuit_breaker.rb +15 -5
- data/lib/rubik/platform/process.rb +1 -1
- data/lib/rubik/platform/sanitize.rb +4 -4
- data/lib/rubik/platform.rb +2 -2
- data/lib/rubik/prompt/token_estimator.rb +15 -3
- data/lib/rubik/repl/ast_warmup.rb +35 -4
- data/lib/rubik/repl/commands/subagents.rb +12 -11
- data/lib/rubik/repl/interrupt_handler.rb +10 -10
- data/lib/rubik/repl/shell.rb +1 -1
- data/lib/rubik/tools/builtins/subagent.rb +7 -2
- data/lib/rubik/tools/builtins/symbols.rb +56 -20
- data/lib/rubik/tools/symbol_tool_support.rb +113 -0
- data/lib/rubik/version.rb +1 -1
- metadata +3 -1
data/lib/rubik/lsp/manager.rb
CHANGED
|
@@ -42,6 +42,8 @@ module Rubik
|
|
|
42
42
|
@context = context || runtime_manager&.context
|
|
43
43
|
@clients = {}
|
|
44
44
|
@crash_stats = {}
|
|
45
|
+
@recovery_errors = {}
|
|
46
|
+
@registered_disposable_langs = {}
|
|
45
47
|
@monitor = Monitor.new
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -93,82 +95,20 @@ module Rubik
|
|
|
93
95
|
def ensure_healthy!(language_or_client)
|
|
94
96
|
return nil unless enabled?
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
return nil unless
|
|
98
|
+
lang = language_for(language_or_client)
|
|
99
|
+
return nil unless lang
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
@monitor.synchronize do
|
|
103
|
-
begin
|
|
104
|
-
client.stop
|
|
105
|
-
rescue
|
|
106
|
-
nil
|
|
107
|
-
end
|
|
108
|
-
@clients.delete(lang)
|
|
109
|
-
end
|
|
110
|
-
start_client(lang) unless circuit_open?(lang)
|
|
111
|
-
client = @clients[lang]
|
|
112
|
-
elsif !client.running && @config.fetch("lsp.auto_start", true) && client.available? && !circuit_open?(client.language)
|
|
113
|
-
start_client(client.language)
|
|
114
|
-
client = @clients[client.language]
|
|
101
|
+
@monitor.synchronize do
|
|
102
|
+
preferred = language_or_client if language_or_client.is_a?(Client)
|
|
103
|
+
client_for_unlocked(lang, preferred)
|
|
115
104
|
end
|
|
116
|
-
|
|
117
|
-
client
|
|
118
105
|
end
|
|
119
106
|
|
|
120
107
|
def client_for(language_or_file)
|
|
121
|
-
lang =
|
|
122
|
-
detect_language(language_or_file)
|
|
123
|
-
else
|
|
124
|
-
language_or_file.to_s.downcase
|
|
125
|
-
end
|
|
126
|
-
|
|
108
|
+
lang = language_for(language_or_file)
|
|
127
109
|
return nil unless lang
|
|
128
110
|
|
|
129
|
-
@monitor.synchronize
|
|
130
|
-
if @clients.key?(lang)
|
|
131
|
-
client = @clients[lang]
|
|
132
|
-
if client.running && client.respond_to?(:alive?) && !client.alive?
|
|
133
|
-
record_crash(lang)
|
|
134
|
-
begin
|
|
135
|
-
client.stop
|
|
136
|
-
rescue
|
|
137
|
-
nil
|
|
138
|
-
end
|
|
139
|
-
@clients.delete(lang)
|
|
140
|
-
nil # Will trigger recreate / start below
|
|
141
|
-
else
|
|
142
|
-
client
|
|
143
|
-
end
|
|
144
|
-
elsif enabled?
|
|
145
|
-
configured_cmd = @config.fetch("lsp.servers.#{lang}") || Client::SERVERS[lang]
|
|
146
|
-
client = Client.new(lang, root_dir: @root_dir, server_cmd: configured_cmd)
|
|
147
|
-
unless client.available?
|
|
148
|
-
probed_cmd = Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir, custom_cmd: configured_cmd)
|
|
149
|
-
client = Client.new(lang, root_dir: @root_dir, server_cmd: probed_cmd) if probed_cmd
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
if client.available? && @config.fetch("lsp.auto_start", true) && !circuit_open?(lang)
|
|
153
|
-
if client.start
|
|
154
|
-
reset_circuit!(lang)
|
|
155
|
-
effective_ctx = @context || @runtime_manager&.context
|
|
156
|
-
if effective_ctx
|
|
157
|
-
effective_ctx.provide("lsp:#{lang}", client)
|
|
158
|
-
effective_ctx.register_disposable { client.stop if client.running }
|
|
159
|
-
end
|
|
160
|
-
else
|
|
161
|
-
record_crash(lang)
|
|
162
|
-
begin
|
|
163
|
-
client.stop
|
|
164
|
-
rescue StandardError
|
|
165
|
-
nil
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
@clients[lang] = client
|
|
170
|
-
end
|
|
171
|
-
end
|
|
111
|
+
@monitor.synchronize { client_for_unlocked(lang) }
|
|
172
112
|
end
|
|
173
113
|
|
|
174
114
|
def start_client(lang)
|
|
@@ -204,19 +144,12 @@ module Rubik
|
|
|
204
144
|
@clients[lang_str] = client
|
|
205
145
|
if res
|
|
206
146
|
reset_circuit!(lang_str)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
effective_ctx.provide("lsp:#{lang_str}", client)
|
|
210
|
-
effective_ctx.register_disposable { client.stop if client.running }
|
|
211
|
-
end
|
|
147
|
+
@recovery_errors.delete(lang_str)
|
|
148
|
+
register_client_unlocked(lang_str, client)
|
|
212
149
|
{ ok: true, message: "Started LSP server for '#{lang_str}' (#{client.server_cmd})" }
|
|
213
150
|
else
|
|
214
151
|
record_crash(lang_str)
|
|
215
|
-
|
|
216
|
-
client.stop
|
|
217
|
-
rescue StandardError
|
|
218
|
-
nil
|
|
219
|
-
end
|
|
152
|
+
stop_client_safely(client)
|
|
220
153
|
failure = Runtime::DiagnosticEngine.diagnose(
|
|
221
154
|
cmd: client.server_cmd,
|
|
222
155
|
exit_code: 1,
|
|
@@ -241,13 +174,19 @@ module Rubik
|
|
|
241
174
|
|
|
242
175
|
client.stop
|
|
243
176
|
@clients.delete(lang_str)
|
|
177
|
+
effective_ctx = @context || @runtime_manager&.context
|
|
178
|
+
effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
|
|
244
179
|
{ ok: true, message: "Stopped LSP server for '#{lang_str}'" }
|
|
245
180
|
end
|
|
246
181
|
end
|
|
247
182
|
|
|
248
183
|
def stop_all
|
|
249
184
|
@monitor.synchronize do
|
|
250
|
-
@
|
|
185
|
+
effective_ctx = @context || @runtime_manager&.context
|
|
186
|
+
@clients.each do |lang_str, client|
|
|
187
|
+
client.stop
|
|
188
|
+
effective_ctx&.unprovide("lsp:#{lang_str}") if effective_ctx.respond_to?(:unprovide)
|
|
189
|
+
end
|
|
251
190
|
@clients.clear
|
|
252
191
|
end
|
|
253
192
|
end
|
|
@@ -317,21 +256,21 @@ module Rubik
|
|
|
317
256
|
|
|
318
257
|
def hover(file_path, line, character)
|
|
319
258
|
client = client_for(file_path)
|
|
320
|
-
return { ok: false, error:
|
|
259
|
+
return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
|
|
321
260
|
|
|
322
261
|
client.hover(file_path, line, character)
|
|
323
262
|
end
|
|
324
263
|
|
|
325
264
|
def definition(file_path, line, character)
|
|
326
265
|
client = client_for(file_path)
|
|
327
|
-
return { ok: false, error:
|
|
266
|
+
return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
|
|
328
267
|
|
|
329
268
|
client.definition(file_path, line, character)
|
|
330
269
|
end
|
|
331
270
|
|
|
332
271
|
def references(file_path, line, character)
|
|
333
272
|
client = client_for(file_path)
|
|
334
|
-
return { ok: false, error:
|
|
273
|
+
return { ok: false, error: inactive_client_error(file_path, client) } unless client&.running
|
|
335
274
|
|
|
336
275
|
client.references(file_path, line, character)
|
|
337
276
|
end
|
|
@@ -361,6 +300,120 @@ module Rubik
|
|
|
361
300
|
servers: client_statuses
|
|
362
301
|
}
|
|
363
302
|
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
def language_for(language_or_file)
|
|
307
|
+
if language_or_file.is_a?(Client)
|
|
308
|
+
language_or_file.language.to_s.downcase
|
|
309
|
+
elsif language_or_file.to_s.include?(".")
|
|
310
|
+
detect_language(language_or_file)
|
|
311
|
+
else
|
|
312
|
+
language_or_file.to_s.downcase
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Caller MUST hold @monitor. Dead-client removal and replacement startup
|
|
317
|
+
# happen in one critical section so concurrent requests share one client.
|
|
318
|
+
def client_for_unlocked(lang, preferred = nil)
|
|
319
|
+
stored = @clients[lang]
|
|
320
|
+
client = stored || preferred
|
|
321
|
+
recovering = false
|
|
322
|
+
|
|
323
|
+
if client && client_dead?(client)
|
|
324
|
+
recovering = true
|
|
325
|
+
record_crash(lang)
|
|
326
|
+
stop_client_safely(client)
|
|
327
|
+
@clients.delete(lang) if stored.equal?(client)
|
|
328
|
+
client = nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if recovering && circuit_open?(lang)
|
|
332
|
+
@recovery_errors[lang] = "LSP server restart for '#{lang}' is blocked by the circuit breaker"
|
|
333
|
+
return nil
|
|
334
|
+
end
|
|
335
|
+
if recovering && !@config.fetch("lsp.auto_start", true)
|
|
336
|
+
@recovery_errors[lang] = "LSP server restart for '#{lang}' is disabled"
|
|
337
|
+
return nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
return nil unless client || enabled?
|
|
341
|
+
|
|
342
|
+
if client.nil?
|
|
343
|
+
configured_cmd = @config.fetch("lsp.servers.#{lang}") || Client::SERVERS[lang]
|
|
344
|
+
client = Client.new(lang, root_dir: @root_dir, server_cmd: configured_cmd)
|
|
345
|
+
unless client.available?
|
|
346
|
+
probed_cmd = Runtime::Discovery.probe_lsp(lang, root_dir: @root_dir, custom_cmd: configured_cmd)
|
|
347
|
+
client = Client.new(lang, root_dir: @root_dir, server_cmd: probed_cmd) if probed_cmd
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
if client.available? && @config.fetch("lsp.auto_start", true) && !circuit_open?(lang)
|
|
351
|
+
if client.start
|
|
352
|
+
reset_circuit!(lang)
|
|
353
|
+
@recovery_errors.delete(lang)
|
|
354
|
+
register_client_unlocked(lang, client)
|
|
355
|
+
else
|
|
356
|
+
record_crash(lang)
|
|
357
|
+
stop_client_safely(client)
|
|
358
|
+
@recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
|
|
359
|
+
end
|
|
360
|
+
elsif recovering
|
|
361
|
+
@recovery_errors[lang] = if circuit_open?(lang)
|
|
362
|
+
"LSP server restart for '#{lang}' is blocked by the circuit breaker"
|
|
363
|
+
elsif !@config.fetch("lsp.auto_start", true)
|
|
364
|
+
"LSP server restart for '#{lang}' is disabled"
|
|
365
|
+
else
|
|
366
|
+
"LSP server restart failed for '#{lang}': server unavailable"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
@clients[lang] = client
|
|
370
|
+
elsif !client.running && @config.fetch("lsp.auto_start", true) &&
|
|
371
|
+
client.available? && !circuit_open?(lang)
|
|
372
|
+
if client.start
|
|
373
|
+
reset_circuit!(lang)
|
|
374
|
+
@recovery_errors.delete(lang)
|
|
375
|
+
register_client_unlocked(lang, client)
|
|
376
|
+
else
|
|
377
|
+
record_crash(lang)
|
|
378
|
+
stop_client_safely(client)
|
|
379
|
+
@recovery_errors[lang] = "LSP server restart failed for '#{lang}'" if recovering
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
client
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def client_dead?(client)
|
|
387
|
+
(client.running && client.respond_to?(:alive?) && !client.alive?) ||
|
|
388
|
+
(client.respond_to?(:crashed?) && client.crashed?)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def register_client_unlocked(lang, client)
|
|
392
|
+
effective_ctx = @context || @runtime_manager&.context
|
|
393
|
+
return unless effective_ctx
|
|
394
|
+
|
|
395
|
+
effective_ctx.provide("lsp:#{lang}", client)
|
|
396
|
+
return if @registered_disposable_langs[lang] == effective_ctx.object_id
|
|
397
|
+
|
|
398
|
+
@registered_disposable_langs[lang] = effective_ctx.object_id
|
|
399
|
+
effective_ctx.register_disposable do
|
|
400
|
+
current = @clients[lang]
|
|
401
|
+
current.stop if current&.running
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def stop_client_safely(client)
|
|
406
|
+
client.stop
|
|
407
|
+
rescue StandardError
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def inactive_client_error(file_path, client)
|
|
412
|
+
lang = client&.language || detect_language(file_path)
|
|
413
|
+
@monitor.synchronize do
|
|
414
|
+
@recovery_errors[lang] || "LSP not active for '#{file_path}'"
|
|
415
|
+
end
|
|
416
|
+
end
|
|
364
417
|
end
|
|
365
418
|
end
|
|
366
419
|
end
|
|
@@ -28,9 +28,11 @@ module Rubik
|
|
|
28
28
|
class Budget
|
|
29
29
|
DEFAULT_MAX_STEPS = 88
|
|
30
30
|
DEFAULT_GRANT_STEPS = 20
|
|
31
|
+
DEFAULT_MAX_TOTAL_STEPS = 256
|
|
31
32
|
|
|
32
|
-
def initialize(max_steps: nil)
|
|
33
|
-
@
|
|
33
|
+
def initialize(max_steps: nil, max_total_steps: DEFAULT_MAX_TOTAL_STEPS)
|
|
34
|
+
@max_total_steps = [(max_total_steps || DEFAULT_MAX_TOTAL_STEPS).to_i, 1].max
|
|
35
|
+
@max_steps = [(max_steps || DEFAULT_MAX_STEPS).to_i, @max_total_steps].min
|
|
34
36
|
@current_step = 0
|
|
35
37
|
@monitor = Monitor.new
|
|
36
38
|
end
|
|
@@ -39,6 +41,10 @@ module Rubik
|
|
|
39
41
|
@monitor.synchronize { @max_steps }
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
def max_total_steps
|
|
45
|
+
@monitor.synchronize { @max_total_steps }
|
|
46
|
+
end
|
|
47
|
+
|
|
42
48
|
def current_step
|
|
43
49
|
@monitor.synchronize { @current_step }
|
|
44
50
|
end
|
|
@@ -62,14 +68,16 @@ module Rubik
|
|
|
62
68
|
@monitor.synchronize { "[Step #{@current_step}/#{@max_steps}]" }
|
|
63
69
|
end
|
|
64
70
|
|
|
65
|
-
# Normalize -> floor ->
|
|
66
|
-
#
|
|
71
|
+
# Normalize -> floor -> cap, atomically. Returns the [extra, total]
|
|
72
|
+
# pair Instance's note lambda needs. Repeated grants can never push a
|
|
73
|
+
# run past its configured lifetime ceiling. Terminal-state
|
|
67
74
|
# refusal, the :running transition, the history note, and the condvar
|
|
68
75
|
# broadcast stay on Instance — lifecycle facts, not budget facts.
|
|
69
76
|
# Called with the Instance monitor held (lock order: instance -> budget).
|
|
70
77
|
def grant!(steps)
|
|
71
78
|
@monitor.synchronize do
|
|
72
|
-
|
|
79
|
+
available = [@max_total_steps - @max_steps, 0].max
|
|
80
|
+
extra = [Budget.normalize_grant(steps), available].min
|
|
73
81
|
@max_steps += extra
|
|
74
82
|
[extra, @max_steps]
|
|
75
83
|
end
|
|
@@ -80,11 +88,10 @@ module Rubik
|
|
|
80
88
|
@monitor.synchronize { { current_step: @current_step, max_steps: @max_steps } }
|
|
81
89
|
end
|
|
82
90
|
|
|
83
|
-
# Pure half
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
# grant! applies. Idempotent.
|
|
91
|
+
# Pure normalization half shared by grant! and the reporting edges
|
|
92
|
+
# (manage_subagents steps arg, REPL !subagents extend arg). A grant
|
|
93
|
+
# may be smaller than this normalized request when the lifetime
|
|
94
|
+
# ceiling has less room remaining. Idempotent.
|
|
88
95
|
def self.normalize_grant(steps)
|
|
89
96
|
extra = (steps || DEFAULT_GRANT_STEPS).to_i
|
|
90
97
|
extra = DEFAULT_GRANT_STEPS if extra <= 0
|
|
@@ -52,6 +52,7 @@ module Rubik
|
|
|
52
52
|
# Budget delegation: step counters live in Subagent::Budget (reader contract preserved).
|
|
53
53
|
def current_step = @budget.current_step
|
|
54
54
|
def max_steps = @budget.max_steps
|
|
55
|
+
def max_total_steps = @budget.max_total_steps
|
|
55
56
|
|
|
56
57
|
def initialize(id:, parent_id:, role:, type_name:, prompt:, context:, tool_registry:,
|
|
57
58
|
system_prompt: nil, workspace: "inherit", model: "inherit",
|
|
@@ -72,7 +73,15 @@ module Rubik
|
|
|
72
73
|
@tool_registry = tool_registry
|
|
73
74
|
@provider = provider
|
|
74
75
|
@buffer_manager = buffer_manager
|
|
75
|
-
|
|
76
|
+
max_total_steps = if config.is_a?(Hash)
|
|
77
|
+
config["subagents.max_total_steps"] || config.dig("subagents", "max_total_steps") ||
|
|
78
|
+
Budget::DEFAULT_MAX_TOTAL_STEPS
|
|
79
|
+
elsif config.respond_to?(:fetch)
|
|
80
|
+
config.fetch("subagents.max_total_steps", Budget::DEFAULT_MAX_TOTAL_STEPS)
|
|
81
|
+
else
|
|
82
|
+
Budget::DEFAULT_MAX_TOTAL_STEPS
|
|
83
|
+
end
|
|
84
|
+
@budget = Budget.new(max_steps: max_steps, max_total_steps: max_total_steps)
|
|
76
85
|
@root_dir = root_dir
|
|
77
86
|
@config = config
|
|
78
87
|
@event_bus = event_bus
|
|
@@ -189,7 +198,8 @@ module Rubik
|
|
|
189
198
|
def to_h
|
|
190
199
|
@monitor.synchronize do
|
|
191
200
|
{ id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
|
|
192
|
-
**@budget.to_h,
|
|
201
|
+
**@budget.to_h, max_total_steps: @budget.max_total_steps,
|
|
202
|
+
grants_received: @grants_received, run: @run_seq,
|
|
193
203
|
result_ref: result_ref, owner: @owner_task&.id, detached: @owner_task.nil?, uptime_seconds: uptime.round(2),
|
|
194
204
|
operation: operations.current, timings: operations.timings,
|
|
195
205
|
last_progress_seconds_ago: operations.last_progress_seconds_ago,
|
|
@@ -230,6 +240,8 @@ module Rubik
|
|
|
230
240
|
return false if terminal?
|
|
231
241
|
|
|
232
242
|
extra, total = @budget.grant!(steps)
|
|
243
|
+
return false if extra <= 0
|
|
244
|
+
|
|
233
245
|
transition!(:running)
|
|
234
246
|
@grants_received += 1
|
|
235
247
|
@pause_facts = nil # the wake retires the wait fact with the pause (B3's clearer, now fact-only)
|
|
@@ -22,9 +22,9 @@ module Rubik
|
|
|
22
22
|
#
|
|
23
23
|
# Retention is bounded independently of the model context: at most
|
|
24
24
|
# `subagents.journal.max_bytes` per execution file. Once the cap is
|
|
25
|
-
# reached a
|
|
26
|
-
#
|
|
27
|
-
#
|
|
25
|
+
# reached a `journal_truncated` record marks the boundary; detail events
|
|
26
|
+
# stop, while reserved space preserves the terminal run/cancellation
|
|
27
|
+
# outcome. Tool payloads are previewed
|
|
28
28
|
# (PREVIEW_CHARS) with explicit `output_chars` / `output_truncated`
|
|
29
29
|
# metadata plus an `artifact_ref` to the conversation snapshot, so replay
|
|
30
30
|
# can distinguish originals from summaries from clipped output.
|
|
@@ -37,10 +37,12 @@ module Rubik
|
|
|
37
37
|
# in Instance#to_h), never raised into the run.
|
|
38
38
|
class Journal
|
|
39
39
|
FILENAME = "journal.jsonl"
|
|
40
|
-
DEFAULT_MAX_BYTES =
|
|
40
|
+
DEFAULT_MAX_BYTES = 8_388_608
|
|
41
41
|
# Headroom kept free for the final truncation marker: accepted records
|
|
42
42
|
# never consume the last bytes, so the marker always fits inside the cap.
|
|
43
43
|
MARKER_RESERVE = 512
|
|
44
|
+
TERMINAL_RESERVE = 16_384
|
|
45
|
+
TERMINAL_EVENTS = ["run_finished", "cancelled"].freeze
|
|
44
46
|
MIN_MAX_BYTES = 1024
|
|
45
47
|
PREVIEW_CHARS = 4000
|
|
46
48
|
ARGS_CHARS = 2000
|
|
@@ -54,9 +56,12 @@ module Rubik
|
|
|
54
56
|
@path = path.to_s
|
|
55
57
|
@monitor = Monitor.new
|
|
56
58
|
@max_bytes = [config_get(config, "subagents.journal.max_bytes", DEFAULT_MAX_BYTES).to_i, MIN_MAX_BYTES].max
|
|
57
|
-
|
|
59
|
+
existing_records = self.class.read(@path).records
|
|
60
|
+
@seq = existing_records.map { |record| record["seq"].to_i }.max || 0
|
|
58
61
|
@bytes = File.exist?(@path) ? File.size(@path) : 0
|
|
59
|
-
@
|
|
62
|
+
@marker_reserve = [MARKER_RESERVE, @max_bytes / 4].min
|
|
63
|
+
@terminal_reserve = [TERMINAL_RESERVE, @max_bytes / 3].min
|
|
64
|
+
@closed = existing_records.any? { |record| record["event"] == "journal_truncated" }
|
|
60
65
|
@last_error = nil
|
|
61
66
|
@run_metrics = {}
|
|
62
67
|
end
|
|
@@ -65,16 +70,24 @@ module Rubik
|
|
|
65
70
|
# record envelope; the payload is masked HERE (single redaction gate).
|
|
66
71
|
def append(event, run: 1, data: {}, call_id: nil)
|
|
67
72
|
@monitor.synchronize do
|
|
68
|
-
|
|
73
|
+
event_name = event.to_s
|
|
74
|
+
terminal = TERMINAL_EVENTS.include?(event_name)
|
|
75
|
+
return nil if @closed && !terminal
|
|
69
76
|
|
|
70
77
|
@last_run = run.to_i
|
|
71
78
|
row = { "seq" => @seq + 1, "run" => @last_run, "ts" => Time.now.utc.iso8601(3),
|
|
72
|
-
"event" =>
|
|
79
|
+
"event" => event_name, "data" => Rubik::Security::Redactor.mask_value(data) }
|
|
73
80
|
row["call_id"] = call_id.to_s if call_id
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
|
|
82
|
+
return append_terminal!(row) if @closed
|
|
83
|
+
|
|
84
|
+
line = serialize(row)
|
|
85
|
+
if @bytes + line.bytesize + @marker_reserve + @terminal_reserve > @max_bytes
|
|
76
86
|
mark_truncated
|
|
77
|
-
return nil
|
|
87
|
+
return nil unless terminal
|
|
88
|
+
|
|
89
|
+
row["seq"] = @seq + 1
|
|
90
|
+
return append_terminal!(row)
|
|
78
91
|
end
|
|
79
92
|
return nil unless write!(line)
|
|
80
93
|
|
|
@@ -199,6 +212,35 @@ module Rubik
|
|
|
199
212
|
|
|
200
213
|
private
|
|
201
214
|
|
|
215
|
+
def serialize(row) = "#{Rubik::Platform.safe_json_generate(row)}\n"
|
|
216
|
+
|
|
217
|
+
def append_terminal!(row)
|
|
218
|
+
line = serialize(row)
|
|
219
|
+
if @bytes + line.bytesize > @max_bytes
|
|
220
|
+
row["data"] = compact_terminal_data(row["data"])
|
|
221
|
+
line = serialize(row)
|
|
222
|
+
end
|
|
223
|
+
return nil if @bytes + line.bytesize > @max_bytes || !write!(line)
|
|
224
|
+
|
|
225
|
+
@seq += 1
|
|
226
|
+
@bytes += line.bytesize
|
|
227
|
+
row
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def compact_terminal_data(data)
|
|
231
|
+
return {} unless data.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
summary = data.slice("state", "outcome", "duration_ms", "step_count")
|
|
234
|
+
["state", "outcome"].each do |key|
|
|
235
|
+
summary[key] = summary[key].to_s.slice(0, 64) if summary.key?(key)
|
|
236
|
+
end
|
|
237
|
+
summary["reason"] = data["reason"].to_s.slice(0, 256) if data["reason"]
|
|
238
|
+
result_ref = data["result_ref"]
|
|
239
|
+
summary["result_ref"] = result_ref.to_s.slice(0, 128) if result_ref
|
|
240
|
+
summary["terminal_summary_truncated"] = true
|
|
241
|
+
summary
|
|
242
|
+
end
|
|
243
|
+
|
|
202
244
|
def empty_run_metrics
|
|
203
245
|
{ tool_usage: {}, model_responses: 0, input_tokens: 0, output_tokens: 0,
|
|
204
246
|
input_token_responses: 0, output_token_responses: 0 }
|
|
@@ -240,8 +282,9 @@ module Rubik
|
|
|
240
282
|
@closed = true
|
|
241
283
|
marker = { "seq" => @seq + 1, "run" => (@last_run || 1).to_i, "ts" => Time.now.utc.iso8601(3),
|
|
242
284
|
"event" => "journal_truncated",
|
|
243
|
-
"data" => { "max_bytes" => @max_bytes,
|
|
244
|
-
|
|
285
|
+
"data" => { "max_bytes" => @max_bytes,
|
|
286
|
+
"reason" => "retention cap reached; detail events stop, terminal outcomes use reserved space" } }
|
|
287
|
+
line = serialize(marker)
|
|
245
288
|
return nil if @bytes + line.bytesize > @max_bytes || !write!(line)
|
|
246
289
|
|
|
247
290
|
@seq += 1
|
|
@@ -59,7 +59,7 @@ module Rubik
|
|
|
59
59
|
drain_inbox_into_history
|
|
60
60
|
@history << { role: "user", content: "[New task from maestro]: #{prompt}" }
|
|
61
61
|
@prompt = prompt.to_s
|
|
62
|
-
@budget = Budget.new(max_steps: max_steps.to_i)
|
|
62
|
+
@budget = Budget.new(max_steps: max_steps.to_i, max_total_steps: @budget.max_total_steps)
|
|
63
63
|
# @result reset is the ONE sanctioned clearer (terminal-only
|
|
64
64
|
# contract): a new lineage begins. @pause_facts rides along —
|
|
65
65
|
# the old run's wait fact must not narrate the new one.
|
|
@@ -69,11 +69,14 @@ module Rubik
|
|
|
69
69
|
def build_effective_system_prompt
|
|
70
70
|
base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
|
|
71
71
|
"Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
|
|
72
|
-
"Allocated Step Budget: #{@budget.max_steps} steps
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
72
|
+
"Allocated Step Budget: #{@budget.max_steps} steps; lifetime ceiling: " \
|
|
73
|
+
"#{@budget.max_total_steps} steps across all grants.\n\n" \
|
|
74
|
+
"A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). " \
|
|
75
|
+
"Pace yourself: when remaining steps (Y - X) run low, conclude the task or prepare a progress " \
|
|
76
|
+
"summary for the maestro supervisor — it may extend your budget up to the lifetime ceiling or " \
|
|
77
|
+
"steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
|
|
78
|
+
"ask_question tool — it blocks until the maestro answers or times out; ask only when the answer " \
|
|
79
|
+
"truly changes your work.\n\n" \
|
|
77
80
|
"Perform your assigned task with high precision and provide a structured synthesis report."
|
|
78
81
|
@system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
|
|
79
82
|
end
|
|
@@ -38,6 +38,7 @@ module Rubik
|
|
|
38
38
|
class Supervisor
|
|
39
39
|
attr_accessor :tool_registry, :router, :init
|
|
40
40
|
attr_reader :root_context, :type_registry, :instances, :max_depth, :max_concurrency, :default_max_steps,
|
|
41
|
+
:max_total_steps,
|
|
41
42
|
:provider_gate, :result_archive
|
|
42
43
|
|
|
43
44
|
include RedeploySupervisor
|
|
@@ -70,7 +71,12 @@ module Rubik
|
|
|
70
71
|
@counter = 0
|
|
71
72
|
@max_depth = (config&.fetch("subagents.max_depth", 2) || 2).to_i
|
|
72
73
|
@max_concurrency = (config&.fetch("subagents.max_concurrency", 10) || 10).to_i
|
|
73
|
-
|
|
74
|
+
configured_max_total_steps = config&.fetch("subagents.max_total_steps", Budget::DEFAULT_MAX_TOTAL_STEPS) ||
|
|
75
|
+
Budget::DEFAULT_MAX_TOTAL_STEPS
|
|
76
|
+
@max_total_steps = [configured_max_total_steps.to_i, 1].max
|
|
77
|
+
configured_default_steps = config&.fetch("subagents.default_max_steps", Budget::DEFAULT_MAX_STEPS) ||
|
|
78
|
+
Budget::DEFAULT_MAX_STEPS
|
|
79
|
+
@default_max_steps = [configured_default_steps.to_i, @max_total_steps].min
|
|
74
80
|
@provider_concurrency = (config&.fetch("subagents.provider_concurrency", 2) || 2).to_i
|
|
75
81
|
@provider_gate = ConcurrencyGate.new(@provider_concurrency)
|
|
76
82
|
# Retained completed-run results (issue #122): session-scoped,
|
|
@@ -35,15 +35,25 @@ module Rubik
|
|
|
35
35
|
when :open
|
|
36
36
|
return false unless cooldown_elapsed?(circuit)
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# Cooldown elapsed: enter half-open state and grant single probe
|
|
39
39
|
circuit[:state] = :half_open
|
|
40
40
|
circuit[:probing] = true
|
|
41
|
+
circuit[:opened_at] = now
|
|
41
42
|
true
|
|
42
43
|
when :half_open
|
|
43
44
|
if circuit[:probing]
|
|
44
|
-
|
|
45
|
+
# Probe is currently in flight. If probe wedged past cooldown,
|
|
46
|
+
# allow a new probe to prevent permanent deadlock.
|
|
47
|
+
if cooldown_elapsed?(circuit)
|
|
48
|
+
circuit[:opened_at] = now
|
|
49
|
+
true
|
|
50
|
+
else
|
|
51
|
+
false
|
|
52
|
+
end
|
|
45
53
|
else
|
|
46
|
-
|
|
54
|
+
circuit[:probing] = true
|
|
55
|
+
circuit[:opened_at] = now
|
|
56
|
+
true
|
|
47
57
|
end
|
|
48
58
|
else
|
|
49
59
|
true
|
|
@@ -53,7 +63,7 @@ module Rubik
|
|
|
53
63
|
|
|
54
64
|
def record_success(tool_name)
|
|
55
65
|
@monitor.synchronize do
|
|
56
|
-
@tools.delete(tool_name)
|
|
66
|
+
@tools.delete(tool_name.to_s)
|
|
57
67
|
end
|
|
58
68
|
end
|
|
59
69
|
|
|
@@ -76,7 +86,7 @@ module Rubik
|
|
|
76
86
|
private
|
|
77
87
|
|
|
78
88
|
def circuit(tool_name)
|
|
79
|
-
@tools[tool_name] ||= { failures: 0, opened_at: nil, probing: false, state: :closed }
|
|
89
|
+
@tools[tool_name.to_s] ||= { failures: 0, opened_at: nil, probing: false, state: :closed }
|
|
80
90
|
end
|
|
81
91
|
|
|
82
92
|
def cooldown_elapsed?(circuit)
|
|
@@ -92,7 +92,7 @@ module Rubik
|
|
|
92
92
|
# Plan 55 D-1 ile tutarlı dizi-form çağrı: cmd.exe ara katmanı ve kabuk
|
|
93
93
|
# yönlendirmesi ("> NUL 2>&1") yerine out:/err: seçenekleri kullanılır.
|
|
94
94
|
system("taskkill", "/F", "/T", "/PID", pid.to_s, out: File::NULL, err: File::NULL)
|
|
95
|
-
rescue
|
|
95
|
+
rescue StandardError
|
|
96
96
|
nil
|
|
97
97
|
end
|
|
98
98
|
else
|
|
@@ -39,7 +39,7 @@ module Rubik
|
|
|
39
39
|
debug_warn("clean_utf8 scrub fallback: #{e.class}: #{e.message}")
|
|
40
40
|
begin
|
|
41
41
|
str.to_s.scrub("")
|
|
42
|
-
rescue
|
|
42
|
+
rescue StandardError
|
|
43
43
|
""
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -108,7 +108,7 @@ module Rubik
|
|
|
108
108
|
fallback = sanitize_for_json_fallback(obj)
|
|
109
109
|
begin
|
|
110
110
|
JSON.generate(fallback, *)
|
|
111
|
-
rescue
|
|
111
|
+
rescue StandardError
|
|
112
112
|
"{}"
|
|
113
113
|
end
|
|
114
114
|
end
|
|
@@ -122,7 +122,7 @@ module Rubik
|
|
|
122
122
|
fallback = sanitize_for_json_fallback(obj)
|
|
123
123
|
begin
|
|
124
124
|
JSON.dump(fallback, *)
|
|
125
|
-
rescue
|
|
125
|
+
rescue StandardError
|
|
126
126
|
"{}"
|
|
127
127
|
end
|
|
128
128
|
end
|
|
@@ -148,7 +148,7 @@ module Rubik
|
|
|
148
148
|
debug_warn("sanitize_for_json_fallback rescue: #{e.class}: #{e.message}")
|
|
149
149
|
begin
|
|
150
150
|
obj.to_s.scrub("")
|
|
151
|
-
rescue
|
|
151
|
+
rescue StandardError
|
|
152
152
|
""
|
|
153
153
|
end
|
|
154
154
|
end
|