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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +130 -0
  4. data/README.md +30 -17
  5. data/lib/rubik/ast/extraction_result.rb +37 -0
  6. data/lib/rubik/ast/extractor.rb +35 -9
  7. data/lib/rubik/ast/graph.rb +27 -1
  8. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  9. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  10. data/lib/rubik/buffer/apply_contract.rb +78 -0
  11. data/lib/rubik/buffer/manager.rb +61 -5
  12. data/lib/rubik/coeffects.rb +14 -15
  13. data/lib/rubik/concurrency/event_loop.rb +56 -44
  14. data/lib/rubik/concurrency/scheduler.rb +7 -24
  15. data/lib/rubik/config/defaults.rb +9 -6
  16. data/lib/rubik/config.rb +8 -0
  17. data/lib/rubik/context/git_lister.rb +6 -1
  18. data/lib/rubik/context/scanner.rb +15 -3
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  21. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  23. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  24. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  25. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  26. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  27. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  28. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  29. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  30. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  31. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  32. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  33. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  34. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  35. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  36. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  37. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  38. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  39. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  40. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  41. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  42. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  43. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  44. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  45. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  46. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  47. data/lib/rubik/platform/process.rb +74 -2
  48. data/lib/rubik/plugin/manager.rb +148 -71
  49. data/lib/rubik/plugin/package_inventory.rb +84 -0
  50. data/lib/rubik/plugin/requires_dag.rb +40 -0
  51. data/lib/rubik/plugin/storage.rb +100 -14
  52. data/lib/rubik/prompt/doctrine.rb +5 -3
  53. data/lib/rubik/repl/commands/buffer.rb +28 -18
  54. data/lib/rubik/repl/commands/subagents.rb +2 -0
  55. data/lib/rubik/repl/input_execution.rb +42 -0
  56. data/lib/rubik/repl/input_loop.rb +57 -7
  57. data/lib/rubik/repl/prompt_grace.rb +103 -0
  58. data/lib/rubik/runtime/repair_probe.rb +98 -0
  59. data/lib/rubik/security/blocked_guidance.rb +19 -0
  60. data/lib/rubik/security/guard.rb +115 -6
  61. data/lib/rubik/security/redactor.rb +7 -7
  62. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  63. data/lib/rubik/tools/builtins/exec.rb +76 -28
  64. data/lib/rubik/tools/builtins/files.rb +6 -1
  65. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  66. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  67. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  68. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  69. data/lib/rubik/tools/truncator.rb +19 -2
  70. data/lib/rubik/tools/uri_resolver.rb +84 -20
  71. data/lib/rubik/uris.rb +11 -0
  72. data/lib/rubik/version.rb +1 -1
  73. 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, so a fiber that timed out and re-parked
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 => true (block-hook parks) — guarded
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
- # io_read body (one nonblocking attempt per call — the VM re-invokes
176
- # the hook in a loop, mirroring IO::Buffer#read's one-syscall shape).
177
- # Fills the caller's buffer and returns bytes read (0 = EOF).
178
- #
179
- # Two 4.0.6 facts shape this (verified by probe): the integer hook
180
- # args are protocol noise (3.3 passes length; 4.0 passes 0,0 — both
181
- # size the buffer to the requested read, hence buffer.size), and
182
- # EVERY blocking-family read primitive — read_nonblock included — is
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(length, exception: false) }
186
+ chunk = ::Fiber.blocking { io.read_nonblock(capacity - transferred, exception: false) }
194
187
  case chunk
195
- when :wait_readable then wait_io(io, READABLE, nil)
196
- when :wait_writable then wait_io(io, WRITABLE, nil)
197
- when nil then return 0
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.copy(::IO::Buffer.for(chunk), 0)
200
- return chunk.bytesize
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
- # io_write body: writes the whole caller-supplied buffer with one
206
- # nonblocking attempt per call, parking on WRITABLE between attempts.
207
- # (4.0.6 passes (0,0) as integers and expects the full buffer out.)
208
- def write_io(io, buffer)
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
- return written if written.is_a?(::Integer)
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
- wait_io(io, WRITABLE, nil)
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.keys
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 = slot[:read].merge(slot[:write])
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
- (slot[:read].keys + slot[:write].keys).each { |fiber| push(fiber, value: 0) }
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 hook protocol — stateless; all
9
- # state lives on the EventLoop. Ruby 3.3 ↔ 4.0 protocol drift is
10
- # concentrated here (probes against 4.0.6, plan §2):
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
- # The integer args differ per protocol (3.3: length; 4.0. offset/length
46
- # both zero) but the buffer is always sized to the operation, so the
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, *_protocol_args)
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
- # Bitmiş subagent bu grace (sn) sonrasında reap edilir (0 = kapalı);
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
- # Maestro kapısı: maestro write_file/replace_file_content/run_command/
156
- # run_tests araçlarını doğrudan ÇALIŞTIRAMAZ — subagent'a devir zorunlu
157
- # (false = doktrin yalnız prompt düzeyinde, kapı kapalı):
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
- out.split("\x00", -1).reject(&:empty?).map { |p| p.tr("\\", "/") }.uniq
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.to_r * 1_000_000).to_i
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 ignored?(rel, ignore_patterns)
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 unless @running || @wait_thr&.alive?
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
- # Result is deliberately absent: notices announce, the model retrieves
23
- # via manage_subagents wait — the wait protocol stays authoritative.
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, { id: inst.id, role: inst.role, state: inst.state, max_steps: inst.max_steps, storage_id: inst.storage_id })
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
- private
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