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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +130 -0
  4. data/README.md +30 -17
  5. data/lib/rubik/ast/extraction_result.rb +37 -0
  6. data/lib/rubik/ast/extractor.rb +35 -9
  7. data/lib/rubik/ast/graph.rb +27 -1
  8. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  9. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  10. data/lib/rubik/buffer/apply_contract.rb +78 -0
  11. data/lib/rubik/buffer/manager.rb +61 -5
  12. data/lib/rubik/coeffects.rb +14 -15
  13. data/lib/rubik/concurrency/event_loop.rb +56 -44
  14. data/lib/rubik/concurrency/scheduler.rb +7 -24
  15. data/lib/rubik/config/defaults.rb +9 -6
  16. data/lib/rubik/config.rb +8 -0
  17. data/lib/rubik/context/git_lister.rb +6 -1
  18. data/lib/rubik/context/scanner.rb +15 -3
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  21. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  23. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  24. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  25. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  26. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  27. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  28. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  29. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  30. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  31. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  32. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  33. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  34. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  35. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  36. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  37. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  38. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  39. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  40. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  41. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  42. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  43. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  44. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  45. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  46. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  47. data/lib/rubik/platform/process.rb +74 -2
  48. data/lib/rubik/plugin/manager.rb +148 -71
  49. data/lib/rubik/plugin/package_inventory.rb +84 -0
  50. data/lib/rubik/plugin/requires_dag.rb +40 -0
  51. data/lib/rubik/plugin/storage.rb +100 -14
  52. data/lib/rubik/prompt/doctrine.rb +5 -3
  53. data/lib/rubik/repl/commands/buffer.rb +28 -18
  54. data/lib/rubik/repl/commands/subagents.rb +2 -0
  55. data/lib/rubik/repl/input_execution.rb +42 -0
  56. data/lib/rubik/repl/input_loop.rb +57 -7
  57. data/lib/rubik/repl/prompt_grace.rb +103 -0
  58. data/lib/rubik/runtime/repair_probe.rb +98 -0
  59. data/lib/rubik/security/blocked_guidance.rb +19 -0
  60. data/lib/rubik/security/guard.rb +115 -6
  61. data/lib/rubik/security/redactor.rb +7 -7
  62. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  63. data/lib/rubik/tools/builtins/exec.rb +76 -28
  64. data/lib/rubik/tools/builtins/files.rb +6 -1
  65. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  66. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  67. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  68. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  69. data/lib/rubik/tools/truncator.rb +19 -2
  70. data/lib/rubik/tools/uri_resolver.rb +84 -20
  71. data/lib/rubik/uris.rb +11 -0
  72. data/lib/rubik/version.rb +1 -1
  73. metadata +20 -1
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transcript_formatter"
4
+ require_relative "narration"
5
+
6
+ module Rubik
7
+ module Orchestrator
8
+ module Subagent
9
+ # The per-run entry/exit choreography, extracted from Instance to keep
10
+ # it under its module-hygiene cap (issue #122 added the run-sequence
11
+ # reference plumbing there): history seeding, the nil-provider terminal
12
+ # stub, the single failure drain, effective-prompt assembly and the
13
+ # durable transcript save belong to ONE vertical slice — a fresh run and
14
+ # a redeploy both enter through run_execution_cycle.
15
+ module RunCycle
16
+ # The human-readable rendering of the current history (public read
17
+ # seam; lives beside the durable-save choreography it mirrors).
18
+ def format_transcript
19
+ TranscriptFormatter.format(self, @history, @monitor)
20
+ end
21
+
22
+ private
23
+
24
+ def run_execution_cycle
25
+ # Fresh runs seed; a redeployed run continues its preserved history.
26
+ @history.push({ role: "system", content: build_effective_system_prompt }, { role: "user", content: @prompt }) if @history.empty?
27
+
28
+ unless @provider
29
+ @budget.consume
30
+ @result = Narration.completed_stub_line(@id, @role, @prompt)
31
+ transition!(:completed)
32
+ @unit&.sync_state!
33
+ save_transcript!
34
+ return
35
+ end
36
+
37
+ execute_steps_with_provider
38
+ @unit&.sync_state!
39
+ save_transcript!
40
+ rescue Lifecycle::StateError => e
41
+ fail_run("LIFECYCLE INVARIANT VIOLATED: #{e.class}: #{e.message}")
42
+ rescue StandardError => e
43
+ fail_run("#{e.class}: #{e.message}")
44
+ end
45
+
46
+ # The run's single failure drain: record why, land on :failed, sync
47
+ # the unit, persist — the loud prefix keeps an FSM violation
48
+ # distinguishable from a provider failure.
49
+ def fail_run(reason)
50
+ @monitor.synchronize do
51
+ @last_error = reason
52
+ transition!(:failed)
53
+ @unit&.sync_state!
54
+ end
55
+ save_transcript!
56
+ end
57
+
58
+ # Private by design: the transcript URI is minted once at construction
59
+ # and every save mirrors the in-memory history (redacted at the file
60
+ # boundary) — callers own the retention decision, not the path.
61
+ def save_transcript!
62
+ saved = TranscriptFormatter.save!(@transcript_uri, @history, @monitor,
63
+ prompt_reconstructible: @prompt_reconstructible)
64
+ @transcript_path = saved
65
+ # A failed save must not be silent: the advertised path would 404 silently.
66
+ @last_error ||= "Transcript save failed for #{@transcript_uri}: see stderr" unless saved
67
+ end
68
+
69
+ def build_effective_system_prompt
70
+ base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
71
+ "Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
72
+ "Allocated Step Budget: #{@budget.max_steps} steps.\n\n" \
73
+ "A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). Pace yourself: " \
74
+ "when remaining steps (Y - X) run low, conclude the task or prepare a progress summary for the maestro " \
75
+ "supervisor — it may extend your budget or steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
76
+ "ask_question tool — it blocks until the maestro answers or times out; ask only when the answer truly changes your work.\n\n" \
77
+ "Perform your assigned task with high precision and provide a structured synthesis report."
78
+ @system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../coeffects"
4
+
3
5
  module Rubik
4
6
  module Orchestrator
5
7
  module Subagent
@@ -51,14 +53,16 @@ module Rubik
51
53
  end
52
54
  end
53
55
 
54
- # Subagent düzlemine geçen araçlar (ask_question + send_message):
55
- # her ikisinin de coeffect çözümü yalnız child ctx'te sağlanır
56
+ # Subagent düzlemine geçen araçlar (ask_question + send_message +
57
+ # orchestration plane): coeffect çözümü yalnız child ctx'te sağlanır
56
58
  # (supervisor#spawn/redeploy provide). The rebind MUST precede the
57
59
  # buffer guard conceptually: research-type subagents inherit
58
60
  # workspace buffers, but ask_question still has to swap to the child
59
61
  # ctx — otherwise it would keep the ROOT tool (live shell) and pop a
60
62
  # HUMAN dialog from the worker thread; send_message swaps so the
61
- # subagent-plane message carries its REAL sender id to the maestro.
63
+ # subagent-plane message carries its REAL sender id to the maestro;
64
+ # invoke/manage swap so a nested spawn resolves the INSTANCE's task
65
+ # token (issue #125) instead of the root turn's.
62
66
  # Class-matched (not name-matched): a plugin/dynamic tool registered
63
67
  # under the same name keeps its own implementation in every plane.
64
68
  def rebind_to_context(tool, ctx)
@@ -69,10 +73,18 @@ module Rubik
69
73
  Rubik::Tools::AskQuestionTool.new(shell: nil, runtime_context: ctx)
70
74
  when Rubik::Tools::SendMessageTool
71
75
  Rubik::Tools::SendMessageTool.new(runtime_context: ctx)
76
+ when Rubik::Tools::InvokeSubagentTool
77
+ Rubik::Tools::InvokeSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
78
+ when Rubik::Tools::ManageSubagentsTool
79
+ Rubik::Tools::ManageSubagentsTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
72
80
  else
73
81
  tool
74
82
  end
75
83
  end
84
+
85
+ def supervisor_for(ctx)
86
+ Rubik::Coeffects.get(ctx, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
87
+ end
76
88
  end
77
89
  end
78
90
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "../../prompt"
5
+ require_relative "../../platform"
6
+ require_relative "../../security/redactor"
5
7
  require_relative "../../tools/truncator"
8
+ require_relative "../../uris"
6
9
  require_relative "../provider_recovery"
7
10
  require_relative "../auto_apply"
8
11
  require_relative "../tool_call_id"
@@ -13,6 +16,9 @@ require_relative "../../coeffects"
13
16
  require_relative "notices"
14
17
  require_relative "resilience"
15
18
  require_relative "conversation_id"
19
+ require_relative "context_compaction"
20
+ require_relative "operation_tracker"
21
+ require_relative "tool_dispatch"
16
22
 
17
23
  module Rubik
18
24
  module Orchestrator
@@ -24,6 +30,9 @@ module Rubik
24
30
  # spine (agent-turn-executor plan); this module keeps the invocation path, budget
25
31
  # mechanics, and the subagent dispatch.
26
32
  module StepRunner
33
+ include ContextCompaction
34
+ include ToolDispatch
35
+
27
36
  # The shared response-processing spine, built lazily on first use so
28
37
  # objects that skip initialize (test doubles) stay safe.
29
38
  def turn_executor
@@ -78,21 +87,6 @@ module Rubik
78
87
  transition!(:failed)
79
88
  end
80
89
 
81
- def evaluate_subagent_context_compaction!
82
- return unless @context_limit&.positive?
83
-
84
- context_cfg = ProviderRecovery.compaction_config(@config)
85
- needs = Rubik::Prompt::ContextCompactor.needs_compaction?(
86
- @history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
87
- )
88
- return unless needs
89
-
90
- compacted = Rubik::Prompt::ContextCompactor.compact_history(
91
- @history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
92
- )
93
- @history.replace(compacted)
94
- end
95
-
96
90
  def invoke_provider_with_recovery(opts)
97
91
  ProviderRecovery.with_recovery(config: @config, compact: method(:emergency_compact_and_notify!)) do
98
92
  Resilience.retry_transient(config: @config, gate: @provider_gate) do
@@ -105,22 +99,6 @@ module Rubik
105
99
  @config.respond_to?(:fetch) ? @config.fetch("subagents.auto_pause_on_network_error", true) != false : true
106
100
  end
107
101
 
108
- # Compaction actuator for the subagent path (mirrors Loop's public
109
- # seam of the same name): the compaction body lives in
110
- # TurnExecutor#emergency_compact! (one home for both channels); this
111
- # delegates and the channel announces on the bus. Runs on the worker
112
- # thread between monitor sections — never call it while holding
113
- # @monitor (the emit stays lock-free).
114
- def emergency_compact_and_notify!(reason)
115
- turn_executor.emergency_compact!(reason)
116
- end
117
-
118
- # The object that owns the fact emits it — outside the monitor, so
119
- # subscribers never run under the instance lock.
120
- def emit_compacted_event(reason, layer)
121
- Notices.emit(@event_bus, Notices::COMPACTED_EVENT, { id: @id, role: @role, state: @state, reason: reason.to_s, layer: layer })
122
- end
123
-
124
102
  def invoke_provider(opts)
125
103
  effective_opts = opts.dup
126
104
  effective_opts[:timeout] ||= ProviderRecovery::SUBAGENT_REQUEST_TIMEOUT
@@ -128,10 +106,18 @@ module Rubik
128
106
  effective_opts[:context_limit] ||= @context_limit if @context_limit
129
107
  effective_opts[:max_tokens] ||= @max_output_tokens if @max_output_tokens&.positive?
130
108
 
131
- return @provider.complete(@history, effective_opts) if @provider.respond_to?(:complete)
132
- return @provider.chat(@history, **effective_opts) if @provider.respond_to?(:chat)
109
+ # Operation telemetry (issue #126): the model wait is observable
110
+ # while it lasts (status shows kind/name/elapsed) and its duration
111
+ # lands in the run's model bucket even when the call raises.
112
+ @operations.start(OperationTracker::MODEL, name: @model || "default")
113
+ begin
114
+ return @provider.complete(@history, effective_opts) if @provider.respond_to?(:complete)
115
+ return @provider.chat(@history, **effective_opts) if @provider.respond_to?(:chat)
133
116
 
134
- raise "Provider #{@provider.class} does not support complete or chat"
117
+ raise "Provider #{@provider.class} does not support complete or chat"
118
+ ensure
119
+ @operations.finish(OperationTracker::MODEL)
120
+ end
135
121
  end
136
122
 
137
123
  def drain_inbox_into_history
@@ -155,10 +141,8 @@ module Rubik
155
141
  end
156
142
 
157
143
  # Single-call subagent dispatch (the TurnExecutor's per-call seam):
158
- # pre-execution auto-apply, timed execution, and the canonical
159
- # ToolHistory row — which now carries status + duration_ms like every
160
- # other channel (the row-superset delta of the agent-turn-executor
161
- # plan; no wire-side formatter reads them, observability only).
144
+ # pre-execution auto-apply, timed execution, the canonical ToolHistory
145
+ # row (status + duration_ms; observability only), and the #124 journal.
162
146
  def execute_subagent_tool_call(tool_call)
163
147
  name = tool_call["tool"]
164
148
  args = tool_call["args"] || {}
@@ -166,15 +150,39 @@ module Rubik
166
150
  canonical_name = tool ? tool.name : name.to_s.downcase
167
151
 
168
152
  auto_apply_buffer!(:pre_execution, tool: canonical_name)
169
-
170
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
171
- output, status = dispatch_subagent_tool(tool, name, args)
172
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
153
+ # Journal the call BEFORE dispatch (issue #124): a crash inside the
154
+ # tool still leaves the attempted call correlated by its id. The
155
+ # operation snapshot (issue #126) shows the tool + redacted detail
156
+ # while it runs; the duration lands in the tool bucket either way.
157
+ @operations.start(OperationTracker::TOOL, name: canonical_name, detail: tool_detail(args))
158
+ @journal&.record_tool_call(run: @run_seq, call_id: tool_call["id"], tool: canonical_name, args: args)
159
+
160
+ result = nil
161
+ begin
162
+ result = dispatch_subagent_tool(tool, name, args)
163
+ ensure
164
+ elapsed = @operations.finish(OperationTracker::TOOL)
165
+ end
166
+ duration_ms = result.duration_ms.positive? ? result.duration_ms : ((elapsed || 0.0) * 1000).round(1)
167
+ @journal&.record_tool_result(run: @run_seq, call_id: tool_call["id"], tool: canonical_name,
168
+ status: result.status, duration_ms: duration_ms, output: result.output,
169
+ exit_code: result.exit_code, truncated: result.truncated,
170
+ artifact_ref: Rubik::Uris.subagent_transcript(@storage_id))
173
171
  Rubik::Orchestrator::ToolHistory.write(@history, tool_call_id: tool_call["id"], tool_name: name,
174
- status: status, duration_ms: duration_ms, content: output.to_s)
172
+ status: result.status, duration_ms: duration_ms,
173
+ content: result.output, exit_code: result.exit_code,
174
+ metadata: result.metadata)
175
175
  nil
176
176
  end
177
177
 
178
+ # Redacted, clipped argument preview for the model-visible operation
179
+ # snapshot (issue #126): credentials are masked before they can reach
180
+ # status/wait output, and long payloads are bounded.
181
+ def tool_detail(args)
182
+ json = Rubik::Platform.safe_json_generate(Rubik::Security::Redactor.mask_value(args))
183
+ json.length > 120 ? "#{json[0, 120]}... [truncated]" : json
184
+ end
185
+
178
186
  # The subagent's own spelling of the shared auto-apply policy (AutoApply
179
187
  # owns the decision): decide, apply+sync through the coeffect shell,
180
188
  # and REPORT failures instead of swallowing them — a failed pre-test
@@ -208,30 +216,15 @@ module Rubik
208
216
  Rubik::Coeffects.get(@context, Rubik::Coeffects::SHELL)
209
217
  end
210
218
 
211
- # Returns [truncated_output, status] so the dispatch site can build
212
- # the canonical tool row. Unknown tools and execution errors carry
213
- # :error; plain results are :ok (Result-backed tools carry their own).
214
- def dispatch_subagent_tool(tool, name, args)
215
- return ["Tool '#{name}' is not available in subagent toolset.", :error] unless tool
216
-
217
- raw = tool.execute(args)
218
- out_str = raw.respond_to?(:output) ? raw.output.to_s : raw.to_s
219
- max_chars = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_chars", 12_000) : 12_000) || 12_000
220
- max_lines = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_lines", 300) : 300) || 300
221
- trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars.to_i, max_lines: max_lines.to_i)
222
- status = raw.respond_to?(:status) ? raw.status : :ok
223
- [trunc[:text], status]
224
- rescue StandardError => e
225
- ["Tool execution error: #{e.class}: #{e.message}", :error]
226
- end
227
-
228
219
  private
229
220
 
230
221
  # Plan 109 Faz A: per-step budget transparency replaces the 75%
231
222
  # warning latch — Budget owns the marker string under its lock; the
232
223
  # step loop owns only the history mutation (a conversation fact),
233
- # the same split the warning used.
224
+ # the same split the warning used. The step boundary is also an
225
+ # observable progress point for the operation telemetry (#126).
234
226
  def inject_step_marker
227
+ @operations.progress!
235
228
  @history << { role: "user", content: @budget.step_marker }
236
229
  end
237
230
  end
@@ -2,8 +2,13 @@
2
2
 
3
3
  require "monitor"
4
4
  require_relative "../../platform"
5
+ require_relative "../task_cancellation"
5
6
  require_relative "type_registry"
6
7
  require_relative "instance"
8
+ require_relative "result_archive"
9
+ require_relative "execution_reads"
10
+ require_relative "task_ownership"
11
+ require_relative "waiting"
7
12
  require_relative "reuse"
8
13
  require_relative "notices"
9
14
  require_relative "concurrency_gate"
@@ -19,6 +24,10 @@ module Rubik
19
24
  module Subagent
20
25
  class DepthExceededError < StandardError; end
21
26
  class ConcurrencyExceededError < StandardError; end
27
+ # New owned work after task cancellation (issue #125): the owner's token
28
+ # is already cancelled — spawning into it would violate "no pending owned
29
+ # descendant starts after cancellation".
30
+ class TaskCancelledError < StandardError; end
22
31
 
23
32
  # Root Supervisor Agent governing the entire subagent process hierarchy:
24
33
  # this class models Linux init's PID-1 supervisory ROLE (parent of the
@@ -26,7 +35,8 @@ module Rubik
26
35
  # `agent-0`). Also owns concurrent pool limits and LIFO shutdown (Plan 94).
27
36
  class Supervisor
28
37
  attr_accessor :tool_registry, :router, :init
29
- attr_reader :root_context, :type_registry, :instances, :max_depth, :max_concurrency, :default_max_steps, :provider_gate
38
+ attr_reader :root_context, :type_registry, :instances, :max_depth, :max_concurrency, :default_max_steps,
39
+ :provider_gate, :result_archive
30
40
 
31
41
  include RedeploySupervisor
32
42
  include ReaperSupervisor
@@ -34,6 +44,9 @@ module Rubik
34
44
  include DependencySupervisor
35
45
  include MessagingSupervisor
36
46
  include ActivationSupervisor
47
+ include ExecutionReads
48
+ include TaskOwnershipSupervisor
49
+ include WaitSupervisor
37
50
 
38
51
  def initialize(root_context:, buffer_manager: nil, tool_registry: nil,
39
52
  provider: nil, router: nil, config: nil, root_dir: Dir.pwd, event_bus: nil, init: nil)
@@ -57,6 +70,9 @@ module Rubik
57
70
  @default_max_steps = (config&.fetch("subagents.default_max_steps", Budget::DEFAULT_MAX_STEPS) || Budget::DEFAULT_MAX_STEPS).to_i
58
71
  @provider_concurrency = (config&.fetch("subagents.provider_concurrency", 2) || 2).to_i
59
72
  @provider_gate = ConcurrencyGate.new(@provider_concurrency)
73
+ # Retained completed-run results (issue #122): session-scoped,
74
+ # redacted, paginated — see ResultArchive.
75
+ @result_archive = ResultArchive.new(config: @config)
60
76
 
61
77
  # Register inverse in root context (maestro hook)
62
78
  @root_context.register_disposable { shutdown_all } if @root_context.respond_to?(:register_disposable)
@@ -64,18 +80,23 @@ module Rubik
64
80
 
65
81
  def spawn(role:, prompt:, type_name: "research", system_prompt: nil,
66
82
  parent_id: ConversationId::ROOT_ID, workspace: "inherit", model: "inherit",
67
- max_steps: nil, provider: nil, depends_on: nil, reserved_id: nil)
83
+ max_steps: nil, provider: nil, depends_on: nil, reserved_id: nil, owner: nil)
68
84
  role_str = role.to_s.strip
69
85
  prompt_str = prompt.to_s.strip
70
86
  raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
71
87
  raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
72
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
+
73
93
  # Bağımlı spec doğrudan spawn edilmez: kuyruğa girer (FS/SS
74
94
  # kısıtı), bırakma süpürmesi gerçek koreografiyi oynatır.
75
95
  if depends_on
76
96
  return spawn_batch([{ role: role_str, prompt: prompt_str, type_name: type_name,
77
97
  system_prompt: system_prompt, parent_id: parent_id, workspace: workspace,
78
- model: model, max_steps: max_steps, provider: provider, depends_on: depends_on }]).first[1]
98
+ model: model, max_steps: max_steps, provider: provider, depends_on: depends_on }],
99
+ owner: owner).first[1]
79
100
  end
80
101
 
81
102
  created = @monitor.synchronize do
@@ -107,12 +128,12 @@ module Rubik
107
128
  provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
108
129
  provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
109
130
  context_limit: (@config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768),
110
- event_bus: @event_bus, transcript_storage: absorb_storage,
131
+ event_bus: @event_bus, transcript_storage: absorb_storage, owner_task: owner,
111
132
  prompt_reconstructible: prompt_reconstructible
112
133
  )
113
134
 
114
135
  @instances[subagent_id] = inst
115
- activate_instance!(inst, child_ctx: child_ctx)
136
+ activate_instance!(inst, child_ctx: child_ctx, owner_token: owner)
116
137
  inst
117
138
  end
118
139
  # SS bırakma kancası: yeni başlayan instance, onu bekleyen kuyruk
@@ -121,30 +142,6 @@ module Rubik
121
142
  created
122
143
  end
123
144
 
124
- def wait_for(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
125
- target_ids = Array(ids).compact.map(&:to_s)
126
- deadline = Time.now + [timeout.to_i, 1].max
127
-
128
- loop do
129
- active_targets = @monitor.synchronize do
130
- candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
131
- candidates.select do |inst|
132
- inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
133
- end
134
- end
135
-
136
- # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
137
- break if (active_targets.empty? && !deferred_pending?(target_ids)) || Time.now >= deadline
138
-
139
- sleep 0.1
140
- end
141
-
142
- @monitor.synchronize do
143
- candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
144
- candidates.map(&:to_h)
145
- end
146
- end
147
-
148
145
  # Tombstone + kuyruk dahil arama: reap/rezervasyon sonrası da
149
146
  # status/transcript/list sorgulanır.
150
147
  def get(id) = @monitor.synchronize { @instances[id.to_s] || (@finished ||= {})[id.to_s] || queued_spec(id.to_s) }
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../task_cancellation"
4
+ require_relative "notices"
5
+
6
+ module Rubik
7
+ module Orchestrator
8
+ module Subagent
9
+ # Task ownership and cancellation choreography (issue #125). The
10
+ # supervisor already knows every execution; this seam answers "which
11
+ # executions belong to this token" and stops exactly those. Work that was
12
+ # explicitly detached carries its own never-cancelled token and work
13
+ # spawned under a different turn carries a different token, so both are
14
+ # untouched by construction.
15
+ #
16
+ # The outcome reports the three states the UI needs: cancellation
17
+ # requested (the token), complete (nothing remains), and incomplete
18
+ # cleanup (threads that did not settle inside the grace period — they are
19
+ # force-killed AND named, so a stuck execution is never silently lost).
20
+ module TaskOwnershipSupervisor
21
+ CancellationOutcome = Struct.new(:cancelled, :stopped, :remaining, :queued_cancelled, keyword_init: true) do
22
+ def any? = !(stopped.empty? && remaining.empty? && queued_cancelled.empty?)
23
+ def complete? = remaining.empty?
24
+ end
25
+
26
+ # Request termination on each owned worker, then wait for cleanup up to
27
+ # the configured grace period and report the remainder. Queued specs of the
28
+ # token are cancelled before they ever start.
29
+ def cancel_owned(token, timeout: nil)
30
+ return CancellationOutcome.new(cancelled: false, stopped: [], remaining: [], queued_cancelled: []) unless token
31
+
32
+ token.cancel!("foreground task cancelled (^C)")
33
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [timeout.to_f, 0.0].max
34
+ queued = cancel_queued_specs(token)
35
+ requested = {}
36
+ loop do
37
+ targets = @monitor.synchronize do
38
+ @instances.values.select { |inst| owned_by?(inst, token) && !cancellation_settled?(inst) }
39
+ end
40
+ targets.each do |inst|
41
+ next if requested[inst.id]
42
+
43
+ accepted = inst.respond_to?(:cancel_async) ? inst.cancel_async : inst.kill
44
+ next unless accepted
45
+
46
+ requested[inst.id] = inst
47
+ end
48
+ break if targets.empty? || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
49
+
50
+ sleep 0.01
51
+ end
52
+
53
+ remaining = @monitor.synchronize do
54
+ (@instances.values + (@finished || {}).values + requested.values).uniq.select do |inst|
55
+ owned_by?(inst, token) && !cancellation_settled?(inst)
56
+ end
57
+ end
58
+ stopped = requested.values.reject { |inst| remaining.include?(inst) }.map(&:id)
59
+ CancellationOutcome.new(cancelled: true,
60
+ stopped: stopped.uniq,
61
+ remaining: remaining.map { |inst| { id: inst.id, state: inst.state.to_s } },
62
+ queued_cancelled: queued)
63
+ end
64
+
65
+ # A stopped execution is either disposed (the kill path completed) or
66
+ # settled naturally. Anything else is remaining work and must be named.
67
+ def cancellation_settled?(instance)
68
+ return instance.cancellation_settled? if instance.respond_to?(:cancellation_settled?)
69
+ return true if instance.respond_to?(:disposed?) && instance.disposed?
70
+
71
+ instance.terminal_and_settled?
72
+ end
73
+
74
+ def owned_by?(instance, token)
75
+ instance.respond_to?(:owner_task) && instance.owner_task.equal?(token)
76
+ end
77
+
78
+ # Claim fence: a pending spec whose owner was cancelled never starts.
79
+ # Called under the supervisor monitor by the dependency claim loops.
80
+ def owned_claim_blocked?(spec)
81
+ spec.respond_to?(:owner) && spec.owner&.cancelled?
82
+ end
83
+
84
+ # Guard for new owned work: refuse before anything is reserved.
85
+ def refute_cancelled_owner!(token)
86
+ return unless token&.cancelled?
87
+
88
+ raise TaskCancelledError,
89
+ "task was cancelled (^C) — new owned work is refused; pass detach: true to run it independently of this task"
90
+ end
91
+
92
+ private
93
+
94
+ # Cancel queued specs owned by the token and report their ids (the
95
+ # dependency wave that follows is announced by the same helper the
96
+ # dead-predecessor path uses).
97
+ def cancel_queued_specs(token)
98
+ queued = @monitor.synchronize do
99
+ specs = @deferred.values.select { |spec| spec.respond_to?(:owner) && spec.owner.equal?(token) }
100
+ specs.each { |spec| cancel_spec!(spec, "owner task cancelled (^C)") }
101
+ specs
102
+ end
103
+ announce_cancellations(queued)
104
+ queued.map(&:id)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "notices"
4
+
5
+ module Rubik
6
+ module Orchestrator
7
+ module Subagent
8
+ # Instance teardown choreography, extracted from instance.rb (module
9
+ # hygiene; issue #125 added the cancellation notice and the
10
+ # operator-cancelled flag here): kill request (one terminal outcome per
11
+ # execution, thread kill), idempotent cleanup, and the monitor-guarded
12
+ # context disposal. Disposal itself stays LIFO + error-isolated inside
13
+ # Runtime::Context; cleanup_context only guarantees it runs exactly once.
14
+ module Teardown
15
+ # True when an OPERATOR kill (not a natural settle) stopped this
16
+ # execution. The completion handler reads it to suppress its own
17
+ # settle notice, so one execution never produces two terminal
18
+ # outcomes (issue #125).
19
+ def cancelled_by_operator? = @monitor.synchronize { @cancelled_by_operator }
20
+
21
+ # Foreground cancellation lets the existing worker drain its context.
22
+ # Repeated requests must not interrupt an inverse already unwinding.
23
+ def cancel_async
24
+ return true if @cancelled_by_operator
25
+
26
+ kill(cleanup: false, wait_for_lock: false)
27
+ end
28
+
29
+ # A cleanup inverse may hold the instance monitor. Deadline checks
30
+ # must report it as pending rather than blocking on that same monitor.
31
+ def cancellation_settled?
32
+ return false unless @monitor.try_enter
33
+
34
+ begin
35
+ disposed? || terminal_and_settled?
36
+ ensure
37
+ @monitor.exit
38
+ end
39
+ end
40
+
41
+ def kill(cleanup: true, wait_for_lock: true)
42
+ announce = false
43
+ if wait_for_lock
44
+ @monitor.enter
45
+ else
46
+ return false unless @monitor.try_enter
47
+ end
48
+ begin
49
+ return true if disposed?
50
+
51
+ announce = !settled? && !@cancelled_by_operator
52
+ @cancelled_by_operator ||= announce
53
+ transition!(:canceling)
54
+ @ended_at = Time.now
55
+ @budget_cond.broadcast
56
+ @error_cond.broadcast
57
+ ensure
58
+ @monitor.exit
59
+ end
60
+
61
+ # The cancellation announcement belongs to the kill path (the
62
+ # operator act), not to the completion choreography: it is emitted
63
+ # exactly once, and only when the run had not already settled.
64
+ if announce
65
+ Notices.emit(@event_bus, Notices::COMPLETED_EVENT,
66
+ { id: @id, role: @role, state: :cancelled, max_steps: @budget.max_steps })
67
+ end
68
+ @journal&.record_cancelled(run: @run_seq, reason: "kill requested by maestro")
69
+
70
+ begin; @thread&.kill if @thread&.alive?; rescue StandardError; nil; end
71
+
72
+ self.cleanup if cleanup || !@thread&.alive?
73
+ true
74
+ end
75
+
76
+ def cleanup
77
+ @monitor.synchronize do
78
+ return if disposed?
79
+
80
+ @budget_cond.broadcast
81
+ @error_cond.broadcast
82
+ cleanup_context
83
+ transition!(:disposed)
84
+ end
85
+ end
86
+
87
+ # Idempotent under monitor: check-and-set of @context_cleaned_up
88
+ # (ensure block vs kill cleanup race).
89
+ def cleanup_context
90
+ @monitor.synchronize do
91
+ return if @context_cleaned_up
92
+
93
+ @context_cleaned_up = true
94
+ begin
95
+ @context&.dispose unless @context&.disposed?
96
+ rescue StandardError => e
97
+ @last_error ||= "#{e.class}: #{e.message}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end