rubik 0.2.0 → 0.3.1
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 +4 -0
- data/CHANGELOG.md +115 -0
- data/README.md +137 -268
- data/bin/rubik +9 -0
- data/lib/rubik/cli/workspace_trust.rb +78 -0
- data/lib/rubik/kernel/init.rb +15 -0
- data/lib/rubik/lsp/client.rb +3 -2
- data/lib/rubik/orchestrator/auto_apply.rb +1 -1
- data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
- data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
- data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
- data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
- data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
- data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
- data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
- data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
- data/lib/rubik/platform/process.rb +78 -1
- data/lib/rubik/prompt/context_builder.rb +54 -20
- data/lib/rubik/prompt/doctrine.rb +158 -22
- data/lib/rubik/prompt/token_estimator.rb +5 -3
- data/lib/rubik/prompt/tool_teachings.rb +19 -12
- data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
- data/lib/rubik/sandbox/driver/docker.rb +14 -60
- data/lib/rubik/sandbox/driver/podman.rb +13 -56
- data/lib/rubik/sandbox/driver/process.rb +12 -64
- data/lib/rubik/security/guard.rb +1 -1
- data/lib/rubik/storage/store.rb +52 -11
- data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
- data/lib/rubik/tools/builtins/storage.rb +8 -5
- data/lib/rubik/tools/builtins/subagent.rb +20 -53
- data/lib/rubik/tools/builtins/synthesis.rb +6 -1
- data/lib/rubik/tools/builtins.rb +1 -0
- data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
- data/lib/rubik/tools/fuzzy_matcher.rb +39 -19
- data/lib/rubik/tools/truncator.rb +54 -21
- data/lib/rubik/version.rb +1 -1
- metadata +10 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../coeffects"
|
|
5
|
+
|
|
6
|
+
module Rubik
|
|
7
|
+
module Orchestrator
|
|
8
|
+
module Subagent
|
|
9
|
+
class ProfileValidationError < ArgumentError
|
|
10
|
+
attr_reader :errors
|
|
11
|
+
|
|
12
|
+
def initialize(errors)
|
|
13
|
+
@errors = errors.map(&:freeze).freeze
|
|
14
|
+
super(JSON.generate(to_h))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{ "error" => "invalid_subagent_profile", "errors" => @errors }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Validates the model-facing archetype contract before the registry can
|
|
23
|
+
# observe it. Tool grants expand only to the existing declaration groups.
|
|
24
|
+
module ProfileContract
|
|
25
|
+
FIELD_LIMITS = { name: 64, description: 500, system_prompt: 12_000 }.freeze
|
|
26
|
+
MAX_COEFFECTS = 16
|
|
27
|
+
MAX_COEFFECT_LENGTH = 64
|
|
28
|
+
MAX_GRANTS = 2
|
|
29
|
+
CAPABILITY_GRANTS = ["write", "subagent_spawn"].map(&:freeze).freeze
|
|
30
|
+
UNSET = Object.new.freeze
|
|
31
|
+
|
|
32
|
+
KNOWN_COEFFECTS = Rubik::Coeffects.constants(false).filter_map do |constant|
|
|
33
|
+
value = Rubik::Coeffects.const_get(constant, false)
|
|
34
|
+
Rubik::Coeffects.canonical(value) if value.is_a?(String)
|
|
35
|
+
end.uniq.freeze
|
|
36
|
+
|
|
37
|
+
Profile = Struct.new(:name, :description, :system_prompt, :requires, :grants, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def validate(name:, description:, system_prompt:, requires: [], grants: UNSET,
|
|
42
|
+
enable_write_tools: nil, enable_subagent_tools: nil, reserved_names: [], override_requested: false)
|
|
43
|
+
errors = []
|
|
44
|
+
profile_name = normalize_text(name, :name, errors, allow_symbol: true)
|
|
45
|
+
profile_description = normalize_text(description, :description, errors)
|
|
46
|
+
profile_prompt = normalize_text(system_prompt, :system_prompt, errors)
|
|
47
|
+
|
|
48
|
+
errors << error("name", "too_long", "Name must be at most #{FIELD_LIMITS.fetch(:name)} characters.") if profile_name.length > FIELD_LIMITS.fetch(:name)
|
|
49
|
+
if profile_description.length > FIELD_LIMITS.fetch(:description)
|
|
50
|
+
errors << error("description", "too_long", "Description must be at most #{FIELD_LIMITS.fetch(:description)} characters.")
|
|
51
|
+
end
|
|
52
|
+
if profile_prompt.length > FIELD_LIMITS.fetch(:system_prompt)
|
|
53
|
+
errors << error("system_prompt", "too_long", "System prompt must be at most #{FIELD_LIMITS.fetch(:system_prompt)} characters.")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
reserved = Array(reserved_names).map { |reserved_name| reserved_name.to_s.strip.downcase }
|
|
57
|
+
if reserved.include?(profile_name.downcase)
|
|
58
|
+
detail = override_requested ? " even when an override flag is supplied" : ""
|
|
59
|
+
errors << error("name", "reserved_name", "built-in system archetypes are reserved#{detail}.")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
normalized_requires = normalize_list(
|
|
63
|
+
requires, field: "requires", allowed: KNOWN_COEFFECTS, maximum: MAX_COEFFECTS,
|
|
64
|
+
unknown_code: "unknown_coeffect", duplicate_code: "duplicate_coeffect", errors: errors
|
|
65
|
+
) do |value|
|
|
66
|
+
Rubik::Coeffects.canonical(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
normalized_grants = normalize_grants(
|
|
70
|
+
grants, enable_write_tools, enable_subagent_tools, errors
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
raise ProfileValidationError, errors unless errors.empty?
|
|
74
|
+
|
|
75
|
+
Profile.new(
|
|
76
|
+
name: profile_name, description: profile_description, system_prompt: profile_prompt,
|
|
77
|
+
requires: normalized_requires.map(&:freeze).freeze, grants: normalized_grants.map(&:freeze).freeze
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tool_filter(grants)
|
|
82
|
+
enabled = grants.freeze
|
|
83
|
+
write_tools = Rubik::Tools::Catalog.write_grant_tools
|
|
84
|
+
spawn_tools = Rubik::Tools::Catalog.spawn_tools
|
|
85
|
+
|
|
86
|
+
lambda do |tool_name|
|
|
87
|
+
next false if write_tools.include?(tool_name) && !enabled.include?("write")
|
|
88
|
+
next false if spawn_tools.include?(tool_name) && !enabled.include?("subagent_spawn")
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalize_text(value, field, errors, allow_symbol: false)
|
|
95
|
+
if value.nil?
|
|
96
|
+
errors << error(field.to_s, "required", "#{field} is required.")
|
|
97
|
+
return ""
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
valid_type = value.is_a?(String) || (allow_symbol && value.is_a?(Symbol))
|
|
101
|
+
unless valid_type
|
|
102
|
+
errors << error(field.to_s, "invalid_type", "#{field} must be a string.")
|
|
103
|
+
return ""
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
normalized = value.to_s.strip
|
|
107
|
+
errors << error(field.to_s, "required", "#{field} must not be empty.") if normalized.empty?
|
|
108
|
+
normalized
|
|
109
|
+
end
|
|
110
|
+
private_class_method :normalize_text
|
|
111
|
+
|
|
112
|
+
def normalize_list(values, field:, allowed:, maximum:, unknown_code:, duplicate_code:, errors:)
|
|
113
|
+
unless values.is_a?(Array)
|
|
114
|
+
errors << error(field, "invalid_type", "#{field} must be an array of strings.")
|
|
115
|
+
return []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
errors << error(field, "too_many_values", "#{field} may contain at most #{maximum} values.") if values.length > maximum
|
|
119
|
+
|
|
120
|
+
normalized = values.filter_map.with_index do |value, index|
|
|
121
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
122
|
+
errors << error(field, "invalid_type", "#{field}[#{index}] must be a string.")
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
text = value.to_s.strip.downcase
|
|
127
|
+
if field == "requires" && text.length > MAX_COEFFECT_LENGTH
|
|
128
|
+
errors << error(field, "too_long", "#{field}[#{index}] must be at most #{MAX_COEFFECT_LENGTH} characters.")
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
key = yield(text)
|
|
133
|
+
if key.empty?
|
|
134
|
+
errors << error(field, "empty_value", "#{field}[#{index}] must not be empty.")
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
unless allowed.include?(key)
|
|
138
|
+
errors << error(field, unknown_code, "#{field}[#{index}] is not in the allowlist.")
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
key
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
duplicates = normalized.tally.select { |_value, count| count > 1 }.keys
|
|
146
|
+
duplicates.each do |duplicate|
|
|
147
|
+
errors << error(field, duplicate_code, "#{field} contains duplicate value '#{duplicate}'.")
|
|
148
|
+
end
|
|
149
|
+
normalized
|
|
150
|
+
end
|
|
151
|
+
private_class_method :normalize_list
|
|
152
|
+
|
|
153
|
+
def normalize_grants(grants, enable_write_tools, enable_subagent_tools, errors)
|
|
154
|
+
legacy = {
|
|
155
|
+
"write" => enable_write_tools,
|
|
156
|
+
"subagent_spawn" => enable_subagent_tools
|
|
157
|
+
}
|
|
158
|
+
legacy_present = legacy.values.any? { |value| !value.nil? }
|
|
159
|
+
|
|
160
|
+
unless grants.equal?(UNSET)
|
|
161
|
+
errors << error("grants", "conflicting_grant_sources", "Use grants or legacy enable_* flags, not both.") if legacy_present
|
|
162
|
+
return normalize_list(
|
|
163
|
+
grants, field: "grants", allowed: CAPABILITY_GRANTS, maximum: MAX_GRANTS,
|
|
164
|
+
unknown_code: "unknown_grant", duplicate_code: "duplicate_grant", errors: errors
|
|
165
|
+
) { |value| value }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
legacy.each_with_object([]) do |(grant, value), normalized|
|
|
169
|
+
next if value.nil?
|
|
170
|
+
|
|
171
|
+
unless [true, false].include?(value)
|
|
172
|
+
errors << error("grants", "invalid_legacy_grant", "Legacy capability flags must be booleans.")
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
normalized << grant if value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
private_class_method :normalize_grants
|
|
179
|
+
|
|
180
|
+
def error(field, code, message)
|
|
181
|
+
{ "field" => field, "code" => code, "message" => message }
|
|
182
|
+
end
|
|
183
|
+
private_class_method :error
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubik
|
|
4
|
+
module Orchestrator
|
|
5
|
+
module Subagent
|
|
6
|
+
class UnsatisfiedCoeffectsError < StandardError
|
|
7
|
+
attr_reader :profile_name, :missing
|
|
8
|
+
|
|
9
|
+
def initialize(profile_name, missing)
|
|
10
|
+
@profile_name = profile_name.to_s.freeze
|
|
11
|
+
@missing = missing.map { |name| name.to_s.freeze }.freeze
|
|
12
|
+
super("Subagent archetype '#{@profile_name}' requires unsatisfied coeffects: #{@missing.join(', ')}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ReservedProfileError < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Per-spawn copy of the profile fields that affect activation and tool
|
|
19
|
+
# visibility. Registry updates replace future snapshots; they never
|
|
20
|
+
# mutate the copy held by an active instance.
|
|
21
|
+
class ProfileSnapshot
|
|
22
|
+
attr_reader :name, :description, :system_prompt, :effective_system_prompt,
|
|
23
|
+
:requires, :grants, :tool_filter, :custom_prompt, :revision,
|
|
24
|
+
:profile_version
|
|
25
|
+
|
|
26
|
+
def self.capture(type_definition, effective_system_prompt: nil)
|
|
27
|
+
legacy_grants = []
|
|
28
|
+
legacy_grants << "write" if type_definition.enable_write_tools
|
|
29
|
+
legacy_grants << "subagent_spawn" if type_definition.enable_subagent_tools
|
|
30
|
+
grants = type_definition.grants.nil? ? legacy_grants : type_definition.grants
|
|
31
|
+
|
|
32
|
+
new(
|
|
33
|
+
name: type_definition.name,
|
|
34
|
+
description: type_definition.description,
|
|
35
|
+
system_prompt: type_definition.system_prompt,
|
|
36
|
+
effective_system_prompt: effective_system_prompt.nil? ? type_definition.system_prompt : effective_system_prompt,
|
|
37
|
+
requires: type_definition.requires || [],
|
|
38
|
+
grants: grants,
|
|
39
|
+
tool_filter: type_definition.tool_filter,
|
|
40
|
+
custom_prompt: type_definition.custom_prompt,
|
|
41
|
+
revision: type_definition.revision
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(name:, description:, system_prompt:, effective_system_prompt:, requires:, grants:,
|
|
46
|
+
tool_filter:, custom_prompt:, revision:)
|
|
47
|
+
@name = immutable_string(name)
|
|
48
|
+
@description = immutable_string(description)
|
|
49
|
+
@system_prompt = immutable_string(system_prompt)
|
|
50
|
+
@effective_system_prompt = immutable_string(effective_system_prompt)
|
|
51
|
+
@requires = immutable_list(requires)
|
|
52
|
+
@grants = immutable_list(grants)
|
|
53
|
+
@tool_filter = tool_filter&.freeze
|
|
54
|
+
@custom_prompt = custom_prompt == true
|
|
55
|
+
@revision = revision
|
|
56
|
+
@profile_version = revision || :builtin
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def immutable_string(value)
|
|
63
|
+
value.nil? ? nil : value.to_s.dup.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def immutable_list(values)
|
|
67
|
+
Array(values).map { |value| immutable_string(value) }.freeze
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -104,7 +104,8 @@ module Rubik
|
|
|
104
104
|
refute_cancelled_owner!(eff_owner)
|
|
105
105
|
validate_concurrency!
|
|
106
106
|
child_ctx = extend_root_context!(:redeploy)
|
|
107
|
-
|
|
107
|
+
profile = inst.profile_snapshot || @type_registry.get(inst.type_name)
|
|
108
|
+
scoped_registry = build_scoped_registry(profile, child_ctx, inst.buffer_manager)
|
|
108
109
|
return false unless inst.redeploy_for!(prompt: prompt_str, max_steps: (max_steps || @default_max_steps).to_i,
|
|
109
110
|
context: child_ctx, tool_registry: scoped_registry,
|
|
110
111
|
owner_task: eff_owner)
|
|
@@ -77,6 +77,8 @@ module Rubik
|
|
|
77
77
|
Rubik::Tools::InvokeSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
|
|
78
78
|
when Rubik::Tools::ManageSubagentsTool
|
|
79
79
|
Rubik::Tools::ManageSubagentsTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
|
|
80
|
+
when Rubik::Tools::DefineSubagentTool
|
|
81
|
+
Rubik::Tools::DefineSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
|
|
80
82
|
else
|
|
81
83
|
tool
|
|
82
84
|
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubik
|
|
4
|
+
module Orchestrator
|
|
5
|
+
module Subagent
|
|
6
|
+
# Owns the transactional boundary for one subagent spawn. Kept separate
|
|
7
|
+
# from the root supervisor's query and lifecycle APIs.
|
|
8
|
+
module SpawnSupervisor
|
|
9
|
+
RESERVED_ROLE_PROFILES = {
|
|
10
|
+
"vakanüvis distiller" => "vakanuvis",
|
|
11
|
+
"context pruner" => "pruner"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
CHILD_CONTEXT_COEFFECTS = [
|
|
15
|
+
Rubik::Coeffects::SUBAGENT_QUESTION_ASKER,
|
|
16
|
+
Rubik::Coeffects::SUBAGENT_SENDER_ID,
|
|
17
|
+
Rubik::Coeffects::TASK_CANCELLATION
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def spawn(role:, prompt:, type_name: "research", system_prompt: nil,
|
|
21
|
+
parent_id: ConversationId::ROOT_ID, workspace: "inherit", model: "inherit",
|
|
22
|
+
max_steps: nil, provider: nil, depends_on: nil, reserved_id: nil, owner: nil)
|
|
23
|
+
role_str = role.to_s.strip
|
|
24
|
+
prompt_str = prompt.to_s.strip
|
|
25
|
+
raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
|
|
26
|
+
raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
|
|
27
|
+
|
|
28
|
+
# Issue #125: the owner's task was cancelled — refuse before any
|
|
29
|
+
# reservation, slot or context work happens.
|
|
30
|
+
refute_cancelled_owner!(owner)
|
|
31
|
+
|
|
32
|
+
# Bağımlı spec doğrudan spawn edilmez: kuyruğa girer (FS/SS
|
|
33
|
+
# kısıtı), bırakma süpürmesi gerçek koreografiyi oynatır.
|
|
34
|
+
if depends_on
|
|
35
|
+
return spawn_batch([{ role: role_str, prompt: prompt_str, type_name: type_name,
|
|
36
|
+
system_prompt: system_prompt, parent_id: parent_id, workspace: workspace,
|
|
37
|
+
model: model, max_steps: max_steps, provider: provider, depends_on: depends_on }],
|
|
38
|
+
owner: owner).first[1]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
created = @monitor.synchronize do
|
|
42
|
+
validate_concurrency!
|
|
43
|
+
validate_depth!(parent_id)
|
|
44
|
+
|
|
45
|
+
profile_snapshot = resolve_profile_snapshot(type_name: type_name, role: role_str,
|
|
46
|
+
system_prompt: system_prompt)
|
|
47
|
+
effective_sys_prompt = profile_snapshot.effective_system_prompt
|
|
48
|
+
# A profile update must not redirect rollback to a newly provided
|
|
49
|
+
# init supervisor after this spawn registered its unit.
|
|
50
|
+
activation_init = init_for_activation
|
|
51
|
+
# Transcript persistence needs the full custom prompt, whereas an
|
|
52
|
+
# unchanged built-in prompt can be reconstructed from its type.
|
|
53
|
+
prompt_reconstructible = system_prompt.to_s.strip.empty? && !profile_snapshot.custom_prompt
|
|
54
|
+
|
|
55
|
+
# Queued specs already reserved this id; fresh spawns reserve the
|
|
56
|
+
# next sequence number here and return it if activation rolls back.
|
|
57
|
+
generated_id = reserved_id.nil?
|
|
58
|
+
subagent_id = generated_id ? "subagent-#{@counter + 1}" : reserved_id.to_s
|
|
59
|
+
id_in_use = @instances.key?(subagent_id) || (@finished || {}).key?(subagent_id)
|
|
60
|
+
raise ArgumentError, "Subagent id '#{subagent_id}' is already in use." if id_in_use
|
|
61
|
+
|
|
62
|
+
@counter += 1 if generated_id
|
|
63
|
+
|
|
64
|
+
child_ctx = nil
|
|
65
|
+
inst = nil
|
|
66
|
+
begin
|
|
67
|
+
child_ctx = extend_root_context!(:spawn)
|
|
68
|
+
effective_buffer = branch_or_inherit_buffer(workspace)
|
|
69
|
+
scoped_registry = build_scoped_registry(profile_snapshot, child_ctx, effective_buffer)
|
|
70
|
+
prov, effective_model = resolve_provider_and_model(model)
|
|
71
|
+
prov = provider if provider
|
|
72
|
+
|
|
73
|
+
inst = Instance.new(
|
|
74
|
+
id: subagent_id, parent_id: parent_id, role: role_str, type_name: profile_snapshot.name,
|
|
75
|
+
prompt: prompt_str, system_prompt: effective_sys_prompt, workspace: workspace,
|
|
76
|
+
model: effective_model, context: child_ctx, tool_registry: scoped_registry,
|
|
77
|
+
provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
|
|
78
|
+
provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
|
|
79
|
+
context_limit: (@config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768),
|
|
80
|
+
event_bus: @event_bus, transcript_storage: absorb_storage, owner_task: owner,
|
|
81
|
+
prompt_reconstructible: prompt_reconstructible, profile_snapshot: profile_snapshot
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@instances[subagent_id] = inst
|
|
85
|
+
activate_instance!(inst, child_ctx: child_ctx, owner_token: owner,
|
|
86
|
+
init_supervisor: activation_init, defer_start: true)
|
|
87
|
+
raise "Subagent worker start gate could not be released." unless inst.release_start!
|
|
88
|
+
|
|
89
|
+
inst
|
|
90
|
+
rescue StandardError
|
|
91
|
+
rollback_spawn!(subagent_id: subagent_id, inst: inst, child_ctx: child_ctx,
|
|
92
|
+
init_supervisor: activation_init, generated_id: generated_id)
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
# SS bırakma kancası: yeni başlayan instance, onu bekleyen kuyruk
|
|
97
|
+
# spesifikasyonlarını serbest bırakır (kilit DIŞINDA — emit kuralı).
|
|
98
|
+
begin
|
|
99
|
+
release_ready!
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Rubik::Log.warn(:orchestrator, "subagent dependency release failed after successful spawn (#{e.class}: #{e.message})")
|
|
102
|
+
end
|
|
103
|
+
created
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def resolve_profile_snapshot(type_name:, role:, system_prompt:)
|
|
109
|
+
reserved_name = RESERVED_ROLE_PROFILES[role.to_s.downcase.strip]
|
|
110
|
+
if reserved_name
|
|
111
|
+
requested = type_name.to_s.downcase.strip
|
|
112
|
+
unless requested.empty? || requested == "research" || requested == reserved_name
|
|
113
|
+
raise ReservedProfileError,
|
|
114
|
+
"Reserved subagent role '#{role}' must use built-in archetype '#{reserved_name}', not '#{requested}'."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
type_def = @type_registry.builtins.find { |candidate| candidate.name.to_s.downcase == reserved_name }
|
|
118
|
+
unless type_def
|
|
119
|
+
raise ReservedProfileError,
|
|
120
|
+
"Reserved subagent role '#{role}' requires built-in archetype '#{reserved_name}', which is unavailable."
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if !system_prompt.to_s.strip.empty? && system_prompt.to_s != type_def.system_prompt.to_s
|
|
124
|
+
raise ReservedProfileError, "Reserved subagent role '#{role}' cannot override its built-in system prompt."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
effective_prompt = type_def.system_prompt
|
|
128
|
+
else
|
|
129
|
+
type_def = @type_registry.get(type_name) || @type_registry.get("research")
|
|
130
|
+
raise ReservedProfileError, "No subagent archetype is available for '#{type_name}'." unless type_def
|
|
131
|
+
|
|
132
|
+
effective_prompt = system_prompt.nil? ? type_def.system_prompt : system_prompt
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
snapshot = ProfileSnapshot.capture(type_def, effective_system_prompt: effective_prompt)
|
|
136
|
+
missing = snapshot.requires.reject do |name|
|
|
137
|
+
CHILD_CONTEXT_COEFFECTS.include?(name) || !Rubik::Coeffects.get(@root_context, name).nil?
|
|
138
|
+
end
|
|
139
|
+
raise UnsatisfiedCoeffectsError.new(snapshot.name, missing) unless missing.empty?
|
|
140
|
+
|
|
141
|
+
snapshot
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def rollback_spawn!(subagent_id:, inst:, child_ctx:, init_supervisor:, generated_id:)
|
|
145
|
+
@instances.delete(subagent_id) if inst && @instances[subagent_id].equal?(inst)
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
inst&.abort_start!
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
Rubik::Log.warn(:orchestrator, "subagent spawn thread rollback failed (#{e.class}: #{e.message})")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
begin
|
|
154
|
+
init_supervisor&.unregister(inst.unit) if inst&.unit
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
Rubik::Log.warn(:orchestrator, "subagent kernel unit rollback failed (#{e.class}: #{e.message})")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
child_ctx&.dispose
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
Rubik::Log.warn(:orchestrator, "subagent context rollback failed (#{e.class}: #{e.message})")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@counter -= 1 if generated_id && subagent_id == "subagent-#{@counter}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubik
|
|
4
|
+
module Orchestrator
|
|
5
|
+
module Subagent
|
|
6
|
+
# Owns the parked worker gate used to make subagent startup reversible.
|
|
7
|
+
module StartGate
|
|
8
|
+
def start_async(defer_start: false, &on_complete)
|
|
9
|
+
run_gate = Queue.new
|
|
10
|
+
@monitor.synchronize do
|
|
11
|
+
return false if @thread&.alive?
|
|
12
|
+
|
|
13
|
+
@started_at = Time.now
|
|
14
|
+
@run_started_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
@run_started_step = @budget.current_step
|
|
16
|
+
@run_seq += 1
|
|
17
|
+
@start_aborted = false
|
|
18
|
+
@start_gate = run_gate
|
|
19
|
+
transition!(:running)
|
|
20
|
+
@unit&.sync_state!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ready = Queue.new
|
|
24
|
+
thread = Thread.new do
|
|
25
|
+
Thread.handle_interrupt(Object => :never) do
|
|
26
|
+
ready << true # cleanup is armed before cancellation is enabled
|
|
27
|
+
Thread.handle_interrupt(Object => :immediate) do
|
|
28
|
+
run_execution_cycle if run_gate.pop == :start
|
|
29
|
+
end
|
|
30
|
+
ensure
|
|
31
|
+
aborted = @monitor.synchronize { @start_aborted }
|
|
32
|
+
@ended_at ||= Time.now
|
|
33
|
+
if aborted
|
|
34
|
+
cleanup_context
|
|
35
|
+
else
|
|
36
|
+
profile_name = @profile_snapshot&.name || @type_name
|
|
37
|
+
profile_version = @profile_snapshot ? @profile_snapshot.profile_version.to_s : "unknown"
|
|
38
|
+
@journal&.record_run_finished(run: @run_seq, state: @state.to_s, result: @result,
|
|
39
|
+
result_ref: (result_ref if settled?), outcome: @state.to_s,
|
|
40
|
+
error_summary: @last_error,
|
|
41
|
+
duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) -
|
|
42
|
+
@run_started_monotonic) * 1000).round,
|
|
43
|
+
step_count: [@budget.current_step - @run_started_step, 0].max,
|
|
44
|
+
profile_name: profile_name, profile_version: profile_version)
|
|
45
|
+
save_transcript! if @transcript_path.nil?
|
|
46
|
+
@cancelled_by_operator ? cleanup : cleanup_context
|
|
47
|
+
on_complete&.call(self)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
@monitor.synchronize { @thread = thread }
|
|
52
|
+
ready.pop
|
|
53
|
+
release_start! unless defer_start
|
|
54
|
+
true
|
|
55
|
+
rescue StandardError
|
|
56
|
+
abort_start!
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The spawn transaction releases the worker only after every
|
|
61
|
+
# fallible registration step has completed.
|
|
62
|
+
def release_start!
|
|
63
|
+
@monitor.synchronize do
|
|
64
|
+
return false unless @start_gate && !@start_aborted
|
|
65
|
+
|
|
66
|
+
profile_name = @profile_snapshot&.name || @type_name
|
|
67
|
+
profile_version = @profile_snapshot ? @profile_snapshot.profile_version.to_s : "unknown"
|
|
68
|
+
@journal&.record_run_started(run: @run_seq, role: @role, type: @type_name, prompt: @prompt,
|
|
69
|
+
profile_name: profile_name, profile_version: profile_version)
|
|
70
|
+
@start_gate << :start
|
|
71
|
+
@start_gate = nil
|
|
72
|
+
end
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def abort_start!
|
|
77
|
+
thread = @monitor.synchronize do
|
|
78
|
+
@start_aborted = true
|
|
79
|
+
gate = @start_gate
|
|
80
|
+
@start_gate = nil
|
|
81
|
+
gate << :abort if gate
|
|
82
|
+
@thread
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if thread && thread != Thread.current
|
|
86
|
+
thread.join(0.5)
|
|
87
|
+
if thread.alive?
|
|
88
|
+
thread.kill
|
|
89
|
+
thread.join
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
cleanup_context
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -4,6 +4,8 @@ require "monitor"
|
|
|
4
4
|
require_relative "../../platform"
|
|
5
5
|
require_relative "../task_cancellation"
|
|
6
6
|
require_relative "type_registry"
|
|
7
|
+
require_relative "profile_snapshot"
|
|
8
|
+
require_relative "spawn_transaction"
|
|
7
9
|
require_relative "instance"
|
|
8
10
|
require_relative "result_archive"
|
|
9
11
|
require_relative "execution_reads"
|
|
@@ -44,6 +46,7 @@ module Rubik
|
|
|
44
46
|
include DependencySupervisor
|
|
45
47
|
include MessagingSupervisor
|
|
46
48
|
include ActivationSupervisor
|
|
49
|
+
include SpawnSupervisor
|
|
47
50
|
include ExecutionReads
|
|
48
51
|
include TaskOwnershipSupervisor
|
|
49
52
|
include WaitSupervisor
|
|
@@ -60,7 +63,7 @@ module Rubik
|
|
|
60
63
|
@event_bus = event_bus
|
|
61
64
|
@init = init
|
|
62
65
|
|
|
63
|
-
@type_registry = TypeRegistry.new
|
|
66
|
+
@type_registry = TypeRegistry.new(default_context: @root_context)
|
|
64
67
|
@instances = {}
|
|
65
68
|
@deferred = {}
|
|
66
69
|
@monitor = Monitor.new
|
|
@@ -78,72 +81,6 @@ module Rubik
|
|
|
78
81
|
@root_context.register_disposable { shutdown_all } if @root_context.respond_to?(:register_disposable)
|
|
79
82
|
end
|
|
80
83
|
|
|
81
|
-
def spawn(role:, prompt:, type_name: "research", system_prompt: nil,
|
|
82
|
-
parent_id: ConversationId::ROOT_ID, workspace: "inherit", model: "inherit",
|
|
83
|
-
max_steps: nil, provider: nil, depends_on: nil, reserved_id: nil, owner: nil)
|
|
84
|
-
role_str = role.to_s.strip
|
|
85
|
-
prompt_str = prompt.to_s.strip
|
|
86
|
-
raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
|
|
87
|
-
raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
|
|
88
|
-
|
|
89
|
-
# Issue #125: the owner's task was cancelled — refuse before any
|
|
90
|
-
# reservation, slot or context work happens.
|
|
91
|
-
refute_cancelled_owner!(owner)
|
|
92
|
-
|
|
93
|
-
# Bağımlı spec doğrudan spawn edilmez: kuyruğa girer (FS/SS
|
|
94
|
-
# kısıtı), bırakma süpürmesi gerçek koreografiyi oynatır.
|
|
95
|
-
if depends_on
|
|
96
|
-
return spawn_batch([{ role: role_str, prompt: prompt_str, type_name: type_name,
|
|
97
|
-
system_prompt: system_prompt, parent_id: parent_id, workspace: workspace,
|
|
98
|
-
model: model, max_steps: max_steps, provider: provider, depends_on: depends_on }],
|
|
99
|
-
owner: owner).first[1]
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
created = @monitor.synchronize do
|
|
103
|
-
validate_concurrency!
|
|
104
|
-
validate_depth!(parent_id)
|
|
105
|
-
|
|
106
|
-
type_def = @type_registry.get(type_name) || @type_registry.get("research")
|
|
107
|
-
effective_sys_prompt = system_prompt || type_def.system_prompt
|
|
108
|
-
# Transcript sağlık paketi (2026-10-10): etkili prompt yalnızca
|
|
109
|
-
# builtin archetype default'undan türemişse kurulabilirdir →
|
|
110
|
-
# kalıcı dosyada meta stub yeterli. Çağıranın kendi system_prompt'i
|
|
111
|
-
# veya custom tip prompt'u tek kayıttır → bire bir kopya kalır.
|
|
112
|
-
prompt_reconstructible = system_prompt.to_s.strip.empty? && !type_def.custom_prompt
|
|
113
|
-
|
|
114
|
-
child_ctx = extend_root_context!(:spawn)
|
|
115
|
-
effective_buffer = branch_or_inherit_buffer(workspace)
|
|
116
|
-
scoped_registry = build_scoped_registry(type_def, child_ctx, effective_buffer)
|
|
117
|
-
prov, effective_model = resolve_provider_and_model(model)
|
|
118
|
-
prov = provider if provider
|
|
119
|
-
|
|
120
|
-
# Kuyruktan bırakılan spec kendi REZERVE id'siyle doğar — modelin
|
|
121
|
-
# depends_on/wait referansı kopmaz. Taze spawn yalnız sayaç ilerletir.
|
|
122
|
-
subagent_id = reserved_id || "subagent-#{@counter += 1}"
|
|
123
|
-
|
|
124
|
-
inst = Instance.new(
|
|
125
|
-
id: subagent_id, parent_id: parent_id, role: role_str, type_name: type_def.name,
|
|
126
|
-
prompt: prompt_str, system_prompt: effective_sys_prompt, workspace: workspace,
|
|
127
|
-
model: effective_model, context: child_ctx, tool_registry: scoped_registry,
|
|
128
|
-
provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
|
|
129
|
-
provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
|
|
130
|
-
context_limit: (@config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768),
|
|
131
|
-
event_bus: @event_bus, transcript_storage: absorb_storage, owner_task: owner,
|
|
132
|
-
prompt_reconstructible: prompt_reconstructible
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
@instances[subagent_id] = inst
|
|
136
|
-
activate_instance!(inst, child_ctx: child_ctx, owner_token: owner)
|
|
137
|
-
inst
|
|
138
|
-
end
|
|
139
|
-
# SS bırakma kancası: yeni başlayan instance, onu bekleyen kuyruk
|
|
140
|
-
# spesifikasyonlarını serbest bırakır (kilit DIŞINDA — emit kuralı).
|
|
141
|
-
release_ready!
|
|
142
|
-
created
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Tombstone + kuyruk dahil arama: reap/rezervasyon sonrası da
|
|
146
|
-
# status/transcript/list sorgulanır.
|
|
147
84
|
def get(id) = @monitor.synchronize { @instances[id.to_s] || (@finished ||= {})[id.to_s] || queued_spec(id.to_s) }
|
|
148
85
|
|
|
149
86
|
def extend_budget(id, steps = Budget::DEFAULT_GRANT_STEPS)
|