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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +4 -0
  3. data/CHANGELOG.md +115 -0
  4. data/README.md +137 -268
  5. data/bin/rubik +9 -0
  6. data/lib/rubik/cli/workspace_trust.rb +78 -0
  7. data/lib/rubik/kernel/init.rb +15 -0
  8. data/lib/rubik/lsp/client.rb +3 -2
  9. data/lib/rubik/orchestrator/auto_apply.rb +1 -1
  10. data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
  11. data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
  12. data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
  13. data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
  14. data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
  15. data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
  16. data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
  17. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
  18. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
  19. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
  20. data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
  21. data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
  22. data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
  23. data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
  24. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
  25. data/lib/rubik/platform/process.rb +78 -1
  26. data/lib/rubik/prompt/context_builder.rb +54 -20
  27. data/lib/rubik/prompt/doctrine.rb +158 -22
  28. data/lib/rubik/prompt/token_estimator.rb +5 -3
  29. data/lib/rubik/prompt/tool_teachings.rb +19 -12
  30. data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
  31. data/lib/rubik/sandbox/driver/docker.rb +14 -60
  32. data/lib/rubik/sandbox/driver/podman.rb +13 -56
  33. data/lib/rubik/sandbox/driver/process.rb +12 -64
  34. data/lib/rubik/security/guard.rb +1 -1
  35. data/lib/rubik/storage/store.rb +52 -11
  36. data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
  37. data/lib/rubik/tools/builtins/storage.rb +8 -5
  38. data/lib/rubik/tools/builtins/subagent.rb +20 -53
  39. data/lib/rubik/tools/builtins/synthesis.rb +6 -1
  40. data/lib/rubik/tools/builtins.rb +1 -0
  41. data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
  42. data/lib/rubik/tools/fuzzy_matcher.rb +39 -19
  43. data/lib/rubik/tools/truncator.rb +54 -21
  44. data/lib/rubik/version.rb +1 -1
  45. 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
- scoped_registry = build_scoped_registry(@type_registry.get(inst.type_name), child_ctx, inst.buffer_manager)
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)