rubik 0.1.5 → 0.2.0
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 +130 -0
- data/README.md +30 -17
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/platform/process.rb +74 -2
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- metadata +20 -1
|
@@ -20,7 +20,8 @@ module Rubik
|
|
|
20
20
|
# the loop thread wakes immediately.
|
|
21
21
|
# * Wake staleness is handled by park generations: every registration
|
|
22
22
|
# stamps @gen[fiber]; timer fires and io dispatch only push when the
|
|
23
|
-
# generation still matches
|
|
23
|
+
# generation still matches at dispatch, including queued wakeups,
|
|
24
|
+
# so a fiber that timed out and re-parked
|
|
24
25
|
# elsewhere can never be resumed by a stale source.
|
|
25
26
|
#
|
|
26
27
|
# Drain model (waiters, not liveness — Fiber#alive? does not exist on
|
|
@@ -48,8 +49,8 @@ module Rubik
|
|
|
48
49
|
|
|
49
50
|
def initialize
|
|
50
51
|
@mutex = ::Mutex.new
|
|
51
|
-
@runnable = [] # [[fiber, resume_value]] — guarded by @mutex
|
|
52
|
-
@parked = {} # fiber =>
|
|
52
|
+
@runnable = [] # [[fiber, resume_value, park_generation]] — guarded by @mutex
|
|
53
|
+
@parked = {} # fiber => generation (block-hook parks) — guarded
|
|
53
54
|
@waiter_threads = [] # process-wait threads — guarded
|
|
54
55
|
# Stale timers (fiber re-parked or resumed since registration) must
|
|
55
56
|
# not gate draining — @gen is bumped on every resume, so any of this
|
|
@@ -77,11 +78,11 @@ module Rubik
|
|
|
77
78
|
|
|
78
79
|
# Queues a fiber to be resumed with value. Safe from foreign threads:
|
|
79
80
|
# pushes write a self-pipe byte so the loop's IO.select wakes at once.
|
|
80
|
-
def push(fiber, value: true)
|
|
81
|
+
def push(fiber, value: true, generation: nil)
|
|
81
82
|
@mutex.synchronize do
|
|
82
83
|
return if @closed
|
|
83
84
|
|
|
84
|
-
@runnable << [fiber, value]
|
|
85
|
+
@runnable << [fiber, value, generation]
|
|
85
86
|
end
|
|
86
87
|
kick unless ::Thread.current.equal?(@loop_thread)
|
|
87
88
|
end
|
|
@@ -95,8 +96,8 @@ module Rubik
|
|
|
95
96
|
@mutex.synchronize do
|
|
96
97
|
return if @closed
|
|
97
98
|
|
|
98
|
-
@parked.delete(fiber)
|
|
99
|
-
@runnable << [fiber, value]
|
|
99
|
+
generation = @parked.delete(fiber)
|
|
100
|
+
@runnable << [fiber, value, generation]
|
|
100
101
|
end
|
|
101
102
|
kick unless ::Thread.current.equal?(@loop_thread)
|
|
102
103
|
end
|
|
@@ -149,8 +150,8 @@ module Rubik
|
|
|
149
150
|
# timeout of 0 still registers a timer (fires immediately) — truthiness
|
|
150
151
|
# guards would park join(0)/wait(0) polls forever.
|
|
151
152
|
def park(_blocker, timeout = nil)
|
|
152
|
-
@mutex.synchronize { @parked[::Fiber.current] = true }
|
|
153
153
|
value = park_fiber do |fiber, gen|
|
|
154
|
+
@mutex.synchronize { @parked[fiber] = gen }
|
|
154
155
|
@timers.add(monotonic + timeout, fiber, :timeout, gen) unless timeout.nil?
|
|
155
156
|
end
|
|
156
157
|
value == :timeout ? false : value
|
|
@@ -172,49 +173,52 @@ module Rubik
|
|
|
172
173
|
value
|
|
173
174
|
end
|
|
174
175
|
|
|
175
|
-
#
|
|
176
|
-
# the
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
# itself routed back through scheduler.io_read once the hook exists.
|
|
184
|
-
# The Fiber.blocking wrap is the VM-sanctioned escape (ruby's own test
|
|
185
|
-
# scheduler wraps its primitives the same way): it runs the primitive
|
|
186
|
-
# immediately, bypassing interception, and read_nonblock(exception:
|
|
187
|
-
# false) never blocks the OS thread.
|
|
188
|
-
def read_io(io, buffer)
|
|
189
|
-
length = buffer.size
|
|
190
|
-
return 0 if length.zero?
|
|
176
|
+
# A zero minimum means one nonblocking attempt: return -errno to
|
|
177
|
+
# the VM instead of parking here, so read_nonblock preserves the
|
|
178
|
+
# caller's subsequent IO#wait_readable deadline (e.g. Net::HTTP).
|
|
179
|
+
# Fiber.blocking prevents the primitive from re-entering this hook.
|
|
180
|
+
def read_io(io, buffer, minimum = 0, offset = 0)
|
|
181
|
+
transferred = 0
|
|
182
|
+
capacity = buffer.size - offset
|
|
183
|
+
return 0 if capacity.zero?
|
|
191
184
|
|
|
192
185
|
loop do
|
|
193
|
-
chunk = ::Fiber.blocking { io.read_nonblock(
|
|
186
|
+
chunk = ::Fiber.blocking { io.read_nonblock(capacity - transferred, exception: false) }
|
|
194
187
|
case chunk
|
|
195
|
-
when :wait_readable
|
|
196
|
-
|
|
197
|
-
|
|
188
|
+
when :wait_readable, :wait_writable
|
|
189
|
+
return transferred.positive? ? transferred : -Errno::EAGAIN::Errno if transferred >= minimum
|
|
190
|
+
|
|
191
|
+
wait_io(io, chunk == :wait_readable ? READABLE : WRITABLE, nil)
|
|
192
|
+
when nil then return transferred
|
|
198
193
|
else
|
|
199
|
-
buffer.
|
|
200
|
-
|
|
194
|
+
buffer.set_string(chunk, offset + transferred)
|
|
195
|
+
transferred += chunk.bytesize
|
|
196
|
+
return transferred if transferred >= minimum || transferred == capacity
|
|
201
197
|
end
|
|
202
198
|
end
|
|
199
|
+
rescue SystemCallError => e
|
|
200
|
+
-e.errno
|
|
203
201
|
end
|
|
204
202
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
data = buffer.get_string(0, buffer.size)
|
|
210
|
-
return 0 if data.empty?
|
|
203
|
+
def write_io(io, buffer, minimum = 0, offset = 0)
|
|
204
|
+
transferred = 0
|
|
205
|
+
capacity = buffer.size - offset
|
|
206
|
+
return 0 if capacity.zero?
|
|
211
207
|
|
|
212
208
|
loop do
|
|
209
|
+
data = buffer.get_string(offset + transferred, capacity - transferred)
|
|
213
210
|
written = ::Fiber.blocking { io.write_nonblock(data, exception: false) }
|
|
214
|
-
|
|
211
|
+
if written.is_a?(::Integer)
|
|
212
|
+
transferred += written
|
|
213
|
+
return transferred if transferred >= minimum || transferred == capacity
|
|
214
|
+
else
|
|
215
|
+
return transferred.positive? ? transferred : -Errno::EAGAIN::Errno if transferred >= minimum
|
|
215
216
|
|
|
216
|
-
|
|
217
|
+
wait_io(io, written == :wait_readable ? READABLE : WRITABLE, nil)
|
|
218
|
+
end
|
|
217
219
|
end
|
|
220
|
+
rescue SystemCallError => e
|
|
221
|
+
-e.errno
|
|
218
222
|
end
|
|
219
223
|
|
|
220
224
|
# DNS resolution parks the fiber; the DnsPool worker delivers the list
|
|
@@ -345,7 +349,9 @@ module Rubik
|
|
|
345
349
|
|
|
346
350
|
def run_batch
|
|
347
351
|
while (item = @mutex.synchronize { @runnable.shift })
|
|
348
|
-
fiber, value = item
|
|
352
|
+
fiber, value, generation = item
|
|
353
|
+
next if generation && @gen[fiber] != generation
|
|
354
|
+
|
|
349
355
|
# A resumed fiber is definitionally no longer parked; dropping the
|
|
350
356
|
# registration here keeps interrupt-style wakes from leaking parks.
|
|
351
357
|
# Bumping the park generation invalidates every OTHER outstanding
|
|
@@ -369,7 +375,9 @@ module Rubik
|
|
|
369
375
|
end
|
|
370
376
|
|
|
371
377
|
def select_or_wait
|
|
372
|
-
read_set = @io_waiters.
|
|
378
|
+
read_set = @io_waiters.each_with_object([]) do |(io, slot), acc|
|
|
379
|
+
acc << io unless slot[:read].empty?
|
|
380
|
+
end
|
|
373
381
|
write_set = @io_waiters.each_with_object([]) do |(io, slot), acc|
|
|
374
382
|
acc << io unless slot[:write].empty?
|
|
375
383
|
end
|
|
@@ -407,7 +415,7 @@ module Rubik
|
|
|
407
415
|
next if gen != STICKY && @gen[fiber] != gen # stale: fiber moved on after registering
|
|
408
416
|
|
|
409
417
|
drop_io_interest(fiber)
|
|
410
|
-
push(fiber, value: value)
|
|
418
|
+
push(fiber, value: value, generation: gen == STICKY ? nil : gen)
|
|
411
419
|
end
|
|
412
420
|
end
|
|
413
421
|
|
|
@@ -420,7 +428,9 @@ module Rubik
|
|
|
420
428
|
slot = @io_waiters[io]
|
|
421
429
|
return unless slot
|
|
422
430
|
|
|
423
|
-
waiters =
|
|
431
|
+
waiters = {}
|
|
432
|
+
waiters.merge!(slot[:read]) if events.anybits?(READABLE)
|
|
433
|
+
waiters.merge!(slot[:write]) if events.anybits?(WRITABLE)
|
|
424
434
|
waiters.each do |fiber, gen|
|
|
425
435
|
if @gen[fiber] != gen
|
|
426
436
|
# Stale interest (the fiber timed out or moved on): remove it so
|
|
@@ -434,7 +444,7 @@ module Rubik
|
|
|
434
444
|
# it re-registers with a fresh generation.
|
|
435
445
|
slot[:read].delete(fiber)
|
|
436
446
|
slot[:write].delete(fiber)
|
|
437
|
-
push(fiber, value: events)
|
|
447
|
+
push(fiber, value: events, generation: gen)
|
|
438
448
|
end
|
|
439
449
|
@io_waiters.delete(io) if slot[:read].empty? && slot[:write].empty?
|
|
440
450
|
end
|
|
@@ -460,7 +470,9 @@ module Rubik
|
|
|
460
470
|
slot = @io_waiters.delete(io)
|
|
461
471
|
next unless slot
|
|
462
472
|
|
|
463
|
-
|
|
473
|
+
slot[:read].merge(slot[:write]).each do |fiber, gen|
|
|
474
|
+
push(fiber, value: 0, generation: gen)
|
|
475
|
+
end
|
|
464
476
|
pruned += 1
|
|
465
477
|
end
|
|
466
478
|
pruned
|
|
@@ -5,23 +5,9 @@ require_relative "process_waiter"
|
|
|
5
5
|
|
|
6
6
|
module Rubik
|
|
7
7
|
module Concurrency
|
|
8
|
-
# Adapter for the VM's Fiber::Scheduler
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# fiber 4.0: fiber(&blk) — scheduler creates and returns the fiber.
|
|
13
|
-
# 3.3: fiber(fiber, block) — VM pre-created it.
|
|
14
|
-
# io_read integer args differ (3.3 passes length; 4.0 passes 0, 0)
|
|
15
|
-
# io_write but the buffer is always sized to the operation, so both
|
|
16
|
-
# hooks drive off buffer.size — see EventLoop#read_io.
|
|
17
|
-
#
|
|
18
|
-
# io_read/io_write are required on 4.0: without them a plain IO#read
|
|
19
|
-
# inside a scheduled fiber falls back to a whole-thread BLOCKING read
|
|
20
|
-
# (measured: 20 × 0.2 s child pipes serialized into 13 s). The buffer is
|
|
21
|
-
# filled/drained with read_nonblock/write_nonblock + IO::Buffer views —
|
|
22
|
-
# IO#nonblock= and IO::Buffer#read(io,...) are NOT usable on Windows
|
|
23
|
-
# builds (NoMethodError / constant-zero reads, verified), while the
|
|
24
|
-
# nonblock(…, exception: false) family works for pipes and sockets alike.
|
|
8
|
+
# Adapter for the VM's Fiber::Scheduler hooks. IO hooks receive a
|
|
9
|
+
# minimum transfer length and a buffer offset. A zero minimum requests
|
|
10
|
+
# one nonblocking attempt; the VM owns subsequent waits and deadlines.
|
|
25
11
|
class Scheduler
|
|
26
12
|
attr_reader :loop
|
|
27
13
|
|
|
@@ -42,15 +28,12 @@ module Rubik
|
|
|
42
28
|
@loop.wait_io(io, events, timeout)
|
|
43
29
|
end
|
|
44
30
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# EventLoop drives both hooks off buffer.size — see read_io/write_io.
|
|
48
|
-
def io_read(io, buffer, *_protocol_args)
|
|
49
|
-
@loop.read_io(io, buffer)
|
|
31
|
+
def io_read(io, buffer, length, offset = 0)
|
|
32
|
+
@loop.read_io(io, buffer, length, offset)
|
|
50
33
|
end
|
|
51
34
|
|
|
52
|
-
def io_write(io, buffer,
|
|
53
|
-
@loop.write_io(io, buffer)
|
|
35
|
+
def io_write(io, buffer, length, offset = 0)
|
|
36
|
+
@loop.write_io(io, buffer, length, offset)
|
|
54
37
|
end
|
|
55
38
|
|
|
56
39
|
def kernel_sleep(duration = nil)
|
|
@@ -118,6 +118,7 @@ module Rubik
|
|
|
118
118
|
"statusline_template" => "{model} · ctx {ctx} · {dirty} · sandbox {sandbox}",
|
|
119
119
|
"input_box" => false,
|
|
120
120
|
"paste_join_window_ms" => 120, # Plan 91: lines arriving within this gap join into one paste turn
|
|
121
|
+
"prompt_grace_window_ms" => 1000,
|
|
121
122
|
"quiet_notices" => false # Plan 105: buffer background notices to inbox until prompt/enter
|
|
122
123
|
},
|
|
123
124
|
"workspace" => {
|
|
@@ -148,13 +149,15 @@ module Rubik
|
|
|
148
149
|
"network_backoff_base" => 0.5,
|
|
149
150
|
"error_wait_timeout" => 300,
|
|
150
151
|
"question_timeout" => 300,
|
|
151
|
-
#
|
|
152
|
-
# tombstone_limit sorgulanabilir mezar taşı sayısını sınırlar:
|
|
152
|
+
# reap_finished_after: grace (sn, 0 = kapalı); tombstone_limit: sorgulanabilir mezar taşı sınırı.
|
|
153
153
|
"reap_finished_after" => 300,
|
|
154
154
|
"tombstone_limit" => 25,
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
155
|
+
"cancel_timeout" => 2, # foreground ^C: owned descendants' grace period (s) before force-kill
|
|
156
|
+
# Sonuç arşivi (issue #122): tamamlanan koşular oturum-içi saklanır; redeploy öncekini EZMEZ.
|
|
157
|
+
"result_archive" => { "ttl_seconds" => 3600, "max_runs_per_instance" => 8, "page_chars" => 4000 },
|
|
158
|
+
# Append-only execution journal (issue #124): per-execution JSONL, capped per file.
|
|
159
|
+
"journal" => { "max_bytes" => 2_097_152 },
|
|
160
|
+
# Maestro kapısı: maestro write_file/replace_file_content/run_command/run_tests'ı ÇALIŞTIRAMAZ:
|
|
158
161
|
"maestro_gate" => true,
|
|
159
162
|
"auto_pause_on_network_error" => true,
|
|
160
163
|
"reactive_wakeup" => true,
|
|
@@ -217,6 +220,7 @@ module Rubik
|
|
|
217
220
|
|
|
218
221
|
config.each do |key, val|
|
|
219
222
|
current_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
223
|
+
validate_prompt_grace_value!(current_key, val)
|
|
220
224
|
if schema.is_a?(Hash) && !schema.key?(key.to_s)
|
|
221
225
|
candidate_keys = schema.keys
|
|
222
226
|
candidates = if defined?(Rubik::Tools::FuzzyMatcher)
|
|
@@ -235,7 +239,6 @@ module Rubik
|
|
|
235
239
|
raise ArgumentError, suggestion_msg if strict
|
|
236
240
|
|
|
237
241
|
@warnings << suggestion_msg
|
|
238
|
-
|
|
239
242
|
elsif val.is_a?(Hash) && schema.is_a?(Hash) && schema[key.to_s].is_a?(Hash) && !schema[key.to_s].empty?
|
|
240
243
|
validate_schema(val, schema[key.to_s], current_key, strict: strict)
|
|
241
244
|
end
|
data/lib/rubik/config.rb
CHANGED
|
@@ -68,6 +68,8 @@ module Rubik
|
|
|
68
68
|
def set(key, value, persist: nil)
|
|
69
69
|
old_val = fetch(key)
|
|
70
70
|
typed_val = parse_scalar_value(value)
|
|
71
|
+
validate_prompt_grace_value!(key.to_s, typed_val)
|
|
72
|
+
validate_schema(typed_val, DEFAULT_CONFIG["ui"], "ui") if key.to_s == "ui" && typed_val.is_a?(Hash)
|
|
71
73
|
keys = key.to_s.split(".")
|
|
72
74
|
last_key = keys.pop
|
|
73
75
|
target = keys.reduce(@data) do |acc, k|
|
|
@@ -161,6 +163,12 @@ module Rubik
|
|
|
161
163
|
|
|
162
164
|
private
|
|
163
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
|
+
|
|
164
172
|
# save! returns itself for no-write scopes; persist outcomes are hashes.
|
|
165
173
|
def persist_result(res)
|
|
166
174
|
res.is_a?(Hash) ? res : { ok: true }
|
|
@@ -21,7 +21,12 @@ module Rubik
|
|
|
21
21
|
)
|
|
22
22
|
return nil unless status.success?
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# PERF: NUL-split with conditional in-place backslash translation and
|
|
25
|
+
# in-place deduplication avoids intermediate array and string allocations.
|
|
26
|
+
res = out.split("\x00", -1).reject(&:empty?)
|
|
27
|
+
res.map! { |p| p.tr("\\", "/") } if out.include?("\\")
|
|
28
|
+
res.uniq!
|
|
29
|
+
res
|
|
25
30
|
rescue StandardError
|
|
26
31
|
nil
|
|
27
32
|
end
|
|
@@ -196,8 +196,9 @@ module Rubik
|
|
|
196
196
|
}
|
|
197
197
|
end
|
|
198
198
|
|
|
199
|
+
# PERF: integer math avoids Rational allocation from Time#to_r (~5x faster, zero allocation)
|
|
199
200
|
def mtime_micros(stat)
|
|
200
|
-
(stat.mtime.
|
|
201
|
+
(stat.mtime.tv_sec * 1_000_000) + stat.mtime.tv_usec
|
|
201
202
|
end
|
|
202
203
|
|
|
203
204
|
def walk_dir(base_dir, rel_prefix, key_prefix, ignore_patterns, previous, needles)
|
|
@@ -325,6 +326,15 @@ module Rubik
|
|
|
325
326
|
# depth. subtree_ignored? is a pure function of (dir, needles), so
|
|
326
327
|
# the memo is exact.
|
|
327
328
|
dir_verdicts = Hash.new { |h, dir| h[dir] = subtree_ignored?(dir, needles) }
|
|
329
|
+
|
|
330
|
+
# PERF: filter ignore_patterns to exclude prunable subtree patterns that
|
|
331
|
+
# dir_verdicts already checked on ancestor directory segments.
|
|
332
|
+
file_patterns = ignore_patterns.reject do |pat|
|
|
333
|
+
m = PRUNABLE_PATTERN.match(pat)
|
|
334
|
+
m && !m[1].include?("*") && !m[1].include?("?") && !m[1].include?("[")
|
|
335
|
+
end
|
|
336
|
+
file_rules = compile_ignore_regexes(file_patterns)
|
|
337
|
+
|
|
328
338
|
order_like_walker(rel_paths).each do |rel|
|
|
329
339
|
# PERF: zero-allocation ancestor check using string slicing instead of Array#split and Array#join
|
|
330
340
|
ignored_dir = false
|
|
@@ -337,7 +347,8 @@ module Rubik
|
|
|
337
347
|
idx += 1
|
|
338
348
|
end
|
|
339
349
|
next if ignored_dir
|
|
340
|
-
next if
|
|
350
|
+
next if rel == ".rubik" || rel.start_with?(".rubik/") || rel == ".git" || rel.start_with?(".git/")
|
|
351
|
+
next if file_rules.any? { |r| pattern_match?(r, rel) }
|
|
341
352
|
next unless included?(rel, @custom_includes)
|
|
342
353
|
|
|
343
354
|
full = "#{base_dir}/#{rel}"
|
|
@@ -370,8 +381,9 @@ module Rubik
|
|
|
370
381
|
# PERF: replacing "/" with NUL byte ("\x00") is equivalent to array component
|
|
371
382
|
# comparison because NUL (0x00) is strictly less than any valid path byte,
|
|
372
383
|
# avoiding intermediate Array allocations per path (~8.4x faster, ~62.5% fewer allocations).
|
|
384
|
+
# PERF: in-place sort_by! avoids array re-allocation
|
|
373
385
|
def order_like_walker(paths)
|
|
374
|
-
paths.sort_by { |p| p.tr("/", "\x00") }
|
|
386
|
+
paths.sort_by! { |p| p.tr("/", "\x00") }
|
|
375
387
|
end
|
|
376
388
|
|
|
377
389
|
def parse_gitignore(dir = @root_dir)
|
|
@@ -29,6 +29,7 @@ module Rubik
|
|
|
29
29
|
@mutex = Mutex.new
|
|
30
30
|
@cv = ConditionVariable.new
|
|
31
31
|
@responses = {}
|
|
32
|
+
@closed = false
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def open
|
|
@@ -78,6 +79,7 @@ module Rubik
|
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
@running = true
|
|
82
|
+
@closed = false
|
|
81
83
|
@req_id = 0
|
|
82
84
|
@responses.clear
|
|
83
85
|
|
|
@@ -118,8 +120,9 @@ module Rubik
|
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
def close
|
|
121
|
-
return
|
|
123
|
+
return if @closed
|
|
122
124
|
|
|
125
|
+
@closed = true
|
|
123
126
|
@running = false
|
|
124
127
|
|
|
125
128
|
begin
|
|
@@ -128,6 +131,16 @@ module Rubik
|
|
|
128
131
|
nil
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
if @wait_thr&.alive?
|
|
135
|
+
Rubik::Platform.kill_tree(@wait_thr.pid)
|
|
136
|
+
begin
|
|
137
|
+
@wait_thr.join(0.5)
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
@wait_thr = nil
|
|
143
|
+
|
|
131
144
|
[@stdin, @stdout, @stderr].each do |io|
|
|
132
145
|
io&.close unless io&.closed?
|
|
133
146
|
rescue StandardError
|
|
@@ -142,16 +155,6 @@ module Rubik
|
|
|
142
155
|
@reader_thr = nil
|
|
143
156
|
end
|
|
144
157
|
|
|
145
|
-
if @wait_thr&.alive?
|
|
146
|
-
Rubik::Platform.kill_tree(@wait_thr.pid)
|
|
147
|
-
begin
|
|
148
|
-
@wait_thr.join(0.5)
|
|
149
|
-
rescue StandardError
|
|
150
|
-
nil
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
@wait_thr = nil
|
|
154
|
-
|
|
155
158
|
@mutex.synchronize do
|
|
156
159
|
@responses.clear
|
|
157
160
|
@cv.broadcast
|
|
@@ -151,7 +151,11 @@ module Rubik
|
|
|
151
151
|
error: err,
|
|
152
152
|
output: trunc_res[:text],
|
|
153
153
|
duration_ms: dur,
|
|
154
|
-
truncated: trunc_res[:truncated]
|
|
154
|
+
truncated: trunc_res[:truncated],
|
|
155
|
+
# Structured fields ride along when the tool provides them
|
|
156
|
+
# (issue #127): the same Result contract for every carrier.
|
|
157
|
+
exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
|
|
158
|
+
metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
|
|
155
159
|
)
|
|
156
160
|
else
|
|
157
161
|
out_str = raw_output.to_s
|
|
@@ -239,6 +243,8 @@ module Rubik
|
|
|
239
243
|
step: @deps.step_count,
|
|
240
244
|
max_steps: @deps.max_steps,
|
|
241
245
|
call_id: call_id,
|
|
246
|
+
exit_code: tool_result.exit_code,
|
|
247
|
+
metadata: tool_result.metadata,
|
|
242
248
|
line_count: tool_result.output.to_s.lines.size,
|
|
243
249
|
byte_size: tool_result.output.to_s.bytesize,
|
|
244
250
|
tokens: @deps.total_tokens
|
|
@@ -246,7 +252,8 @@ module Rubik
|
|
|
246
252
|
|
|
247
253
|
Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
|
|
248
254
|
status: tool_result.status, duration_ms: tool_result.duration_ms,
|
|
249
|
-
content: tool_result.to_xml
|
|
255
|
+
content: tool_result.to_xml, exit_code: tool_result.exit_code,
|
|
256
|
+
metadata: tool_result.metadata)
|
|
250
257
|
@deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
|
|
251
258
|
@deps.reset_invalid_tools!
|
|
252
259
|
tool_result
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../task_cancellation"
|
|
4
|
+
|
|
3
5
|
module Rubik
|
|
4
6
|
module Orchestrator
|
|
5
7
|
module Subagent
|
|
@@ -23,13 +25,18 @@ module Rubik
|
|
|
23
25
|
# happens at execute time, but a start_async completion must never
|
|
24
26
|
# race an unprovided asker), unit registration second (pre-start, so
|
|
25
27
|
# the kernel board sees a live unit), start_async last.
|
|
26
|
-
def activate_instance!(inst, child_ctx:)
|
|
28
|
+
def activate_instance!(inst, child_ctx:, owner_token: nil)
|
|
27
29
|
# ask_question'ın subagent düzlemi: instance kendi child ctx'ine
|
|
28
30
|
# asker olarak publish edilir.
|
|
29
31
|
child_ctx&.provide(Rubik::Coeffects::SUBAGENT_QUESTION_ASKER, inst)
|
|
30
32
|
# send_message atfı: subagent→maestro mesajları gerçek gönderen
|
|
31
33
|
# id'siyle taşır (coeffect yoksa root default'u yanlış atfı yapar).
|
|
32
34
|
child_ctx&.provide(Rubik::Coeffects::SUBAGENT_SENDER_ID, inst.id)
|
|
35
|
+
# Task ownership (issue #125): the spawned execution (and, through
|
|
36
|
+
# inheritance, its own nested spawns) carries the owner's token; a
|
|
37
|
+
# detached spawn gets a never-cancelled token so its whole subtree
|
|
38
|
+
# survives a foreground ^C.
|
|
39
|
+
child_ctx&.provide(Rubik::Coeffects::TASK_CANCELLATION, owner_token || TaskCancellation.detached)
|
|
33
40
|
eff_init = @init || (@root_context.respond_to?(:get) && Rubik::Coeffects.get(@root_context, Rubik::Coeffects::INIT))
|
|
34
41
|
eff_init&.register(Kernel::SubagentUnit.new(inst, context: child_ctx, birth_state: unit_birth_state(inst)))
|
|
35
42
|
inst.start_async { |completed_inst| handle_subagent_completion(completed_inst) }
|
|
@@ -16,18 +16,45 @@ module Rubik
|
|
|
16
16
|
def handle_subagent_completion(inst)
|
|
17
17
|
merge_branch_buffer(inst) if inst.workspace == "branch" && inst.completed?
|
|
18
18
|
auto_apply_subagent_buffer(inst) if inst.completed?
|
|
19
|
+
retain_result(inst)
|
|
19
20
|
Rubik::Storage::TranscriptIndexer.index(storage_store, inst)
|
|
20
21
|
reap_orphans
|
|
21
22
|
release_ready!
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
emit_completion_notice(inst)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# One terminal outcome per execution (issue #125): an operator kill
|
|
29
|
+
# already announced the cancellation from the kill path — the settle
|
|
30
|
+
# notice would be a duplicate. Natural settles announce here, with the
|
|
31
|
+
# canonical result reference so a truncated preview keeps a working
|
|
32
|
+
# continuation (result_ref, issue #122).
|
|
33
|
+
def emit_completion_notice(inst)
|
|
34
|
+
return if inst.respond_to?(:cancelled_by_operator?) && inst.cancelled_by_operator?
|
|
35
|
+
|
|
24
36
|
# max_steps rides along so the budget-exhausted guidance can count.
|
|
25
37
|
# storage_id: Vakanüvis kronikçisinin annal/transcript ref anahtarı
|
|
26
38
|
# (plan 2026-10-04) — mevcut aboneler bu anahtarı yok sayar.
|
|
27
|
-
Notices.emit(@event_bus, Notices::COMPLETED_EVENT,
|
|
39
|
+
Notices.emit(@event_bus, Notices::COMPLETED_EVENT,
|
|
40
|
+
{ id: inst.id, role: inst.role, state: inst.state, max_steps: inst.max_steps,
|
|
41
|
+
storage_id: inst.storage_id, result_ref: (inst.result_ref if inst.settled?),
|
|
42
|
+
transcript_ref: inst.transcript_ref })
|
|
28
43
|
end
|
|
29
44
|
|
|
30
|
-
|
|
45
|
+
# The result archive's single write site (issue #122): a settled run
|
|
46
|
+
# retains its redacted result under its execution-specific reference
|
|
47
|
+
# (storage id + run sequence) so retrieval never needs a redeploy.
|
|
48
|
+
# A retention failure degrades to a warning — the durable transcript
|
|
49
|
+
# remains the fallback, and completion choreography must not stall.
|
|
50
|
+
def retain_result(inst)
|
|
51
|
+
return unless inst.settled? && @result_archive
|
|
52
|
+
|
|
53
|
+
@result_archive.retain(storage_id: inst.storage_id, id: inst.id, run: inst.run_seq,
|
|
54
|
+
state: inst.state, body: inst.result)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Rubik::Log.warn(:orchestrator, "result archive retain failed for #{inst.id}: #{e.class}: #{e.message}")
|
|
57
|
+
end
|
|
31
58
|
|
|
32
59
|
# Completion-time spelling of the shared auto-apply policy: the
|
|
33
60
|
# subagents flag OR the buffer.auto_apply mode decides (previously
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "notices"
|
|
4
|
+
require_relative "../provider_recovery"
|
|
5
|
+
|
|
6
|
+
module Rubik
|
|
7
|
+
module Orchestrator
|
|
8
|
+
module Subagent
|
|
9
|
+
# The subagent's context-recovery plane, extracted from StepRunner
|
|
10
|
+
# (module hygiene): routine compaction evaluation, the emergency
|
|
11
|
+
# compaction actuator, and the bus announcement. One vertical slice —
|
|
12
|
+
# history pruning and its observable event.
|
|
13
|
+
module ContextCompaction
|
|
14
|
+
def evaluate_subagent_context_compaction!
|
|
15
|
+
return unless @context_limit&.positive?
|
|
16
|
+
|
|
17
|
+
context_cfg = ProviderRecovery.compaction_config(@config)
|
|
18
|
+
needs = Rubik::Prompt::ContextCompactor.needs_compaction?(
|
|
19
|
+
@history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
|
|
20
|
+
)
|
|
21
|
+
return unless needs
|
|
22
|
+
|
|
23
|
+
compacted = Rubik::Prompt::ContextCompactor.compact_history(
|
|
24
|
+
@history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
|
|
25
|
+
)
|
|
26
|
+
before = @history.size
|
|
27
|
+
@history.replace(compacted)
|
|
28
|
+
# Journaled BEFORE the next step can prune further (issue #124): the
|
|
29
|
+
# pre-compaction event sequence stays reconstructible.
|
|
30
|
+
@journal&.record_compaction(run: @run_seq, layer: "context_compactor", before: before, after: @history.size)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Compaction actuator for the subagent path (mirrors Loop's seam): the
|
|
34
|
+
# body lives in TurnExecutor#emergency_compact! (one home for both
|
|
35
|
+
# channels); this delegates and the channel announces on the bus. Never
|
|
36
|
+
# call it while holding @monitor (the emit stays lock-free).
|
|
37
|
+
def emergency_compact_and_notify!(reason)
|
|
38
|
+
turn_executor.emergency_compact!(reason)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The object that owns the fact emits it — outside the monitor, so
|
|
42
|
+
# subscribers never run under the instance lock.
|
|
43
|
+
def emit_compacted_event(reason, layer)
|
|
44
|
+
@journal&.record_compaction(run: @run_seq, layer: layer, reason: reason)
|
|
45
|
+
Notices.emit(@event_bus, Notices::COMPACTED_EVENT, { id: @id, role: @role, state: @state, reason: reason.to_s, layer: layer })
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|