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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +79 -0
  4. data/lib/rubik/ast/cache_store.rb +4 -2
  5. data/lib/rubik/ast/graph.rb +49 -94
  6. data/lib/rubik/ast/graph_extensions.rb +202 -0
  7. data/lib/rubik/ast/ruby_extractor.rb +8 -3
  8. data/lib/rubik/ast/symbol.rb +7 -0
  9. data/lib/rubik/ast/treesitter/base_walker.rb +4 -3
  10. data/lib/rubik/ast/treesitter/c_walker.rb +10 -8
  11. data/lib/rubik/ast/treesitter/go_walker.rb +4 -2
  12. data/lib/rubik/ast/treesitter/rust_walker.rb +9 -7
  13. data/lib/rubik/ast/treesitter/typescript_walker.rb +7 -5
  14. data/lib/rubik/ast/treesitter/zig_walker.rb +44 -6
  15. data/lib/rubik/config/defaults.rb +3 -6
  16. data/lib/rubik/config/store.rb +1 -1
  17. data/lib/rubik/lsp/client.rb +102 -24
  18. data/lib/rubik/lsp/manager.rb +136 -83
  19. data/lib/rubik/orchestrator/subagent/budget.rb +17 -10
  20. data/lib/rubik/orchestrator/subagent/instance.rb +14 -2
  21. data/lib/rubik/orchestrator/subagent/journal.rb +56 -13
  22. data/lib/rubik/orchestrator/subagent/reuse.rb +1 -1
  23. data/lib/rubik/orchestrator/subagent/run_cycle.rb +8 -5
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +7 -1
  25. data/lib/rubik/orchestrator/tool_circuit_breaker.rb +15 -5
  26. data/lib/rubik/platform/process.rb +1 -1
  27. data/lib/rubik/platform/sanitize.rb +4 -4
  28. data/lib/rubik/platform.rb +2 -2
  29. data/lib/rubik/prompt/token_estimator.rb +15 -3
  30. data/lib/rubik/repl/ast_warmup.rb +35 -4
  31. data/lib/rubik/repl/commands/subagents.rb +12 -11
  32. data/lib/rubik/repl/interrupt_handler.rb +10 -10
  33. data/lib/rubik/repl/shell.rb +1 -1
  34. data/lib/rubik/tools/builtins/subagent.rb +7 -2
  35. data/lib/rubik/tools/builtins/symbols.rb +56 -20
  36. data/lib/rubik/tools/symbol_tool_support.rb +113 -0
  37. data/lib/rubik/version.rb +1 -1
  38. metadata +3 -1
@@ -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
- client = language_or_client.is_a?(Client) ? language_or_client : client_for(language_or_client)
97
- return nil unless client
98
+ lang = language_for(language_or_client)
99
+ return nil unless lang
98
100
 
99
- if client.running && client.respond_to?(:alive?) && !client.alive?
100
- lang = client.language
101
- record_crash(lang)
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 = if language_or_file.to_s.include?(".")
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 do
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
- effective_ctx = @context || @runtime_manager&.context
208
- if effective_ctx
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
- begin
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
- @clients.each_value(&:stop)
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: "LSP not active for '#{file_path}'" } unless client&.running
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: "LSP not active for '#{file_path}'" } unless client&.running
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: "LSP not active for '#{file_path}'" } unless client&.running
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
- @max_steps = (max_steps || DEFAULT_MAX_STEPS).to_i
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 -> +=, atomically. Returns the
66
- # [extra, total] pair Instance's note lambda needs. Terminal-state
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
- extra = Budget.normalize_grant(steps)
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 of the grant invariant, shared by grant! (mutation edge)
84
- # and the reporting edges (manage_subagents steps arg, REPL
85
- # !subagents extend arg): nil/non-positive -> DEFAULT_GRANT_STEPS, so
86
- # the number a caller echoes can never disagree with the number
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
- @budget = Budget.new(max_steps: max_steps)
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, grants_received: @grants_received, run: @run_seq,
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 final `journal_truncated` record marks the boundary and
26
- # further events are not persisted (a cancellation outcome is recorded
27
- # when the handler can still write one). Tool payloads are previewed
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 = 2_097_152
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
- @seq = self.class.read(@path).records.length
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
- @closed = false
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
- return nil if @closed
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" => event.to_s, "data" => Rubik::Security::Redactor.mask_value(data) }
79
+ "event" => event_name, "data" => Rubik::Security::Redactor.mask_value(data) }
73
80
  row["call_id"] = call_id.to_s if call_id
74
- line = "#{Rubik::Platform.safe_json_generate(row)}\n"
75
- if @bytes + line.bytesize + MARKER_RESERVE > @max_bytes
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, "reason" => "retention cap reached; further events are not journaled" } }
244
- line = "#{Rubik::Platform.safe_json_generate(marker)}\n"
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.\n\n" \
73
- "A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). Pace yourself: " \
74
- "when remaining steps (Y - X) run low, conclude the task or prepare a progress summary for the maestro " \
75
- "supervisor — it may extend your budget or steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
76
- "ask_question tool — it blocks until the maestro answers or times out; ask only when the answer truly changes your work.\n\n" \
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
- @default_max_steps = (config&.fetch("subagents.default_max_steps", Budget::DEFAULT_MAX_STEPS) || Budget::DEFAULT_MAX_STEPS).to_i
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
- # one probe through the half-open gate
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
- true
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
- cooldown_elapsed?
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