rubik 0.1.4 → 0.1.6

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +66 -0
  4. data/lib/rubik/coeffects.rb +14 -15
  5. data/lib/rubik/config/defaults.rb +9 -6
  6. data/lib/rubik/config.rb +8 -0
  7. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  8. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  9. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  10. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  11. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  12. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  13. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  14. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  15. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  16. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  17. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  18. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  19. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  21. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  22. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  23. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  25. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  26. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  27. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  28. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  29. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  30. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  31. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  32. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  33. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  34. data/lib/rubik/prompt/context_builder.rb +1 -1
  35. data/lib/rubik/prompt/doctrine.rb +5 -3
  36. data/lib/rubik/repl/commands/subagents.rb +2 -0
  37. data/lib/rubik/repl/input_execution.rb +42 -0
  38. data/lib/rubik/repl/input_loop.rb +57 -7
  39. data/lib/rubik/repl/prompt_grace.rb +103 -0
  40. data/lib/rubik/repl/terminal_guard.rb +5 -1
  41. data/lib/rubik/security/redactor.rb +7 -7
  42. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  43. data/lib/rubik/tools/builtins/exec.rb +76 -28
  44. data/lib/rubik/tools/builtins/files.rb +6 -1
  45. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  46. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  47. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  48. data/lib/rubik/tools/truncator.rb +19 -2
  49. data/lib/rubik/tools/uri_resolver.rb +84 -20
  50. data/lib/rubik/uris.rb +11 -0
  51. data/lib/rubik/version.rb +1 -1
  52. metadata +17 -3
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "time"
5
+
6
+ module Rubik
7
+ module Orchestrator
8
+ module Subagent
9
+ # Operation-level telemetry (issue #126): what an execution is doing
10
+ # RIGHT NOW and how its time split between model waits and tool runs,
11
+ # measured with a monotonic clock (injectable for tests). Pure state —
12
+ # no IO, no notices, no locks shared with the instance; the status and
13
+ # wait surfaces read snapshots from here instead of guessing whether an
14
+ # execution is stalled.
15
+ class OperationTracker
16
+ MODEL = "model"
17
+ TOOL = "tool"
18
+ EMPTY_TIMINGS = { "model_seconds" => 0.0, "tool_seconds" => 0.0, "model_calls" => 0, "tool_calls" => 0 }.freeze
19
+
20
+ def initialize(clock: nil)
21
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
22
+ @monitor = Monitor.new
23
+ @current = nil
24
+ @timings = EMPTY_TIMINGS.dup
25
+ @last_progress_at = nil
26
+ end
27
+
28
+ # Marks the start of an in-flight operation. detail is already
29
+ # redacted/clipped by the caller (it is model-visible telemetry).
30
+ def start(kind, name: nil, detail: nil)
31
+ now = @clock.call
32
+ @monitor.synchronize do
33
+ @current = { kind: kind.to_s, name: name.to_s, detail: detail.to_s.empty? ? nil : detail.to_s,
34
+ started_at: Time.now.utc.iso8601(3), started_monotonic: now }
35
+ @last_progress_at = now
36
+ end
37
+ end
38
+
39
+ # Ends the tracked operation and accumulates its duration into the
40
+ # matching model/tool bucket. Returns the elapsed seconds, or nil when
41
+ # the operation is not the one being tracked (e.g. after a reset).
42
+ def finish(kind)
43
+ @monitor.synchronize do
44
+ return nil unless @current && @current[:kind] == kind.to_s
45
+
46
+ elapsed = [@clock.call - @current[:started_monotonic], 0.0].max
47
+ bucket = kind.to_s == MODEL ? "model_seconds" : "tool_seconds"
48
+ counter = kind.to_s == MODEL ? "model_calls" : "tool_calls"
49
+ @timings[bucket] = (@timings[bucket] + elapsed).round(3)
50
+ @timings[counter] += 1
51
+ @current = nil
52
+ # Completing an operation is observable progress too.
53
+ @last_progress_at = @clock.call
54
+ elapsed
55
+ end
56
+ end
57
+
58
+ def progress!
59
+ now = @clock.call
60
+ @monitor.synchronize { @last_progress_at = now }
61
+ end
62
+
63
+ # A redeployed run is a new lineage: no stale operation, fresh buckets.
64
+ def reset!
65
+ @monitor.synchronize do
66
+ @current = nil
67
+ @timings = EMPTY_TIMINGS.dup
68
+ @last_progress_at = nil
69
+ end
70
+ end
71
+
72
+ # JSON-ready snapshot of the in-flight operation (nil when idle).
73
+ def current
74
+ @monitor.synchronize do
75
+ return nil unless @current
76
+
77
+ { kind: @current[:kind], name: @current[:name], detail: @current[:detail],
78
+ started_at: @current[:started_at],
79
+ elapsed_seconds: [@clock.call - @current[:started_monotonic], 0.0].max.round(3) }
80
+ end
81
+ end
82
+
83
+ def timings = @monitor.synchronize { @timings.dup }
84
+
85
+ def last_progress_seconds_ago
86
+ @monitor.synchronize do
87
+ @last_progress_at ? [@clock.call - @last_progress_at, 0.0].max.round(3) : nil
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "../../security/redactor"
5
+ require_relative "../../uris"
6
+
7
+ module Rubik
8
+ module Orchestrator
9
+ module Subagent
10
+ # Bounded, redacted, paginated access to completed run results (issue #122).
11
+ #
12
+ # Every existing surface truncates: an Instance keeps only the latest
13
+ # run's result under the terminal-only contract, wait rows show a 1500-char
14
+ # preview, transcript rendering and storage summaries have their own caps.
15
+ # This archive is the ONE retrieval contract for the remainder: each
16
+ # settled run's result is retained under an execution-specific reference
17
+ # (unique storage id + run sequence — redeploy runs never overwrite an
18
+ # earlier one), redacted at retention time, and served back in bounded
19
+ # pages with stable cursors (`has_more` / `next_cursor`).
20
+ #
21
+ # Reads are pure memory: no model call, no spawn/redeploy, no execution
22
+ # state change, no filesystem access (so no workspace allowlisting is
23
+ # involved). Retention is session-scoped and bounded by
24
+ # `subagents.result_archive.*` — newest `max_runs_per_instance` runs per
25
+ # execution, `ttl_seconds` lifetime — and unknown, evicted, or expired
26
+ # references answer explicitly instead of silently serving another run.
27
+ class ResultArchive
28
+ Entry = Struct.new(:ref, :storage_id, :id, :run, :state, :body, :retained_at, :expires_at, keyword_init: true)
29
+
30
+ Page = Struct.new(:ref, :storage_id, :id, :run, :state, :body, :cursor, :next_cursor,
31
+ :total_chars, :returned_chars, :has_more, :redacted,
32
+ :retained_at, :expires_at, keyword_init: true) do
33
+ def ok? = true
34
+ end
35
+
36
+ Missing = Struct.new(:ref, :reason, :message, keyword_init: true) do
37
+ def ok? = false
38
+ end
39
+
40
+ DEFAULT_TTL_SECONDS = 3600
41
+ DEFAULT_MAX_RUNS = 8
42
+ DEFAULT_PAGE_CHARS = 4000
43
+ MAX_PAGE_CHARS = 40_000
44
+
45
+ def initialize(config: nil, clock: nil, ttl_seconds: nil, max_runs_per_instance: nil, page_chars: nil)
46
+ @clock = clock || -> { Time.now }
47
+ @ttl_seconds = (ttl_seconds || config_get(config, "subagents.result_archive.ttl_seconds", DEFAULT_TTL_SECONDS)).to_f
48
+ @max_runs = (max_runs_per_instance || config_get(config, "subagents.result_archive.max_runs_per_instance", DEFAULT_MAX_RUNS)).to_i
49
+ @page_chars = (page_chars || config_get(config, "subagents.result_archive.page_chars", DEFAULT_PAGE_CHARS)).to_i
50
+ @max_runs = [@max_runs, 0].max
51
+ @page_chars = @page_chars.clamp(1, MAX_PAGE_CHARS)
52
+ @entries = {} # storage_id => { run => Entry }
53
+ @monitor = Monitor.new
54
+ end
55
+
56
+ # Retains one settled run's result. Redaction happens HERE (the raw
57
+ # in-memory history keeps its wire-faithful copy), the body is never
58
+ # mutated afterwards, and the run sequence keys the entry so a
59
+ # redeployed run cannot overwrite its predecessor.
60
+ def retain(storage_id:, id:, run:, state:, body:)
61
+ sid = storage_id.to_s
62
+ seq = [run.to_i, 1].max
63
+ now = @clock.call
64
+ entry = Entry.new(ref: Rubik::Uris.subagent_result(sid, run: seq), storage_id: sid, id: id.to_s,
65
+ run: seq, state: state.to_s, body: Rubik::Security::Redactor.redact(body.to_s),
66
+ retained_at: now, expires_at: now + @ttl_seconds)
67
+ @monitor.synchronize do
68
+ sweep_expired!(now)
69
+ runs = (@entries[sid] ||= {})
70
+ runs[seq] = entry
71
+ runs.delete(runs.keys.min) while runs.size > @max_runs
72
+ end
73
+ entry
74
+ end
75
+
76
+ # Bounded read by run (nil = latest retained run). Returns a Page on
77
+ # success — with cursor/next_cursor/has_more and explicit truncation
78
+ # metadata — or a Missing result with an explicit reason.
79
+ def read(storage_id:, run: nil, cursor: 0, limit: nil)
80
+ sid = storage_id.to_s
81
+ now = @clock.call
82
+ @monitor.synchronize do
83
+ runs = @entries[sid]
84
+ return missing(Rubik::Uris.subagent_result(sid, run: run&.to_i), :unknown, unknown_message(sid)) if runs.nil? || runs.empty?
85
+
86
+ entry = run ? runs[run.to_i] : runs[runs.keys.max]
87
+ unless entry
88
+ return missing(Rubik::Uris.subagent_result(sid, run: run.to_i), :unknown,
89
+ "No retained run #{run} for execution '#{sid}' — retained run(s): #{runs.keys.sort.join(', ')}. #{retention_note}")
90
+ end
91
+
92
+ if expired?(entry, now)
93
+ runs.delete(entry.run)
94
+ @entries.delete(sid) if runs.empty?
95
+ return missing(entry.ref, :expired,
96
+ "Retained result #{entry.ref} expired at #{entry.expires_at.utc.iso8601} (retention: #{@ttl_seconds.round}s). #{retention_note}")
97
+ end
98
+
99
+ page_for(entry, cursor, limit)
100
+ end
101
+ end
102
+
103
+ # Any non-expired retained entry for this execution id? Lets callers
104
+ # accept a raw storage id after the instance itself was reaped.
105
+ def known?(storage_id)
106
+ sid = storage_id.to_s
107
+ now = @clock.call
108
+ @monitor.synchronize do
109
+ runs = @entries[sid]
110
+ next false unless runs
111
+
112
+ runs.delete_if { |_run, entry| expired?(entry, now) }
113
+ !runs.empty?
114
+ end
115
+ end
116
+
117
+ def latest_run(storage_id)
118
+ @monitor.synchronize { (@entries[storage_id.to_s] || {}).keys.max }
119
+ end
120
+
121
+ # THE page rendering (tool action + rubik:// read share it, so the two
122
+ # access paths cannot drift). The continuation is the paginated API
123
+ # call — the reference itself always identifies the exact run.
124
+ def self.render_page(page, id: nil)
125
+ who = id || page.id
126
+ lines = ["Retained result #{page.ref} (run #{page.run}, state #{page.state}, execution '#{who}')",
127
+ "chars #{page.cursor}-#{page.cursor + page.returned_chars} of #{page.total_chars}; " \
128
+ "credentials redacted; expires #{page.expires_at&.utc&.iso8601}",
129
+ "---", page.body, "---"]
130
+ lines << if page.has_more
131
+ "More content follows — next page: manage_subagents(action: 'result', conversation_id: '#{who}', " \
132
+ "run: #{page.run}, cursor: #{page.next_cursor})"
133
+ else
134
+ "(complete result)"
135
+ end
136
+ lines.join("\n")
137
+ end
138
+
139
+ private
140
+
141
+ def page_for(entry, cursor, limit)
142
+ body = entry.body
143
+ total = body.length
144
+ cur = [cursor.to_i, 0].max
145
+ return missing(entry.ref, :cursor, "Cursor #{cur} exceeds the retained result length (#{total} chars). Use cursor 0.") if cur > total
146
+
147
+ size = limit.to_i
148
+ size = @page_chars if size <= 0
149
+ size = [size, MAX_PAGE_CHARS].min
150
+ text = body[cur, size].to_s
151
+ nxt = cur + text.length
152
+ has_more = nxt < total
153
+ Page.new(ref: entry.ref, storage_id: entry.storage_id, id: entry.id, run: entry.run, state: entry.state,
154
+ body: text, cursor: cur, next_cursor: has_more ? nxt : nil, total_chars: total,
155
+ returned_chars: text.length, has_more: has_more, redacted: true,
156
+ retained_at: entry.retained_at, expires_at: entry.expires_at)
157
+ end
158
+
159
+ def missing(ref, reason, message)
160
+ Missing.new(ref: ref, reason: reason, message: message)
161
+ end
162
+
163
+ def unknown_message(sid)
164
+ "No retained result for execution '#{sid}'. #{retention_note}"
165
+ end
166
+
167
+ def retention_note
168
+ "The archive keeps the newest #{@max_runs} run(s) per execution for #{@ttl_seconds.round}s in this session; " \
169
+ "durable history remains in the subagent transcript."
170
+ end
171
+
172
+ def expired?(entry, now)
173
+ @ttl_seconds.positive? && entry.expires_at && entry.expires_at <= now
174
+ end
175
+
176
+ def sweep_expired!(now)
177
+ @entries.each do |sid, runs|
178
+ runs.delete_if { |_run, entry| expired?(entry, now) }
179
+ @entries.delete(sid) if runs.empty?
180
+ end
181
+ end
182
+
183
+ def config_get(config, key, default)
184
+ return default if config.nil?
185
+
186
+ val = if config.is_a?(Hash)
187
+ config[key] || config.dig(*key.split("."))
188
+ elsif config.respond_to?(:fetch)
189
+ config.fetch(key, nil)
190
+ end
191
+ val.nil? ? default : val
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -52,7 +52,7 @@ module Rubik
52
52
  # ve taze scoped registry bağlanır (eskisinin ask_question'ı
53
53
  # dispose'lu ctx'e rebind'lıydı). Yeni görev history'ye user
54
54
  # mesajı olarak eklenir; run cycle yalnız BOŞ history seed eder.
55
- def redeploy_for!(prompt:, max_steps:, context:, tool_registry:)
55
+ def redeploy_for!(prompt:, max_steps:, context:, tool_registry:, owner_task: nil)
56
56
  @monitor.synchronize do
57
57
  return false unless redeployable?
58
58
 
@@ -65,6 +65,13 @@ module Rubik
65
65
  # the old run's wait fact must not narrate the new one.
66
66
  @result = @last_error = @ended_at = @transcript_path = nil
67
67
  @pause_facts = nil
68
+ # Task ownership (issue #125): the redeployed run belongs to the
69
+ # CURRENT task's token (or stays detached); a fresh lineage is not
70
+ # born "cancelled by operator". Operation telemetry (#126) starts
71
+ # clean with it — no stale operation survives a redeploy.
72
+ @owner_task = owner_task
73
+ @cancelled_by_operator = false
74
+ @operations.reset!
68
75
  @context = context
69
76
  @tool_registry = tool_registry
70
77
  @context_cleaned_up = false
@@ -81,7 +88,10 @@ module Rubik
81
88
  # imkânsız — bkz. activation.rb). provider/buffer/provider_gate/
82
89
  # storage_id/kimlik AYNEN KALIR (reuse'un kendisi bu).
83
90
  module RedeploySupervisor
84
- def redeploy(id, prompt:, max_steps: nil)
91
+ # `owner: :keep` (the default) keeps the instance's existing task
92
+ # ownership; the tool plane passes the CURRENT turn's token so a
93
+ # redeploy issued in this turn is cancellable with it (issue #125).
94
+ def redeploy(id, prompt:, max_steps: nil, owner: :keep)
85
95
  prompt_str = prompt.to_s.strip
86
96
  raise ArgumentError, "Redeploy prompt cannot be empty" if prompt_str.empty?
87
97
 
@@ -90,15 +100,18 @@ module Rubik
90
100
  inst = @instances[key] || (@finished || {})[key]
91
101
  raise ArgumentError, redeploy_refusal(key, inst) unless inst&.redeployable?
92
102
 
103
+ eff_owner = owner == :keep ? inst.owner_task : owner
104
+ refute_cancelled_owner!(eff_owner)
93
105
  validate_concurrency!
94
106
  child_ctx = extend_root_context!(:redeploy)
95
107
  scoped_registry = build_scoped_registry(@type_registry.get(inst.type_name), child_ctx, inst.buffer_manager)
96
108
  return false unless inst.redeploy_for!(prompt: prompt_str, max_steps: (max_steps || @default_max_steps).to_i,
97
- context: child_ctx, tool_registry: scoped_registry)
109
+ context: child_ctx, tool_registry: scoped_registry,
110
+ owner_task: eff_owner)
98
111
 
99
112
  @finished&.delete(inst.id)
100
113
  @instances[inst.id] = inst
101
- activate_instance!(inst, child_ctx: child_ctx)
114
+ activate_instance!(inst, child_ctx: child_ctx, owner_token: eff_owner)
102
115
  end
103
116
  # SS bırakma kancası: redeploy da bir başlangıçtır — taze :running
104
117
  # flip'i, bu id'yi SS ile bekleyen kuyruk spesifikasyonlarını
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transcript_formatter"
4
+ require_relative "narration"
5
+
6
+ module Rubik
7
+ module Orchestrator
8
+ module Subagent
9
+ # The per-run entry/exit choreography, extracted from Instance to keep
10
+ # it under its module-hygiene cap (issue #122 added the run-sequence
11
+ # reference plumbing there): history seeding, the nil-provider terminal
12
+ # stub, the single failure drain, effective-prompt assembly and the
13
+ # durable transcript save belong to ONE vertical slice — a fresh run and
14
+ # a redeploy both enter through run_execution_cycle.
15
+ module RunCycle
16
+ # The human-readable rendering of the current history (public read
17
+ # seam; lives beside the durable-save choreography it mirrors).
18
+ def format_transcript
19
+ TranscriptFormatter.format(self, @history, @monitor)
20
+ end
21
+
22
+ private
23
+
24
+ def run_execution_cycle
25
+ # Fresh runs seed; a redeployed run continues its preserved history.
26
+ @history.push({ role: "system", content: build_effective_system_prompt }, { role: "user", content: @prompt }) if @history.empty?
27
+
28
+ unless @provider
29
+ @budget.consume
30
+ @result = Narration.completed_stub_line(@id, @role, @prompt)
31
+ transition!(:completed)
32
+ @unit&.sync_state!
33
+ save_transcript!
34
+ return
35
+ end
36
+
37
+ execute_steps_with_provider
38
+ @unit&.sync_state!
39
+ save_transcript!
40
+ rescue Lifecycle::StateError => e
41
+ fail_run("LIFECYCLE INVARIANT VIOLATED: #{e.class}: #{e.message}")
42
+ rescue StandardError => e
43
+ fail_run("#{e.class}: #{e.message}")
44
+ end
45
+
46
+ # The run's single failure drain: record why, land on :failed, sync
47
+ # the unit, persist — the loud prefix keeps an FSM violation
48
+ # distinguishable from a provider failure.
49
+ def fail_run(reason)
50
+ @monitor.synchronize do
51
+ @last_error = reason
52
+ transition!(:failed)
53
+ @unit&.sync_state!
54
+ end
55
+ save_transcript!
56
+ end
57
+
58
+ # Private by design: the transcript URI is minted once at construction
59
+ # and every save mirrors the in-memory history (redacted at the file
60
+ # boundary) — callers own the retention decision, not the path.
61
+ def save_transcript!
62
+ saved = TranscriptFormatter.save!(@transcript_uri, @history, @monitor,
63
+ prompt_reconstructible: @prompt_reconstructible)
64
+ @transcript_path = saved
65
+ # A failed save must not be silent: the advertised path would 404 silently.
66
+ @last_error ||= "Transcript save failed for #{@transcript_uri}: see stderr" unless saved
67
+ end
68
+
69
+ def build_effective_system_prompt
70
+ base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
71
+ "Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
72
+ "Allocated Step Budget: #{@budget.max_steps} steps.\n\n" \
73
+ "A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). Pace yourself: " \
74
+ "when remaining steps (Y - X) run low, conclude the task or prepare a progress summary for the maestro " \
75
+ "supervisor — it may extend your budget or steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
76
+ "ask_question tool — it blocks until the maestro answers or times out; ask only when the answer truly changes your work.\n\n" \
77
+ "Perform your assigned task with high precision and provide a structured synthesis report."
78
+ @system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../coeffects"
4
+
3
5
  module Rubik
4
6
  module Orchestrator
5
7
  module Subagent
@@ -51,14 +53,16 @@ module Rubik
51
53
  end
52
54
  end
53
55
 
54
- # Subagent düzlemine geçen araçlar (ask_question + send_message):
55
- # her ikisinin de coeffect çözümü yalnız child ctx'te sağlanır
56
+ # Subagent düzlemine geçen araçlar (ask_question + send_message +
57
+ # orchestration plane): coeffect çözümü yalnız child ctx'te sağlanır
56
58
  # (supervisor#spawn/redeploy provide). The rebind MUST precede the
57
59
  # buffer guard conceptually: research-type subagents inherit
58
60
  # workspace buffers, but ask_question still has to swap to the child
59
61
  # ctx — otherwise it would keep the ROOT tool (live shell) and pop a
60
62
  # HUMAN dialog from the worker thread; send_message swaps so the
61
- # subagent-plane message carries its REAL sender id to the maestro.
63
+ # subagent-plane message carries its REAL sender id to the maestro;
64
+ # invoke/manage swap so a nested spawn resolves the INSTANCE's task
65
+ # token (issue #125) instead of the root turn's.
62
66
  # Class-matched (not name-matched): a plugin/dynamic tool registered
63
67
  # under the same name keeps its own implementation in every plane.
64
68
  def rebind_to_context(tool, ctx)
@@ -69,10 +73,18 @@ module Rubik
69
73
  Rubik::Tools::AskQuestionTool.new(shell: nil, runtime_context: ctx)
70
74
  when Rubik::Tools::SendMessageTool
71
75
  Rubik::Tools::SendMessageTool.new(runtime_context: ctx)
76
+ when Rubik::Tools::InvokeSubagentTool
77
+ Rubik::Tools::InvokeSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
78
+ when Rubik::Tools::ManageSubagentsTool
79
+ Rubik::Tools::ManageSubagentsTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
72
80
  else
73
81
  tool
74
82
  end
75
83
  end
84
+
85
+ def supervisor_for(ctx)
86
+ Rubik::Coeffects.get(ctx, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
87
+ end
76
88
  end
77
89
  end
78
90
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "../../prompt"
5
+ require_relative "../../platform"
6
+ require_relative "../../security/redactor"
5
7
  require_relative "../../tools/truncator"
8
+ require_relative "../../uris"
6
9
  require_relative "../provider_recovery"
7
10
  require_relative "../auto_apply"
8
11
  require_relative "../tool_call_id"
@@ -13,6 +16,9 @@ require_relative "../../coeffects"
13
16
  require_relative "notices"
14
17
  require_relative "resilience"
15
18
  require_relative "conversation_id"
19
+ require_relative "context_compaction"
20
+ require_relative "operation_tracker"
21
+ require_relative "tool_dispatch"
16
22
 
17
23
  module Rubik
18
24
  module Orchestrator
@@ -24,6 +30,9 @@ module Rubik
24
30
  # spine (agent-turn-executor plan); this module keeps the invocation path, budget
25
31
  # mechanics, and the subagent dispatch.
26
32
  module StepRunner
33
+ include ContextCompaction
34
+ include ToolDispatch
35
+
27
36
  # The shared response-processing spine, built lazily on first use so
28
37
  # objects that skip initialize (test doubles) stay safe.
29
38
  def turn_executor
@@ -78,21 +87,6 @@ module Rubik
78
87
  transition!(:failed)
79
88
  end
80
89
 
81
- def evaluate_subagent_context_compaction!
82
- return unless @context_limit&.positive?
83
-
84
- context_cfg = ProviderRecovery.compaction_config(@config)
85
- needs = Rubik::Prompt::ContextCompactor.needs_compaction?(
86
- @history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
87
- )
88
- return unless needs
89
-
90
- compacted = Rubik::Prompt::ContextCompactor.compact_history(
91
- @history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
92
- )
93
- @history.replace(compacted)
94
- end
95
-
96
90
  def invoke_provider_with_recovery(opts)
97
91
  ProviderRecovery.with_recovery(config: @config, compact: method(:emergency_compact_and_notify!)) do
98
92
  Resilience.retry_transient(config: @config, gate: @provider_gate) do
@@ -105,22 +99,6 @@ module Rubik
105
99
  @config.respond_to?(:fetch) ? @config.fetch("subagents.auto_pause_on_network_error", true) != false : true
106
100
  end
107
101
 
108
- # Compaction actuator for the subagent path (mirrors Loop's public
109
- # seam of the same name): the compaction body lives in
110
- # TurnExecutor#emergency_compact! (one home for both channels); this
111
- # delegates and the channel announces on the bus. Runs on the worker
112
- # thread between monitor sections — never call it while holding
113
- # @monitor (the emit stays lock-free).
114
- def emergency_compact_and_notify!(reason)
115
- turn_executor.emergency_compact!(reason)
116
- end
117
-
118
- # The object that owns the fact emits it — outside the monitor, so
119
- # subscribers never run under the instance lock.
120
- def emit_compacted_event(reason, layer)
121
- Notices.emit(@event_bus, Notices::COMPACTED_EVENT, { id: @id, role: @role, state: @state, reason: reason.to_s, layer: layer })
122
- end
123
-
124
102
  def invoke_provider(opts)
125
103
  effective_opts = opts.dup
126
104
  effective_opts[:timeout] ||= ProviderRecovery::SUBAGENT_REQUEST_TIMEOUT
@@ -128,10 +106,18 @@ module Rubik
128
106
  effective_opts[:context_limit] ||= @context_limit if @context_limit
129
107
  effective_opts[:max_tokens] ||= @max_output_tokens if @max_output_tokens&.positive?
130
108
 
131
- return @provider.complete(@history, effective_opts) if @provider.respond_to?(:complete)
132
- return @provider.chat(@history, **effective_opts) if @provider.respond_to?(:chat)
109
+ # Operation telemetry (issue #126): the model wait is observable
110
+ # while it lasts (status shows kind/name/elapsed) and its duration
111
+ # lands in the run's model bucket even when the call raises.
112
+ @operations.start(OperationTracker::MODEL, name: @model || "default")
113
+ begin
114
+ return @provider.complete(@history, effective_opts) if @provider.respond_to?(:complete)
115
+ return @provider.chat(@history, **effective_opts) if @provider.respond_to?(:chat)
133
116
 
134
- raise "Provider #{@provider.class} does not support complete or chat"
117
+ raise "Provider #{@provider.class} does not support complete or chat"
118
+ ensure
119
+ @operations.finish(OperationTracker::MODEL)
120
+ end
135
121
  end
136
122
 
137
123
  def drain_inbox_into_history
@@ -155,10 +141,8 @@ module Rubik
155
141
  end
156
142
 
157
143
  # Single-call subagent dispatch (the TurnExecutor's per-call seam):
158
- # pre-execution auto-apply, timed execution, and the canonical
159
- # ToolHistory row — which now carries status + duration_ms like every
160
- # other channel (the row-superset delta of the agent-turn-executor
161
- # plan; no wire-side formatter reads them, observability only).
144
+ # pre-execution auto-apply, timed execution, the canonical ToolHistory
145
+ # row (status + duration_ms; observability only), and the #124 journal.
162
146
  def execute_subagent_tool_call(tool_call)
163
147
  name = tool_call["tool"]
164
148
  args = tool_call["args"] || {}
@@ -166,15 +150,39 @@ module Rubik
166
150
  canonical_name = tool ? tool.name : name.to_s.downcase
167
151
 
168
152
  auto_apply_buffer!(:pre_execution, tool: canonical_name)
169
-
170
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
171
- output, status = dispatch_subagent_tool(tool, name, args)
172
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
153
+ # Journal the call BEFORE dispatch (issue #124): a crash inside the
154
+ # tool still leaves the attempted call correlated by its id. The
155
+ # operation snapshot (issue #126) shows the tool + redacted detail
156
+ # while it runs; the duration lands in the tool bucket either way.
157
+ @operations.start(OperationTracker::TOOL, name: canonical_name, detail: tool_detail(args))
158
+ @journal&.record_tool_call(run: @run_seq, call_id: tool_call["id"], tool: canonical_name, args: args)
159
+
160
+ result = nil
161
+ begin
162
+ result = dispatch_subagent_tool(tool, name, args)
163
+ ensure
164
+ elapsed = @operations.finish(OperationTracker::TOOL)
165
+ end
166
+ duration_ms = result.duration_ms.positive? ? result.duration_ms : ((elapsed || 0.0) * 1000).round(1)
167
+ @journal&.record_tool_result(run: @run_seq, call_id: tool_call["id"], tool: canonical_name,
168
+ status: result.status, duration_ms: duration_ms, output: result.output,
169
+ exit_code: result.exit_code, truncated: result.truncated,
170
+ artifact_ref: Rubik::Uris.subagent_transcript(@storage_id))
173
171
  Rubik::Orchestrator::ToolHistory.write(@history, tool_call_id: tool_call["id"], tool_name: name,
174
- status: status, duration_ms: duration_ms, content: output.to_s)
172
+ status: result.status, duration_ms: duration_ms,
173
+ content: result.output, exit_code: result.exit_code,
174
+ metadata: result.metadata)
175
175
  nil
176
176
  end
177
177
 
178
+ # Redacted, clipped argument preview for the model-visible operation
179
+ # snapshot (issue #126): credentials are masked before they can reach
180
+ # status/wait output, and long payloads are bounded.
181
+ def tool_detail(args)
182
+ json = Rubik::Platform.safe_json_generate(Rubik::Security::Redactor.mask_value(args))
183
+ json.length > 120 ? "#{json[0, 120]}... [truncated]" : json
184
+ end
185
+
178
186
  # The subagent's own spelling of the shared auto-apply policy (AutoApply
179
187
  # owns the decision): decide, apply+sync through the coeffect shell,
180
188
  # and REPORT failures instead of swallowing them — a failed pre-test
@@ -208,30 +216,15 @@ module Rubik
208
216
  Rubik::Coeffects.get(@context, Rubik::Coeffects::SHELL)
209
217
  end
210
218
 
211
- # Returns [truncated_output, status] so the dispatch site can build
212
- # the canonical tool row. Unknown tools and execution errors carry
213
- # :error; plain results are :ok (Result-backed tools carry their own).
214
- def dispatch_subagent_tool(tool, name, args)
215
- return ["Tool '#{name}' is not available in subagent toolset.", :error] unless tool
216
-
217
- raw = tool.execute(args)
218
- out_str = raw.respond_to?(:output) ? raw.output.to_s : raw.to_s
219
- max_chars = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_chars", 12_000) : 12_000) || 12_000
220
- max_lines = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_lines", 300) : 300) || 300
221
- trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars.to_i, max_lines: max_lines.to_i)
222
- status = raw.respond_to?(:status) ? raw.status : :ok
223
- [trunc[:text], status]
224
- rescue StandardError => e
225
- ["Tool execution error: #{e.class}: #{e.message}", :error]
226
- end
227
-
228
219
  private
229
220
 
230
221
  # Plan 109 Faz A: per-step budget transparency replaces the 75%
231
222
  # warning latch — Budget owns the marker string under its lock; the
232
223
  # step loop owns only the history mutation (a conversation fact),
233
- # the same split the warning used.
224
+ # the same split the warning used. The step boundary is also an
225
+ # observable progress point for the operation telemetry (#126).
234
226
  def inject_step_marker
227
+ @operations.progress!
235
228
  @history << { role: "user", content: @budget.step_marker }
236
229
  end
237
230
  end