rubik 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +130 -0
- data/README.md +30 -17
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/platform/process.rb +74 -2
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- metadata +20 -1
|
@@ -0,0 +1,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
|
-
#
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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,
|
|
159
|
-
#
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
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,
|
|
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 }]
|
|
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
|