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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +130 -0
- data/README.md +30 -17
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- 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/platform/process.rb +74 -2
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- 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/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- 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/lifecycle.rb +129 -136
- 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 +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
|
-
#
|
|
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
|
|
@@ -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
|
-
|
|
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ı
|