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
@@ -5,6 +5,10 @@ require_relative "transcript_formatter"
5
5
  require_relative "storage_id"
6
6
  require_relative "lifecycle"
7
7
  require_relative "step_runner"
8
+ require_relative "run_cycle"
9
+ require_relative "journal"
10
+ require_relative "teardown"
11
+ require_relative "operation_tracker"
8
12
  require_relative "budget"
9
13
  require_relative "questioner"
10
14
  require_relative "reuse"
@@ -12,6 +16,7 @@ require_relative "notices"
12
16
  require_relative "narration"
13
17
  require_relative "resilience"
14
18
  require_relative "../../prompt"
19
+ require_relative "../../uris"
15
20
 
16
21
  module Rubik
17
22
  module Orchestrator
@@ -23,11 +28,13 @@ module Rubik
23
28
  # transition!, and every predicate.
24
29
  include Lifecycle
25
30
  include StepRunner
31
+ include RunCycle
32
+ include Teardown
26
33
  include Questioner
27
34
  include Redeploy
28
35
  include Reaper
29
36
 
30
- attr_accessor :parent_id, :unit
37
+ attr_accessor :parent_id, :unit, :owner_task
31
38
  # `state`: the sanctioned raw reader — display/serialization/kernel
32
39
  # bridge only; control flow asks the Lifecycle predicates. `result`
33
40
  # is TERMINAL-ONLY (real report / nil-provider stub); pauses narrate
@@ -36,7 +43,8 @@ module Rubik
36
43
  :workspace, :model, :context, :tool_registry, :buffer_manager,
37
44
  :state, :started_at, :ended_at, :result,
38
45
  :last_error, :storage_id, :transcript_uri, :history,
39
- :context_limit, :max_output_tokens, :config, :pause_facts, :prompt_reconstructible
46
+ :context_limit, :max_output_tokens, :config, :pause_facts, :prompt_reconstructible,
47
+ :run_seq, :journal, :operations
40
48
 
41
49
  # Budget delegation: step counters live in Subagent::Budget (reader contract preserved).
42
50
  def current_step = @budget.current_step
@@ -46,7 +54,7 @@ module Rubik
46
54
  system_prompt: nil, workspace: "inherit", model: "inherit",
47
55
  provider: nil, buffer_manager: nil, max_steps: Budget::DEFAULT_MAX_STEPS, root_dir: Dir.pwd,
48
56
  context_limit: nil, config: nil, event_bus: nil, provider_gate: nil, transcript_storage: nil,
49
- prompt_reconstructible: false)
57
+ prompt_reconstructible: false, owner_task: nil)
50
58
  @id = id.to_s
51
59
  @parent_id = parent_id.to_s
52
60
  @role = role.to_s.strip
@@ -71,13 +79,26 @@ module Rubik
71
79
  @state = :pending
72
80
  @history = []
73
81
  @inbox = Queue.new
82
+ @owner_task = owner_task # issue #125: nil = explicitly detached (survives ^C)
83
+ @cancelled_by_operator = false
84
+ # Operation telemetry (issue #126): current operation + model/tool
85
+ # buckets, monotonic-clock based.
86
+ @operations = OperationTracker.new
74
87
  @started_at = @ended_at = @result = @last_error = @transcript_path = @pause_facts = nil
88
+ # Each run (initial or redeploy) owns a sequence number: it keys the
89
+ # retained-result reference so an earlier run's result is never
90
+ # overwritten by its redeploy (issue #122).
91
+ @run_seq = 0
75
92
  # Grant telemetry (session review B4/B11): grants consumed this run —
76
93
  # the halt message says "no FURTHER expansion" only when one landed.
77
94
  @grants_received = 0
78
95
  # SSOT (Plan 110): minted once; absorb (transcript_storage) kökü değiştirir.
79
96
  @storage_id = StorageId.mint(@id)
80
97
  @transcript_uri = TranscriptFormatter.mint_path(@root_dir, @storage_id, storage: transcript_storage)
98
+ # Append-only execution journal (issue #124): lands beside the
99
+ # transcript (absorbed or classic root), survives compaction and
100
+ # redeploys, bounded by subagents.journal.max_bytes.
101
+ @journal = Journal.new(path: File.join(File.dirname(@transcript_uri), Journal::FILENAME), config: @config)
81
102
  @monitor = Monitor.new
82
103
  @budget_cond = @monitor.new_cond
83
104
  @error_cond = @monitor.new_cond
@@ -87,6 +108,33 @@ module Rubik
87
108
 
88
109
  def uptime = @started_at ? ((@ended_at || Time.now) - @started_at).to_f : 0.0
89
110
 
111
+ # Execution-specific canonical reference to this run's retained result
112
+ # (issue #122): stable across reaping; the result archive answers
113
+ # explicitly when the run was never retained, evicted, or expired.
114
+ def result_ref
115
+ Rubik::Uris.subagent_result(@storage_id, run: @run_seq)
116
+ end
117
+
118
+ # Canonical transcript read reference (issue #123): ONE address for
119
+ # live memory and the persisted snapshot — the reader reports which
120
+ # snapshot it served. Bare display ids are not addresses.
121
+ def transcript_ref
122
+ Rubik::Uris.subagent_transcript(@storage_id)
123
+ end
124
+
125
+ # Immutable sequence snapshot under the instance lock: the read side of
126
+ # the transcript contract pages over a stable copy while the worker
127
+ # keeps appending (entries themselves are append-only after write).
128
+ def history_snapshot
129
+ @monitor.synchronize { @history.dup }
130
+ end
131
+
132
+ # Diagnostic pointers (issue #124): the append-only journal path and
133
+ # its last persistence failure (nil while healthy) — a failed journal
134
+ # write must be observable, never silently dropped.
135
+ def journal_path = @journal&.path
136
+ def journal_error = @journal&.last_error
137
+
90
138
  # Inbox girdisi: mesaj metni + GERÇEK gönderen id'si. nil veya makine
91
139
  # adı "agent-0" gönderenler render'da maestro persona'sına çevrilir
92
140
  # (iki-düzlem adlandırma sözleşmesi) — subagent gönderenler kendi
@@ -137,68 +185,45 @@ module Rubik
137
185
  return false if @thread&.alive?
138
186
 
139
187
  @started_at = Time.now
188
+ @run_seq += 1
140
189
  transition!(:running)
141
190
  @unit&.sync_state!
191
+ @journal&.record_run_started(run: @run_seq, role: @role, type: @type_name, prompt: @prompt)
142
192
  end
143
193
 
194
+ ready = Queue.new
195
+ run_gate = Queue.new
144
196
  @thread = Thread.new do
145
- run_execution_cycle
146
- ensure
147
- @ended_at ||= Time.now
148
- save_transcript! if @transcript_path.nil?
149
- cleanup_context
150
- on_complete&.call(self)
151
- end
152
- true
153
- end
154
-
155
- def kill
156
- @monitor.synchronize do
157
- return true if disposed?
158
-
159
- transition!(:canceling)
160
- @ended_at = Time.now
161
- @budget_cond.broadcast
162
- @error_cond.broadcast
163
- end
164
-
165
- begin; @thread&.kill if @thread&.alive?; rescue StandardError; nil; end
166
-
167
- cleanup
168
- true
169
- end
170
-
171
- def cleanup
172
- @monitor.synchronize do
173
- return if disposed?
174
-
175
- @budget_cond.broadcast
176
- @error_cond.broadcast
177
- cleanup_context
178
- transition!(:disposed)
179
- end
180
- end
181
-
182
- # Idempotent under monitor: check-and-set of @context_cleaned_up (ensure block vs kill cleanup race).
183
- def cleanup_context
184
- @monitor.synchronize do
185
- return if @context_cleaned_up
186
-
187
- @context_cleaned_up = true
188
- begin
189
- @context&.dispose unless @context&.disposed?
190
- rescue StandardError => e
191
- @last_error ||= "#{e.class}: #{e.message}"
197
+ Thread.handle_interrupt(Object => :never) do
198
+ ready << true # cleanup is armed before cancellation is enabled
199
+ Thread.handle_interrupt(Object => :immediate) do
200
+ run_gate.pop
201
+ run_execution_cycle
202
+ end
203
+ ensure
204
+ @ended_at ||= Time.now
205
+ @journal&.record_run_finished(run: @run_seq, state: @state.to_s, result: @result,
206
+ result_ref: (result_ref if settled?))
207
+ save_transcript! if @transcript_path.nil?
208
+ @cancelled_by_operator ? cleanup : cleanup_context
209
+ on_complete&.call(self)
192
210
  end
193
211
  end
212
+ ready.pop
213
+ run_gate << true
214
+ true
194
215
  end
195
216
 
196
217
  def to_h
197
218
  @monitor.synchronize do
198
219
  { id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
199
- **@budget.to_h, grants_received: @grants_received, uptime_seconds: uptime.round(2),
220
+ **@budget.to_h, grants_received: @grants_received, run: @run_seq,
221
+ result_ref: result_ref, owner: @owner_task&.id, detached: @owner_task.nil?, uptime_seconds: uptime.round(2),
222
+ operation: operations.current, timings: operations.timings,
223
+ last_progress_seconds_ago: operations.last_progress_seconds_ago,
200
224
  started_at: @started_at&.iso8601, ended_at: @ended_at&.iso8601,
201
- transcript: @transcript_uri, last_error: @last_error,
225
+ transcript: @transcript_uri, transcript_ref: transcript_ref,
226
+ journal: journal_path, journal_error: journal_error, last_error: @last_error,
202
227
  result_summary: @result&.to_s&.slice(0, 100), result: @result, state_note: state_note }
203
228
  end
204
229
  end
@@ -211,60 +236,14 @@ module Rubik
211
236
  end
212
237
  end
213
238
 
214
- def format_transcript
215
- TranscriptFormatter.format(self, @history, @monitor)
216
- end
217
-
218
239
  private
219
240
 
220
- # The run's single failure drain: record why, land on :failed, sync
221
- # the unit, persist — the loud prefix keeps an FSM violation
222
- # distinguishable from a provider failure.
223
- def fail_run(reason)
224
- @monitor.synchronize do
225
- @last_error = reason
226
- transition!(:failed)
227
- @unit&.sync_state!
228
- end
229
- save_transcript!
230
- end
231
-
232
241
  # Private by design: a grant must pair with the terminal guard, the
233
242
  # history note, and the broadcast — all lifecycle facts owned here.
234
243
  # Tests drive budget states through supervisor + provider, never by
235
244
  # reaching into this reader.
236
245
  attr_reader :budget
237
246
 
238
- def save_transcript!
239
- saved = TranscriptFormatter.save!(@transcript_uri, @history, @monitor,
240
- prompt_reconstructible: @prompt_reconstructible)
241
- @transcript_path = saved
242
- # A failed save must not be silent: the advertised path would 404 silently.
243
- @last_error ||= "Transcript save failed for #{@transcript_uri}: see stderr" unless saved
244
- end
245
-
246
- def run_execution_cycle
247
- # Fresh runs seed; a redeployed run continues its preserved history.
248
- @history.push({ role: "system", content: build_effective_system_prompt }, { role: "user", content: @prompt }) if @history.empty?
249
-
250
- unless @provider
251
- @budget.consume
252
- @result = Narration.completed_stub_line(@id, @role, @prompt)
253
- transition!(:completed)
254
- @unit&.sync_state!
255
- save_transcript!
256
- return
257
- end
258
-
259
- execute_steps_with_provider
260
- @unit&.sync_state!
261
- save_transcript!
262
- rescue StateError => e
263
- fail_run("LIFECYCLE INVARIANT VIOLATED: #{e.class}: #{e.message}")
264
- rescue StandardError => e
265
- fail_run("#{e.class}: #{e.message}")
266
- end
267
-
268
247
  # The single budget-grant core (Candidate 03): terminal guard ->
269
248
  # grant -> resume -> note -> broadcast. Note wording lives in
270
249
  # Budget.grant_note/message_grant_note (audience boundary: the
@@ -366,18 +345,6 @@ module Rubik
366
345
  end
367
346
  end
368
347
  end
369
-
370
- def build_effective_system_prompt
371
- base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
372
- "Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
373
- "Allocated Step Budget: #{@budget.max_steps} steps.\n\n" \
374
- "A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). Pace yourself: " \
375
- "when remaining steps (Y - X) run low, conclude the task or prepare a progress summary for the maestro " \
376
- "supervisor — it may extend your budget or steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
377
- "ask_question tool — it blocks until the maestro answers or times out; ask only when the answer truly changes your work.\n\n" \
378
- "Perform your assigned task with high precision and provide a structured synthesis report."
379
- @system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
380
- end
381
348
  end
382
349
  end
383
350
  end
@@ -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