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 +4 -4
- data/CHANGELOG.md +77 -0
- data/lib/rubik/buffer/manager.rb +21 -3
- data/lib/rubik/config/defaults.rb +19 -26
- data/lib/rubik/config/store.rb +1 -1
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- 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 +3 -3
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +8 -5
- data/lib/rubik/orchestrator/subagent/supervisor.rb +7 -1
- data/lib/rubik/platform/process.rb +7 -7
- data/lib/rubik/platform/sanitize.rb +41 -20
- data/lib/rubik/platform.rb +2 -2
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_execution.rb +100 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/subagents.rb +12 -11
- data/lib/rubik/repl/commands/workspace.rb +156 -152
- data/lib/rubik/repl/interrupt_handler.rb +10 -10
- 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/tools/builtins/subagent.rb +7 -2
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 220e106421fc0a5692890a2c3f3e7ba2296661c5ce60a96f62a3f8d5fcaa4c0a
|
|
4
|
+
data.tar.gz: 1a287e603bb553b11ebc1e0187f4a0d00e8e6b17a68c005311bc144635a1696a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -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
|
-
|
|
270
|
-
|
|
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(
|
|
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" =>
|
|
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 (
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
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
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
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 =
|
|
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}"
|
data/lib/rubik/config/store.rb
CHANGED
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 }
|
data/lib/rubik/llm/client.rb
CHANGED
|
@@ -234,87 +234,118 @@ module Rubik
|
|
|
234
234
|
)
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
-
def self.
|
|
238
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|