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
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "time"
6
+ require "fileutils"
7
+ require_relative "../../platform"
8
+ require_relative "../../security/redactor"
9
+
10
+ module Rubik
11
+ module Orchestrator
12
+ module Subagent
13
+ # Append-only, redacted execution journal (issue #124).
14
+ #
15
+ # The durable transcript is a CONVERSATION SNAPSHOT: save! rewrites it
16
+ # from the current mutable history, so once compaction replaces earlier
17
+ # steps with a summary marker the pre-compaction events are gone from it.
18
+ # This journal is the complementary artifact: every lifecycle,
19
+ # tool-call, tool-result and compaction event is appended BEFORE the
20
+ # history can be pruned, records are never rewritten, and a redeployed
21
+ # run continues the same file distinguished by its run sequence.
22
+ #
23
+ # Retention is bounded independently of the model context: at most
24
+ # `subagents.journal.max_bytes` per execution file. Once the cap is
25
+ # reached a final `journal_truncated` record marks the boundary and
26
+ # further events are not persisted (a cancellation outcome is recorded
27
+ # when the handler can still write one). Tool payloads are previewed
28
+ # (PREVIEW_CHARS) with explicit `output_chars` / `output_truncated`
29
+ # metadata plus an `artifact_ref` to the conversation snapshot, so replay
30
+ # can distinguish originals from summaries from clipped output.
31
+ #
32
+ # Every record passes through Redactor.mask_value before it reaches the
33
+ # file — credentials are redacted before persistence, never after. A
34
+ # crash mid-append can leave a partial last line; Journal.read skips
35
+ # malformed rows and reports them, so earlier valid records always stay
36
+ # readable. Write failures are observable through #last_error (surfaced
37
+ # in Instance#to_h), never raised into the run.
38
+ class Journal
39
+ FILENAME = "journal.jsonl"
40
+ DEFAULT_MAX_BYTES = 2_097_152
41
+ # Headroom kept free for the final truncation marker: accepted records
42
+ # never consume the last bytes, so the marker always fits inside the cap.
43
+ MARKER_RESERVE = 512
44
+ MIN_MAX_BYTES = 1024
45
+ PREVIEW_CHARS = 4000
46
+ ARGS_CHARS = 2000
47
+ PROMPT_CHARS = 400
48
+
49
+ Read = Struct.new(:records, :skipped, :truncated, :error, keyword_init: true)
50
+
51
+ attr_reader :path
52
+
53
+ def initialize(path:, config: nil)
54
+ @path = path.to_s
55
+ @monitor = Monitor.new
56
+ @max_bytes = [config_get(config, "subagents.journal.max_bytes", DEFAULT_MAX_BYTES).to_i, MIN_MAX_BYTES].max
57
+ @seq = self.class.read(@path).records.length
58
+ @bytes = File.exist?(@path) ? File.size(@path) : 0
59
+ @closed = false
60
+ @last_error = nil
61
+ end
62
+
63
+ # The generic append core: sequence, run, timestamp and type are the
64
+ # record envelope; the payload is masked HERE (single redaction gate).
65
+ def append(event, run: 1, data: {}, call_id: nil)
66
+ @monitor.synchronize do
67
+ return nil if @closed
68
+
69
+ @last_run = run.to_i
70
+ row = { "seq" => @seq + 1, "run" => @last_run, "ts" => Time.now.utc.iso8601(3),
71
+ "event" => event.to_s, "data" => Rubik::Security::Redactor.mask_value(data) }
72
+ row["call_id"] = call_id.to_s if call_id
73
+ line = "#{Rubik::Platform.safe_json_generate(row)}\n"
74
+ if @bytes + line.bytesize + MARKER_RESERVE > @max_bytes
75
+ mark_truncated
76
+ return nil
77
+ end
78
+ return nil unless write!(line)
79
+
80
+ @seq += 1
81
+ @bytes += line.bytesize
82
+ row
83
+ end
84
+ end
85
+
86
+ # --- semantic record vocabulary (the only place event shapes live) ---
87
+
88
+ def record_run_started(run:, role:, type:, prompt:)
89
+ data = { "role" => role.to_s, "type" => type.to_s, "prompt_preview" => clip(prompt, PROMPT_CHARS) }
90
+ append("run_started", run: run, data: data)
91
+ end
92
+
93
+ def record_run_finished(run:, state:, result: nil, result_ref: nil)
94
+ text = result.to_s
95
+ data = { "state" => state.to_s, "result_chars" => text.length,
96
+ "result_preview" => clip(text, PROMPT_CHARS), "result_ref" => result_ref }.compact
97
+ append("run_finished", run: run, data: data)
98
+ end
99
+
100
+ def record_cancelled(run:, reason: nil)
101
+ append("cancelled", run: run, data: { "reason" => reason.to_s })
102
+ end
103
+
104
+ def record_tool_call(run:, call_id:, tool:, args:)
105
+ json = Rubik::Platform.safe_json_generate(Rubik::Security::Redactor.mask_value(args))
106
+ data = { "tool" => tool.to_s, "args_chars" => json.length }
107
+ if json.length > ARGS_CHARS
108
+ data["args_truncated"] = true
109
+ data["args_preview"] = clip(json, ARGS_CHARS)
110
+ else
111
+ data["args"] = args
112
+ end
113
+ append("tool_call", run: run, call_id: call_id, data: data)
114
+ end
115
+
116
+ def record_tool_result(run:, call_id:, tool:, status:, duration_ms:, output:, artifact_ref: nil, exit_code: nil,
117
+ truncated: false)
118
+ text = output.to_s
119
+ data = { "tool" => tool.to_s, "status" => status.to_s, "exit_code" => exit_code, "duration_ms" => duration_ms,
120
+ "output_chars" => text.length, "output_truncated" => truncated || text.length > PREVIEW_CHARS,
121
+ "output_preview" => clip(text, PREVIEW_CHARS), "artifact_ref" => artifact_ref }.compact
122
+ append("tool_result", run: run, call_id: call_id, data: data)
123
+ end
124
+
125
+ def record_compaction(run:, layer:, reason: nil, before: nil, after: nil)
126
+ data = { "layer" => layer.to_s, "reason" => reason.to_s.slice(0, 200),
127
+ "history_before" => before, "history_after" => after }.compact
128
+ append("compaction", run: run, data: data)
129
+ end
130
+
131
+ # Tolerant reader: malformed rows (an interrupted final write included)
132
+ # are skipped and COUNTED — one partial line never hides valid history.
133
+ def self.read(path)
134
+ records = []
135
+ skipped = 0
136
+ return Read.new(records: records, skipped: skipped, truncated: false) unless File.exist?(path)
137
+
138
+ File.foreach(path, encoding: "UTF-8") do |line|
139
+ next if line.strip.empty?
140
+
141
+ row = begin
142
+ JSON.parse(line.scrub)
143
+ rescue JSON::ParserError
144
+ nil
145
+ end
146
+ if row.is_a?(Hash) && row["seq"]
147
+ records << row
148
+ else
149
+ skipped += 1
150
+ end
151
+ end
152
+ Read.new(records: records, skipped: skipped,
153
+ truncated: records.any? { |r| r["event"] == "journal_truncated" })
154
+ rescue StandardError => e
155
+ Read.new(records: records, skipped: skipped + 1, truncated: false, error: e)
156
+ end
157
+
158
+ def read = self.class.read(@path)
159
+
160
+ # The last persistence failure (nil while healthy) — read under the
161
+ # journal lock so Instance#to_h never observes a half-written value.
162
+ def last_error = @monitor.synchronize { @last_error }
163
+
164
+ private
165
+
166
+ def mark_truncated
167
+ @closed = true
168
+ marker = { "seq" => @seq + 1, "run" => (@last_run || 1).to_i, "ts" => Time.now.utc.iso8601(3),
169
+ "event" => "journal_truncated",
170
+ "data" => { "max_bytes" => @max_bytes, "reason" => "retention cap reached; further events are not journaled" } }
171
+ line = "#{Rubik::Platform.safe_json_generate(marker)}\n"
172
+ return nil if @bytes + line.bytesize > @max_bytes || !write!(line)
173
+
174
+ @seq += 1
175
+ @bytes += line.bytesize
176
+ nil
177
+ end
178
+
179
+ def write!(line)
180
+ FileUtils.mkdir_p(File.dirname(@path))
181
+ File.write(@path, line, mode: "a")
182
+ true
183
+ rescue StandardError => e
184
+ @last_error = "#{e.class}: #{e.message}"
185
+ false
186
+ end
187
+
188
+ def clip(text, limit)
189
+ value = Rubik::Security::Redactor.redact(text.to_s)
190
+ value.length > limit ? "#{value[0, limit]}... [truncated #{value.length - limit} chars]" : value
191
+ end
192
+
193
+ def config_get(config, key, default)
194
+ return default if config.nil?
195
+
196
+ val = if config.is_a?(Hash)
197
+ config[key] || config.dig(*key.split("."))
198
+ elsif config.respond_to?(:fetch)
199
+ config.fetch(key, nil)
200
+ end
201
+ val.nil? ? default : val
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -74,6 +74,19 @@ module Rubik
74
74
  end
75
75
  end
76
76
 
77
+ # Wait-return narration (issue #126): WHY the wait returned, as the next
78
+ # actionable fact; nil for a settled wait (no story to tell).
79
+ WAIT_REASON_HINTS = {
80
+ deadline: "Deadline reached while subagent(s) remain ACTIVE — they keep running; wait again with a longer timeout or conclude your turn.",
81
+ paused: "Subagent(s) are paused and need an action (extend_budget / resume / answer their question) before the chain continues.",
82
+ queued: "Only dependency-queued work remains — it launches automatically once its predecessors settle; no action needed.",
83
+ unknown: "No such subagent id(s) — check the roster with manage_subagents(action: 'list')."
84
+ }.freeze
85
+
86
+ def wait_reason_hint(reason)
87
+ WAIT_REASON_HINTS[reason.to_sym]
88
+ end
89
+
77
90
  # The nil-provider fast path's terminal stub (a legitimate outcome:
78
91
  # no provider, no story beyond the echo of the task itself).
79
92
  def completed_stub_line(id, role, prompt)
@@ -93,6 +106,7 @@ module Rubik
93
106
  Action.new(canonical: "status", aliases: [], param: "conversation_id"),
94
107
  Action.new(canonical: "wait", aliases: [], param: "conversation_ids"),
95
108
  Action.new(canonical: "transcript", aliases: [], param: "conversation_id"),
109
+ Action.new(canonical: "result", aliases: [], param: "conversation_id"),
96
110
  Action.new(canonical: "extend_budget", aliases: ["grant_steps", "extend"], param: "conversation_id"),
97
111
  Action.new(canonical: "resume", aliases: ["retry"], param: "conversation_id"),
98
112
  Action.new(canonical: "redeploy", aliases: [], param: "conversation_id"),
@@ -6,9 +6,8 @@ require_relative "budget"
6
6
  module Rubik
7
7
  module Orchestrator
8
8
  module Subagent
9
- # THE single vocabulary home for subagent notices: event names and
10
- # payloads enter, two channel renderings leave (user channel: REPL
11
- # hints allowed; model channel: tool-oriented).
9
+ # THE single vocabulary home for subagent notices: event names and payloads
10
+ # enter, two channel renderings leave (user: REPL hints; model: tool-oriented).
12
11
  module Notices
13
12
  module_function
14
13
 
@@ -34,11 +33,8 @@ module Rubik
34
33
  # Sessizlik (§4.3): bu roller notice ÜRETMEZ, yalnız dmesg'e düşer (Distiller + Context Pruner).
35
34
  SILENT_ROLES = ["Vakanüvis Distiller", "Context Pruner"].freeze
36
35
 
37
- # Dmesg-plane emission metadata (bug-llm-timeout-observability): every
38
- # subagent-plane event lands in the ring under subsystem :subagent with
39
- # an operational-weight severity, so dmesg(level:/subsystem:) surfaces
40
- # a stalled subagent. MESSAGE_EVENT routes here too (a :message notice
41
- # via handle_parent_message, not normalize).
36
+ # Dmesg-plane metadata (bug-llm-timeout-observability): subagent-plane events
37
+ # land in the ring under subsystem :subagent with an operational-weight severity.
42
38
  SUBSYSTEM = :subagent
43
39
 
44
40
  SEVERITIES = {
@@ -51,12 +47,11 @@ module Rubik
51
47
  MESSAGE_EVENT => :info
52
48
  }.freeze
53
49
 
54
- # The one emission door: severity and subsystem are decided HERE,
55
- # never re-derived at call sites. Nil bus = the old `&.emit` skip;
56
- # a bus lacking #emit still raises loud; a legacy 2-arity emit gets
57
- # a plain 2-arg call (severity metadata best-effort, delivery
58
- # load-bearing). `fallback:` flips a raise into a stderr warn (the
59
- # AutoApply.report_failure pattern) for load-bearing event sites.
50
+ # The one emission door: severity and subsystem are decided HERE, never
51
+ # re-derived at call sites. Nil bus = the old `&.emit` skip; a bus lacking
52
+ # #emit still raises loud; a legacy 2-arity emit gets a plain 2-arg call
53
+ # (severity metadata best-effort, delivery load-bearing). `fallback:`
54
+ # flips a raise into a stderr warn (AutoApply.report_failure pattern).
60
55
  def emit(bus, event_name, payload, fallback: nil)
61
56
  return if bus.nil?
62
57
 
@@ -92,11 +87,12 @@ module Rubik
92
87
  notice[:reason] = raw_err.to_s.slice(0, kind == :question ? 300 : 120)
93
88
  end
94
89
  notice[:timeout] = payload[:timeout] if kind == :question
90
+ # Kanonik referanslar (#122/#129): sonuç + transcript okuma adresleri bildirimle taşınır.
91
+ notice[:result_ref] = payload[:result_ref].to_s if payload[:result_ref]
92
+ notice[:transcript_ref] = payload[:transcript_ref].to_s if payload[:transcript_ref]
95
93
  # B11: grant/step markers make repeated pauses distinguishable.
96
- if kind == :budget_exhausted
97
- notice[:grants] = payload[:grants].to_i if payload[:grants].to_i.positive?
98
- notice[:step] = payload[:step].to_i if payload[:step].to_i.positive?
99
- end
94
+ notice[:grants] = payload[:grants].to_i if kind == :budget_exhausted && payload[:grants].to_i.positive?
95
+ notice[:step] = payload[:step].to_i if kind == :budget_exhausted && payload[:step].to_i.positive?
100
96
  notice
101
97
  end
102
98
 
@@ -109,8 +105,7 @@ module Rubik
109
105
  "⚠️ [Subagent Auto-Apply] [#{notice[:id]}] '#{notice[:role]}' could not auto-apply its buffer (#{notice[:reason]}). Changes remain unapplied."
110
106
  when :budget_exhausted
111
107
  grants = notice[:grants].to_i
112
- step = notice[:step]
113
- cyc = " again (after #{grants} grant(s) consumed#{", at step #{step}" if step})" if grants.positive?
108
+ cyc = " again (after #{grants} grant(s) consumed#{", at step #{notice[:step]}" if notice[:step]})" if grants.positive?
114
109
  "⚠️ [Subagent Paused] [#{notice[:id]}] '#{notice[:role]}' reached step budget#{cyc} " \
115
110
  "(#{notice[:max_steps]} steps). Run '!subagents extend #{notice[:id]}' to resume."
116
111
  when :error_paused
@@ -140,6 +135,8 @@ module Rubik
140
135
  def model_element(notice)
141
136
  attrs = { kind: notice[:kind], id: notice[:id], role: notice[:role], state: notice[:state] }
142
137
  attrs[:max_steps] = notice[:max_steps] if notice[:kind] == :budget_exhausted
138
+ attrs[:result_ref] = notice[:result_ref] if notice[:result_ref]
139
+ attrs[:transcript_ref] = notice[:transcript_ref] if notice[:transcript_ref]
143
140
  reason_kinds = [:context_compacted, :auto_apply_failed, :error_paused, :message, :task, :question]
144
141
  attrs[:reason] = notice[:reason] if reason_kinds.include?(notice[:kind]) && !notice[:reason].to_s.empty?
145
142
  attrs[:timeout] = notice[:timeout] if notice[:kind] == :question && notice[:timeout]
@@ -177,8 +174,10 @@ module Rubik
177
174
  else
178
175
  if notice[:state] == "budget_exhausted"
179
176
  budget_exhausted_line(notice[:max_steps])
177
+ elsif notice[:state] == "cancelled"
178
+ "The task was cancelled (^C): this execution will produce no result. Spawn a fresh subagent if the work is still needed."
180
179
  else
181
- "Retrieve the full result with #{wait_hint(notice[:id])}."
180
+ "Retrieve the full result with #{notice[:result_ref].to_s.empty? ? wait_hint(notice[:id]) : "read_file(path: '#{notice[:result_ref]}')"}."
182
181
  end
183
182
  end
184
183
  end
@@ -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ı