rubik 0.3.2 → 0.3.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc6b8b3ff7cb67e302bbb3a74237615515318acde17b8c41193a293dadef7576
4
- data.tar.gz: 3c0616b8f9b6544619903234c4595242b95c1998b2602aca584334eaf19fcc5d
3
+ metadata.gz: 220e106421fc0a5692890a2c3f3e7ba2296661c5ce60a96f62a3f8d5fcaa4c0a
4
+ data.tar.gz: 1a287e603bb553b11ebc1e0187f4a0d00e8e6b17a68c005311bc144635a1696a
5
5
  SHA512:
6
- metadata.gz: e714d3f5fb612958caba5ed92829af4262b55ac50390d5ed9262cdb8c81143886d613424c13f1fe585c4debead0758469b4aeadea855edcc02d14ef5b9eb5d9b
7
- data.tar.gz: cef0f0645353fe8c6f4413c39f6adff50c810c51720022774db779bce54de99a2555396f211a62af336be0be5c4a534f1c14c616a180c3c3344790ca56235772
6
+ metadata.gz: c02dbe25a9cc8a3af6a4c24187fc9115ad8ae5077c49e4842e2f03151ade01bedc4cc5e4709056a388a3934151b4a361422c30b1de92158e3d4c6d6d87c56743
7
+ data.tar.gz: db9835fd0913e3fad74ab68bdbd25137df95544579b2beed718237d09699e378fb2cbfde7da9341785f407d3aea9a43b98aedec34df1305278b93ddc3434adb6
data/CHANGELOG.md CHANGED
@@ -10,6 +10,83 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  Nothing yet.
12
12
 
13
+ ## [0.3.4] - 2026-10-23
14
+
15
+ A security-hardening patch: three injection/traversal findings in
16
+ child-process enumeration, the docker sandbox driver and overlay
17
+ storage are closed, scan-heavy paths got faster, and a wave of
18
+ oversized REPL/LLM methods was decomposed with no behavior change.
19
+
20
+ ### Fixed
21
+
22
+ - **Command injection in child-process enumeration (#238):**
23
+ `direct_child_pids` interpolated the target pid into backtick
24
+ shells (`pgrep -P #{pid}` / `ps --ppid #{pid}`); both lookups now
25
+ run through array-parameterized `Open3.capture2` calls, so pid
26
+ values never reach a shell for re-parsing.
27
+ - **Command injection in the docker sandbox driver (#237):**
28
+ `DockerDriver#available?` and container cleanup built command
29
+ strings with interpolation (`docker rm -f #{container_name}`);
30
+ both now use array-form `system`, keeping shell metacharacters
31
+ inert.
32
+ - **Path traversal in OverlayStorage (#232):** overlay directories
33
+ are validated against an allowlist of the four known overlay dirs
34
+ resolved under their trusted roots, manifests and every
35
+ command/tool write path are containment-checked, and traversal
36
+ attempts are rejected loudly with a `[SECURITY]` warning instead
37
+ of escaping the overlay root.
38
+
39
+ ### Performance
40
+
41
+ - **Faster scanning internals (#220, #225, #227, #228, #244):**
42
+ storage indexing parses JSON lines with less string churn;
43
+ `Session::Replayer` streams with `File.foreach`;
44
+ `Buffer::Manager#all_files` prunes `.git` traversal;
45
+ `FuzzyMatcher#find_closest_subsequence` tightens its inner loop;
46
+ and `clean_utf8_deep` gains an allocation-free fast path
47
+ (Symbol#name validation, Hash-based cycle tracking, ~3.3x on
48
+ clean input). `Buffer::Manager#diff_all` also reads through
49
+ `Platform.read_text`, so on-disk content is UTF-8-tagged
50
+ consistently (#226).
51
+
52
+ ### Changed
53
+
54
+ - **No-behavior-change decompositions (#231, #233, #234, #235,
55
+ #239, #240, #242, #243):** oversized REPL command registrations
56
+ (workspace allow-list, goal, agent providers, allow-cmd, LSP),
57
+ the LLM client's `post_json`, `GoalLedger::Patch#normalize_op`
58
+ and config `Defaults` scalar validation were split into helper
59
+ methods — same behavior, reviewable units.
60
+
61
+ ## [0.3.3] - 2026-10-21
62
+
63
+ A field-incident patch: subagent runs get a lifetime step ceiling,
64
+ budget grants stop lying about what they applied, and execution
65
+ journals keep their terminal outcomes after truncation.
66
+
67
+ ### Fixed
68
+
69
+ - **Subagent runs have a lifetime step ceiling (#219):** a live
70
+ incident showed one subagent at 950 model steps, 1,004 tool calls
71
+ and 10 budget grants — repeated grants could extend a run forever.
72
+ The new `subagents.max_total_steps` ceiling (default 256) bounds
73
+ the initial allocation plus every grant; grants that would exceed
74
+ it are truncated to the remaining room and partial or rejected
75
+ grants are reported as such, so the REPL and `manage_subagents`
76
+ echo the number that actually applied.
77
+ - **Execution journals keep their terminal outcomes (#219):** when a
78
+ journal hits its byte cap, truncation now preserves the terminal
79
+ `run_finished` and `cancelled` records instead of cutting exactly
80
+ the facts a post-mortem needs; the default per-execution journal
81
+ cap rises from 2 MiB to 8 MiB (`subagents.journal.max_bytes`).
82
+
83
+ ### Changed
84
+
85
+ - **Bare `rescue` clauses are explicit `rescue StandardError`
86
+ (#218):** standardization across platform, process, sanitize,
87
+ config store and the interrupt handler — same semantics, harder to
88
+ misread.
89
+
13
90
  ## [0.3.2] - 2026-10-21
14
91
 
15
92
  A reliability patch: LSP requests tell timeouts and crashes apart and
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "find"
4
5
  require "digest"
5
6
  require "pathname"
6
7
  require "monitor"
@@ -265,10 +266,27 @@ module Rubik
265
266
  # for callers that only need a fuzzy-match candidate list.
266
267
  def all_files
267
268
  buffer_keys = @monitor.synchronize { @buffer.keys }
269
+ return buffer_keys unless File.directory?(@root_dir)
270
+
268
271
  disk_files = begin
269
- Dir.glob("**/*", File::FNM_DOTMATCH, base: @root_dir).reject do |f|
270
- f.start_with?(".git") || File.directory?(File.expand_path(f, @root_dir))
272
+ files = []
273
+ root_size = @root_dir.bytesize
274
+ Find.find(@root_dir) do |path|
275
+ next if path.bytesize == root_size
276
+
277
+ rel = path.byteslice((root_size + 1)..-1)
278
+ rel = rel.tr("\\", "/") if File::ALT_SEPARATOR
279
+
280
+ if rel.start_with?(".git")
281
+ Find.prune if File.directory?(path)
282
+ next
283
+ end
284
+
285
+ next if File.directory?(path)
286
+
287
+ files << rel
271
288
  end
289
+ files
272
290
  rescue SystemCallError
273
291
  []
274
292
  end
@@ -282,7 +300,7 @@ module Rubik
282
300
 
283
301
  diffs = snapshot.map do |rel_path, new_content|
284
302
  full_path = File.expand_path(rel_path, @root_dir)
285
- orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(File.read(full_path)) : ""
303
+ orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(Rubik::Platform.read_text(full_path)) : ""
286
304
  Diff.unified_diff(rel_path, orig_content, new_content)
287
305
  end
288
306
 
@@ -142,6 +142,7 @@ module Rubik
142
142
  "max_concurrency" => 10,
143
143
  "max_depth" => 2,
144
144
  "default_max_steps" => 88,
145
+ "max_total_steps" => 256, # Hard per-run ceiling across the initial allocation and all grants.
145
146
  "provider_concurrency" => 2,
146
147
  "budget_wait_timeout" => 180,
147
148
  "auto_apply_buffer" => true,
@@ -156,19 +157,15 @@ module Rubik
156
157
  # Sonuç arşivi (issue #122): tamamlanan koşular oturum-içi saklanır; redeploy öncekini EZMEZ.
157
158
  "result_archive" => { "ttl_seconds" => 3600, "max_runs_per_instance" => 8, "page_chars" => 4000 },
158
159
  # Append-only execution journal (issue #124): per-execution JSONL, capped per file.
159
- "journal" => { "max_bytes" => 2_097_152 },
160
+ "journal" => { "max_bytes" => 8_388_608 },
160
161
  # Maestro kapısı: maestro write_file/replace_file_content/run_command/run_tests'ı ÇALIŞTIRAMAZ:
161
162
  "maestro_gate" => true,
162
163
  "auto_pause_on_network_error" => true,
163
164
  "reactive_wakeup" => true,
164
- # Vakanüvis arka plan kronikçisi (storage/vakanuvis; plan
165
- # 2026-10-04 K1-K9): deterministik annal + wiki derleyicisi.
166
- # enabled: Düzlem 1 harness'i kur/kurulama; distiller: Düzlem 2
167
- # LLM doğumu (token üst sınırı flush_events x max_steps);
168
- # flush_events: damıtıcı doğumu için olay eşiği; model: nil =
169
- # root provider; retention: supersede (iz bırak) | purge (sil);
170
- # root_turns: REPL kök-turn'leri de annal'a yazılsın mı
171
- # (root-turn planı 2026-10-04; kapalıyken abonelik kurulmaz).
165
+ # Vakanüvis arka plan kronikçisi (plan 2026-10-04 K1-K9): deterministik
166
+ # annal + wiki derleyicisi. enabled: harness kur/kurulama; distiller:
167
+ # Düzlem 2 LLM doğumu (bütçe: flush_events x max_steps); model: nil =
168
+ # root provider; retention: supersede|purge; root_turns: kök-turn annal'ı.
172
169
  "vakanuvis" => {
173
170
  "enabled" => true,
174
171
  "distiller" => true,
@@ -178,18 +175,12 @@ module Rubik
178
175
  "retention" => "supersede",
179
176
  "root_turns" => true
180
177
  },
181
- # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi.
182
- # DEFAULT ON (kullanıcı kararı 2026-10-10) — deterministik yığın
183
- # (trim_conversation_window! / ContextCompactor / EmergencyCompactor)
184
- # her zaman güvenlik ağıdır; subagent kanalları bu özellikten asla
185
- # etkilenmez; opt-out: "enabled" => false (kill-switch). enabled:
186
- # harness'i kur (prune_context aracı + !prune komutu açılır);
187
- # trigger_ratio: turn sonu pencere tahmini girdi tavanının bu
188
- # oranına ulaşınca LLM budayıcı doğar (0.85'lik kör trim'den ÖNCE
189
- # anlamsal sıkıştırma); target_ratio: plan yalnızca sonuç bu
190
- # orana sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi
191
- # (nil = root provider); min_turns_between: otomatik doğum oranı
192
- # sınırlayıcısı (turn sayısı).
178
+ # Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi,
179
+ # DEFAULT ON (kullanıcı kararı 2026-10-10; kill-switch: enabled=false).
180
+ # trigger_ratio: tahmini girdi tavanının bu oranında LLM budayıcı doğar
181
+ # (kör trim'den ÖNCE anlamsal sıkıştırma); target_ratio: plan sadece
182
+ # sonuca sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi (nil =
183
+ # root provider); min_turns_between: otomatik doğum oranı sınırlayıcısı.
193
184
  "pruner" => {
194
185
  "enabled" => true,
195
186
  "trigger_ratio" => 0.6,
@@ -215,6 +206,12 @@ module Rubik
215
206
 
216
207
  private
217
208
 
209
+ def validate_prompt_grace_value!(key, value)
210
+ return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
211
+
212
+ raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
213
+ end
214
+
218
215
  def validate_schema(config, schema = DEFAULT_CONFIG, prefix = "", strict: false)
219
216
  raise ArgumentError, "Configuration must be a Hash, got #{config.class}" unless config.is_a?(Hash)
220
217
 
@@ -223,11 +220,7 @@ module Rubik
223
220
  validate_prompt_grace_value!(current_key, val)
224
221
  if schema.is_a?(Hash) && !schema.key?(key.to_s)
225
222
  candidate_keys = schema.keys
226
- candidates = if defined?(Rubik::Tools::FuzzyMatcher)
227
- Rubik::Tools::FuzzyMatcher.best_matches(key.to_s, candidate_keys)
228
- else
229
- []
230
- end
223
+ candidates = defined?(Rubik::Tools::FuzzyMatcher) ? Rubik::Tools::FuzzyMatcher.best_matches(key.to_s, candidate_keys) : []
231
224
 
232
225
  suggestion_msg = if candidates && !candidates.empty?
233
226
  suggested_full = prefix.empty? ? candidates.first : "#{prefix}.#{candidates.first}"
@@ -113,7 +113,7 @@ module Rubik
113
113
  if File.exist?(target_path)
114
114
  begin
115
115
  File.chmod(0o600, target_path)
116
- rescue
116
+ rescue StandardError
117
117
  nil
118
118
  end
119
119
  end
data/lib/rubik/config.rb CHANGED
@@ -163,12 +163,6 @@ module Rubik
163
163
 
164
164
  private
165
165
 
166
- def validate_prompt_grace_value!(key, value)
167
- return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
168
-
169
- raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
170
- end
171
-
172
166
  # save! returns itself for no-write scopes; persist outcomes are hashes.
173
167
  def persist_result(res)
174
168
  res.is_a?(Hash) ? res : { ok: true }
@@ -234,87 +234,118 @@ module Rubik
234
234
  )
235
235
  end
236
236
 
237
- def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
238
- max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
239
- on_retry: nil, http_factory: nil)
240
- begin
241
- uri = URI.parse(url_str.to_s)
242
- rescue URI::InvalidURIError => e
243
- raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
244
- end
245
-
237
+ def self.parse_and_validate_uri(url_str)
238
+ uri = URI.parse(url_str.to_s)
246
239
  unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
247
240
  raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
248
241
  end
249
242
 
243
+ uri
244
+ rescue URI::InvalidURIError => e
245
+ raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
246
+ end
247
+
248
+ def self.perform_post_request(uri, headers, payload, timeout:, open_timeout:, http_factory:)
249
+ if http_factory
250
+ http = http_factory.call(uri)
251
+ req = build_post_request(uri, headers, payload)
252
+ http.request(req)
253
+ else
254
+ with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
255
+ req = build_post_request(uri, headers, payload)
256
+ http.request(req)
257
+ end
258
+ end
259
+ end
260
+
261
+ # Rate limiting is transitory and self-healing — it gets its own
262
+ # larger budget (max_429_retries), a Retry-After-aware delay, and
263
+ # a typed error when exhausted so callers can report "provider
264
+ # throttled" distinctly. The generic 5xx budget is untouched, so
265
+ # one 429 burst cannot exhaust the transport-failure budget.
266
+ def self.handle_429_retry(res, rate_limit_retries:, max_429_retries:, start_time:, backoff_base:, max_wall_clock:, on_retry:)
267
+ if rate_limit_retries < max_429_retries
268
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
269
+ delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
270
+ if elapsed + delay <= max_wall_clock
271
+ rate_limit_retries += 1
272
+ on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
273
+ sleep(delay)
274
+ return rate_limit_retries
275
+ end
276
+ end
277
+ raise rate_limit_error(res, rate_limit_retries)
278
+ end
279
+
280
+ def self.attempt_backoff_retry(retries:, max_retries:, start_time:, backoff_base:, max_wall_clock:, reason:, on_retry:)
281
+ return nil unless retries < max_retries
282
+
283
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
284
+ delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
285
+ return nil if elapsed + delay > max_wall_clock
286
+
287
+ retries += 1
288
+ on_retry&.call(retries, max_retries, reason)
289
+ sleep(delay)
290
+ retries
291
+ end
292
+
293
+ # A body without a charset arrives tagged ASCII-8BIT; embedding it
294
+ # raw makes every ClientError message binary and the UTF-8 output
295
+ # layer raises inside the REPL's rescue clause (masking the real
296
+ # HTTP error as Encoding::CompatibilityError).
297
+ # Exhausted 5xx is transport-class (provider outage) → transient;
298
+ # a 4xx is content/auth/moderation → permanent. One raise point
299
+ # for both, the flag decided by status (issue #106).
300
+ def self.parse_json_response(res, uri:, status:)
301
+ unless status >= 200 && status < 300
302
+ raise ClientError.new(
303
+ "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
304
+ transient: status >= 500
305
+ )
306
+ end
307
+
308
+ begin
309
+ JSON.parse(res.body)
310
+ rescue JSON::ParserError => e
311
+ raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
312
+ end
313
+ end
314
+
315
+ def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
316
+ max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
317
+ on_retry: nil, http_factory: nil)
318
+ uri = parse_and_validate_uri(url_str)
250
319
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
320
  retries = 0
252
321
  rate_limit_retries = 0
253
322
 
254
323
  loop do
255
- res =
256
- if http_factory
257
- http = http_factory.call(uri)
258
- req = build_post_request(uri, headers, payload)
259
- http.request(req)
260
- else
261
- with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
262
- req = build_post_request(uri, headers, payload)
263
- http.request(req)
264
- end
265
- end
266
-
324
+ res = perform_post_request(uri, headers, payload, timeout: timeout, open_timeout: open_timeout, http_factory: http_factory)
267
325
  status = res.code.to_i
268
326
 
269
327
  if status == 429
270
- # Rate limiting is transitory and self-healing — it gets its own
271
- # larger budget (max_429_retries), a Retry-After-aware delay, and
272
- # a typed error when exhausted so callers can report "provider
273
- # throttled" distinctly. The generic 5xx budget is untouched, so
274
- # one 429 burst cannot exhaust the transport-failure budget.
275
- if rate_limit_retries < max_429_retries
276
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
277
- delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
278
- if elapsed + delay <= max_wall_clock
279
- rate_limit_retries += 1
280
- on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
281
- sleep(delay)
282
- next
283
- end
284
- end
285
- raise rate_limit_error(res, rate_limit_retries)
328
+ rate_limit_retries = handle_429_retry(
329
+ res, rate_limit_retries: rate_limit_retries, max_429_retries: max_429_retries,
330
+ start_time: start_time, backoff_base: backoff_base, max_wall_clock: max_wall_clock,
331
+ on_retry: on_retry
332
+ )
333
+ next
286
334
  end
287
335
 
288
- if (status >= 500) && (retries < max_retries)
289
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
290
- delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
291
- if elapsed + delay <= max_wall_clock
292
- retries += 1
293
- on_retry&.call(retries, max_retries, "HTTP #{status}")
294
- sleep(delay)
336
+ if status >= 500
337
+ new_retries = attempt_backoff_retry(
338
+ retries: retries, max_retries: max_retries, start_time: start_time,
339
+ backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: "HTTP #{status}",
340
+ on_retry: on_retry
341
+ )
342
+ if new_retries
343
+ retries = new_retries
295
344
  next
296
345
  end
297
346
  end
298
347
 
299
- unless status >= 200 && status < 300
300
- # A body without a charset arrives tagged ASCII-8BIT; embedding it
301
- # raw makes every ClientError message binary and the UTF-8 output
302
- # layer raises inside the REPL's rescue clause (masking the real
303
- # HTTP error as Encoding::CompatibilityError).
304
- # Exhausted 5xx is transport-class (provider outage) → transient;
305
- # a 4xx is content/auth/moderation → permanent. One raise point
306
- # for both, the flag decided by status (issue #106).
307
- raise ClientError.new(
308
- "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
309
- transient: status >= 500
310
- )
311
- end
312
-
313
- begin
314
- return JSON.parse(res.body)
315
- rescue JSON::ParserError => e
316
- raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
317
- end
348
+ return parse_json_response(res, uri: uri, status: status)
318
349
  rescue ClientError
319
350
  raise
320
351
  rescue Net::ReadTimeout
@@ -323,15 +354,14 @@ module Rubik
323
354
  rescue Interrupt
324
355
  raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
325
356
  rescue *RETRYABLE_ERRORS => e
326
- if retries < max_retries
327
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
328
- delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
329
- if elapsed + delay <= max_wall_clock
330
- retries += 1
331
- on_retry&.call(retries, max_retries, e.class.name)
332
- sleep(delay)
333
- next
334
- end
357
+ new_retries = attempt_backoff_retry(
358
+ retries: retries, max_retries: max_retries, start_time: start_time,
359
+ backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: e.class.name,
360
+ on_retry: on_retry
361
+ )
362
+ if new_retries
363
+ retries = new_retries
364
+ next
335
365
  end
336
366
  raise ClientError.new(
337
367
  "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})",
@@ -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
@@ -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