rubik 0.1.5 → 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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +42 -0
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- metadata +15 -1
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
38
|
-
#
|
|
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
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|