rubik 0.1.5 → 0.1.6
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 +42 -0
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- 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/prompt/doctrine.rb +5 -3
- 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/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/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 +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f281eb015bdfadb67a18d2a61972c2559f448a0458915393443a39c042b8a1d9
|
|
4
|
+
data.tar.gz: dedd94f98ab0785e0688e256b8be35244d764c88ccbe87c9367836715c3c788d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bcaf73fa4a12d8084af7b7e8ae5f1886e14c0f55f7e8817112addc073d17533fce0bc0959971fc18654d217efb24cadd0cdb4fa128c9c6980f49c0ff7c3255d3
|
|
7
|
+
data.tar.gz: 7d5dfa25a399e9e2710d9cbd16ec7c5cb0cbc7b373c62dfd9b3eae76bc4505df08f1bb51f9d893715ca4045da61b9b25c0b8b9c835c4960549788a98fb256f0b
|
data/ATTRIBUTION.md
CHANGED
|
@@ -121,7 +121,7 @@ SOFTWARE.
|
|
|
121
121
|
| **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
|
|
122
122
|
| **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
|
|
123
123
|
| **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
|
|
124
|
-
| **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,
|
|
124
|
+
| **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,600+ unit, integration, and contract tests. |
|
|
125
125
|
| **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
|
|
126
126
|
| **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
|
|
127
127
|
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,48 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
Nothing yet.
|
|
12
12
|
|
|
13
|
+
## [0.1.6] - 2026-10-15
|
|
14
|
+
|
|
15
|
+
The execution-contract release: subagent runs become first-class,
|
|
16
|
+
inspectable objects — retained results, one canonical transcript, an
|
|
17
|
+
append-only journal, cancellable task trees — and the REPL learns to
|
|
18
|
+
hand a just-entered prompt back for editing instead of firing it.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Execution contracts and task supervision (#136):** completed
|
|
23
|
+
subagent results are retained with paginated retrieval instead of
|
|
24
|
+
truncated previews; live and persisted transcript reads collapse
|
|
25
|
+
into one canonical reader with snapshot tokens; an append-only
|
|
26
|
+
execution journal records each step (redacted before clipping);
|
|
27
|
+
foreground cancellation propagates through owned descendants while
|
|
28
|
+
detached tokens stay uncancellable; the runtime reports current
|
|
29
|
+
operations with actionable wait states; and command exit codes plus
|
|
30
|
+
metadata survive both dispatch paths. Closes #122–#127 and #129.
|
|
31
|
+
- **Esc returns pending prompts to editing (#141):** a configurable
|
|
32
|
+
admission window (`ui.prompt_grace_window_ms`, default 1000, 0 to
|
|
33
|
+
disable) lets Esc hand the complete prompt back to Reline before
|
|
34
|
+
any task, model call or turn recording starts; Ctrl+C cancels the
|
|
35
|
+
pending submission, reactive wake-ups cannot inject Enter into the
|
|
36
|
+
restored editor, and bang/exit commands plus programmatic input
|
|
37
|
+
keep direct execution.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Redactor skips irrelevant static passes, keeps replacement order
|
|
42
|
+
(#142):** a precompiled union regex skips the eight static
|
|
43
|
+
credential passes when the input holds only assignments or URL
|
|
44
|
+
credentials, and the original replacement order is preserved —
|
|
45
|
+
masking a preceding token in one combined pass can create the word
|
|
46
|
+
boundary that exposes an adjacent bearer credential (regression
|
|
47
|
+
tested over 10,000 differential cases).
|
|
48
|
+
- **Truncator fast path ~62% (#138):** `Truncator.truncate`
|
|
49
|
+
short-circuits untruncated texts with zero line allocations, and
|
|
50
|
+
custom truncator separators are preserved.
|
|
51
|
+
- **Fuzzy matcher prunes window evaluations (#140):**
|
|
52
|
+
`find_closest_subsequence` pre-computes target line membership and
|
|
53
|
+
a prefix-sum upper bound before running LCS calls.
|
|
54
|
+
|
|
13
55
|
## [0.1.5] - 2026-10-13
|
|
14
56
|
|
|
15
57
|
A dependency-floor release: the reactive wakeup contract moves into
|
data/lib/rubik/coeffects.rb
CHANGED
|
@@ -50,28 +50,27 @@ module Rubik
|
|
|
50
50
|
# Storage::Service at shell boot and resolved by the storage tool /
|
|
51
51
|
# read_file rubik://storage routes / supervisor transcript indexing.
|
|
52
52
|
STORAGE_STORE = "storage_store"
|
|
53
|
-
# The ask-maestro bridge (Subagent::Instance
|
|
54
|
-
#
|
|
55
|
-
# the subagent-plane ask_question resolves it at execute time; unsatisfied
|
|
56
|
-
# on the root context, where the tool keeps its human-dialog plane.
|
|
53
|
+
# The ask-maestro bridge (Subagent::Instance's Questioner): provided into
|
|
54
|
+
# each instance's child ctx so ask_question resolves it, never on the root.
|
|
57
55
|
SUBAGENT_QUESTION_ASKER = "subagent_question_asker"
|
|
58
|
-
# The send_message attribution bridge:
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
# id; unsatisfied on the root context, where Supervisor#send_message
|
|
62
|
-
# keeps its machine default sender ("agent-0").
|
|
56
|
+
# The send_message attribution bridge: provided into each subagent's child
|
|
57
|
+
# ctx so a message routes to the maestro under its REAL sender id
|
|
58
|
+
# (unsatisfied on the root: Supervisor#send_message keeps "agent-0").
|
|
63
59
|
SUBAGENT_SENDER_ID = "subagent_sender_id"
|
|
64
|
-
# The Vakanüvis chronicler harness (Storage::Vakanuvis::Harness),
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
# the chronicler is disabled — the tool hides from the model surface.
|
|
60
|
+
# The Vakanüvis chronicler harness (Storage::Vakanuvis::Harness), provided
|
|
61
|
+
# by Vakanuvis::Service at boot and resolved by the vakanuvis tool;
|
|
62
|
+
# unsatisfied when the chronicler is disabled (the tool hides).
|
|
68
63
|
VAKANUIS = "vakanuvis"
|
|
69
64
|
# The ROOT conversation-window pruner harness (REPL::Pruner::Harness),
|
|
70
65
|
# provided by Pruner::Service at shell boot (Phase 4; default ON via
|
|
71
66
|
# subagents.pruner.enabled — opt-out: false) and resolved by the
|
|
72
|
-
# prune_context tool and the !prune command. Unsatisfied when
|
|
73
|
-
# pruner is disabled.
|
|
67
|
+
# prune_context tool and the !prune command. Unsatisfied when
|
|
68
|
+
# the pruner is disabled.
|
|
74
69
|
PRUNER = "pruner"
|
|
70
|
+
# Foreground task cancellation token (Orchestrator::TaskCancellation, #125):
|
|
71
|
+
# provided per turn and inherited by spawned subagent contexts — spawn tools
|
|
72
|
+
# resolve the OWNING task; detached subtrees carry a never-cancelled token.
|
|
73
|
+
TASK_CANCELLATION = "task_cancellation"
|
|
75
74
|
|
|
76
75
|
# Read-side bridge: third-party providers may still register services
|
|
77
76
|
# under these legacy alias keys; canonical keys are always tried first.
|
|
@@ -118,6 +118,7 @@ module Rubik
|
|
|
118
118
|
"statusline_template" => "{model} · ctx {ctx} · {dirty} · sandbox {sandbox}",
|
|
119
119
|
"input_box" => false,
|
|
120
120
|
"paste_join_window_ms" => 120, # Plan 91: lines arriving within this gap join into one paste turn
|
|
121
|
+
"prompt_grace_window_ms" => 1000,
|
|
121
122
|
"quiet_notices" => false # Plan 105: buffer background notices to inbox until prompt/enter
|
|
122
123
|
},
|
|
123
124
|
"workspace" => {
|
|
@@ -148,13 +149,15 @@ module Rubik
|
|
|
148
149
|
"network_backoff_base" => 0.5,
|
|
149
150
|
"error_wait_timeout" => 300,
|
|
150
151
|
"question_timeout" => 300,
|
|
151
|
-
#
|
|
152
|
-
# tombstone_limit sorgulanabilir mezar taşı sayısını sınırlar:
|
|
152
|
+
# reap_finished_after: grace (sn, 0 = kapalı); tombstone_limit: sorgulanabilir mezar taşı sınırı.
|
|
153
153
|
"reap_finished_after" => 300,
|
|
154
154
|
"tombstone_limit" => 25,
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
155
|
+
"cancel_timeout" => 2, # foreground ^C: owned descendants' grace period (s) before force-kill
|
|
156
|
+
# Sonuç arşivi (issue #122): tamamlanan koşular oturum-içi saklanır; redeploy öncekini EZMEZ.
|
|
157
|
+
"result_archive" => { "ttl_seconds" => 3600, "max_runs_per_instance" => 8, "page_chars" => 4000 },
|
|
158
|
+
# Append-only execution journal (issue #124): per-execution JSONL, capped per file.
|
|
159
|
+
"journal" => { "max_bytes" => 2_097_152 },
|
|
160
|
+
# Maestro kapısı: maestro write_file/replace_file_content/run_command/run_tests'ı ÇALIŞTIRAMAZ:
|
|
158
161
|
"maestro_gate" => true,
|
|
159
162
|
"auto_pause_on_network_error" => true,
|
|
160
163
|
"reactive_wakeup" => true,
|
|
@@ -217,6 +220,7 @@ module Rubik
|
|
|
217
220
|
|
|
218
221
|
config.each do |key, val|
|
|
219
222
|
current_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
223
|
+
validate_prompt_grace_value!(current_key, val)
|
|
220
224
|
if schema.is_a?(Hash) && !schema.key?(key.to_s)
|
|
221
225
|
candidate_keys = schema.keys
|
|
222
226
|
candidates = if defined?(Rubik::Tools::FuzzyMatcher)
|
|
@@ -235,7 +239,6 @@ module Rubik
|
|
|
235
239
|
raise ArgumentError, suggestion_msg if strict
|
|
236
240
|
|
|
237
241
|
@warnings << suggestion_msg
|
|
238
|
-
|
|
239
242
|
elsif val.is_a?(Hash) && schema.is_a?(Hash) && schema[key.to_s].is_a?(Hash) && !schema[key.to_s].empty?
|
|
240
243
|
validate_schema(val, schema[key.to_s], current_key, strict: strict)
|
|
241
244
|
end
|
data/lib/rubik/config.rb
CHANGED
|
@@ -68,6 +68,8 @@ module Rubik
|
|
|
68
68
|
def set(key, value, persist: nil)
|
|
69
69
|
old_val = fetch(key)
|
|
70
70
|
typed_val = parse_scalar_value(value)
|
|
71
|
+
validate_prompt_grace_value!(key.to_s, typed_val)
|
|
72
|
+
validate_schema(typed_val, DEFAULT_CONFIG["ui"], "ui") if key.to_s == "ui" && typed_val.is_a?(Hash)
|
|
71
73
|
keys = key.to_s.split(".")
|
|
72
74
|
last_key = keys.pop
|
|
73
75
|
target = keys.reduce(@data) do |acc, k|
|
|
@@ -161,6 +163,12 @@ module Rubik
|
|
|
161
163
|
|
|
162
164
|
private
|
|
163
165
|
|
|
166
|
+
def validate_prompt_grace_value!(key, value)
|
|
167
|
+
return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
|
|
170
|
+
end
|
|
171
|
+
|
|
164
172
|
# save! returns itself for no-write scopes; persist outcomes are hashes.
|
|
165
173
|
def persist_result(res)
|
|
166
174
|
res.is_a?(Hash) ? res : { ok: true }
|
|
@@ -151,7 +151,11 @@ module Rubik
|
|
|
151
151
|
error: err,
|
|
152
152
|
output: trunc_res[:text],
|
|
153
153
|
duration_ms: dur,
|
|
154
|
-
truncated: trunc_res[:truncated]
|
|
154
|
+
truncated: trunc_res[:truncated],
|
|
155
|
+
# Structured fields ride along when the tool provides them
|
|
156
|
+
# (issue #127): the same Result contract for every carrier.
|
|
157
|
+
exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
|
|
158
|
+
metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
|
|
155
159
|
)
|
|
156
160
|
else
|
|
157
161
|
out_str = raw_output.to_s
|
|
@@ -239,6 +243,8 @@ module Rubik
|
|
|
239
243
|
step: @deps.step_count,
|
|
240
244
|
max_steps: @deps.max_steps,
|
|
241
245
|
call_id: call_id,
|
|
246
|
+
exit_code: tool_result.exit_code,
|
|
247
|
+
metadata: tool_result.metadata,
|
|
242
248
|
line_count: tool_result.output.to_s.lines.size,
|
|
243
249
|
byte_size: tool_result.output.to_s.bytesize,
|
|
244
250
|
tokens: @deps.total_tokens
|
|
@@ -246,7 +252,8 @@ module Rubik
|
|
|
246
252
|
|
|
247
253
|
Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
|
|
248
254
|
status: tool_result.status, duration_ms: tool_result.duration_ms,
|
|
249
|
-
content: tool_result.to_xml
|
|
255
|
+
content: tool_result.to_xml, exit_code: tool_result.exit_code,
|
|
256
|
+
metadata: tool_result.metadata)
|
|
250
257
|
@deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
|
|
251
258
|
@deps.reset_invalid_tools!
|
|
252
259
|
tool_result
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../task_cancellation"
|
|
4
|
+
|
|
3
5
|
module Rubik
|
|
4
6
|
module Orchestrator
|
|
5
7
|
module Subagent
|
|
@@ -23,13 +25,18 @@ module Rubik
|
|
|
23
25
|
# happens at execute time, but a start_async completion must never
|
|
24
26
|
# race an unprovided asker), unit registration second (pre-start, so
|
|
25
27
|
# the kernel board sees a live unit), start_async last.
|
|
26
|
-
def activate_instance!(inst, child_ctx:)
|
|
28
|
+
def activate_instance!(inst, child_ctx:, owner_token: nil)
|
|
27
29
|
# ask_question'ın subagent düzlemi: instance kendi child ctx'ine
|
|
28
30
|
# asker olarak publish edilir.
|
|
29
31
|
child_ctx&.provide(Rubik::Coeffects::SUBAGENT_QUESTION_ASKER, inst)
|
|
30
32
|
# send_message atfı: subagent→maestro mesajları gerçek gönderen
|
|
31
33
|
# id'siyle taşır (coeffect yoksa root default'u yanlış atfı yapar).
|
|
32
34
|
child_ctx&.provide(Rubik::Coeffects::SUBAGENT_SENDER_ID, inst.id)
|
|
35
|
+
# Task ownership (issue #125): the spawned execution (and, through
|
|
36
|
+
# inheritance, its own nested spawns) carries the owner's token; a
|
|
37
|
+
# detached spawn gets a never-cancelled token so its whole subtree
|
|
38
|
+
# survives a foreground ^C.
|
|
39
|
+
child_ctx&.provide(Rubik::Coeffects::TASK_CANCELLATION, owner_token || TaskCancellation.detached)
|
|
33
40
|
eff_init = @init || (@root_context.respond_to?(:get) && Rubik::Coeffects.get(@root_context, Rubik::Coeffects::INIT))
|
|
34
41
|
eff_init&.register(Kernel::SubagentUnit.new(inst, context: child_ctx, birth_state: unit_birth_state(inst)))
|
|
35
42
|
inst.start_async { |completed_inst| handle_subagent_completion(completed_inst) }
|
|
@@ -16,18 +16,45 @@ module Rubik
|
|
|
16
16
|
def handle_subagent_completion(inst)
|
|
17
17
|
merge_branch_buffer(inst) if inst.workspace == "branch" && inst.completed?
|
|
18
18
|
auto_apply_subagent_buffer(inst) if inst.completed?
|
|
19
|
+
retain_result(inst)
|
|
19
20
|
Rubik::Storage::TranscriptIndexer.index(storage_store, inst)
|
|
20
21
|
reap_orphans
|
|
21
22
|
release_ready!
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
emit_completion_notice(inst)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# One terminal outcome per execution (issue #125): an operator kill
|
|
29
|
+
# already announced the cancellation from the kill path — the settle
|
|
30
|
+
# notice would be a duplicate. Natural settles announce here, with the
|
|
31
|
+
# canonical result reference so a truncated preview keeps a working
|
|
32
|
+
# continuation (result_ref, issue #122).
|
|
33
|
+
def emit_completion_notice(inst)
|
|
34
|
+
return if inst.respond_to?(:cancelled_by_operator?) && inst.cancelled_by_operator?
|
|
35
|
+
|
|
24
36
|
# max_steps rides along so the budget-exhausted guidance can count.
|
|
25
37
|
# storage_id: Vakanüvis kronikçisinin annal/transcript ref anahtarı
|
|
26
38
|
# (plan 2026-10-04) — mevcut aboneler bu anahtarı yok sayar.
|
|
27
|
-
Notices.emit(@event_bus, Notices::COMPLETED_EVENT,
|
|
39
|
+
Notices.emit(@event_bus, Notices::COMPLETED_EVENT,
|
|
40
|
+
{ id: inst.id, role: inst.role, state: inst.state, max_steps: inst.max_steps,
|
|
41
|
+
storage_id: inst.storage_id, result_ref: (inst.result_ref if inst.settled?),
|
|
42
|
+
transcript_ref: inst.transcript_ref })
|
|
28
43
|
end
|
|
29
44
|
|
|
30
|
-
|
|
45
|
+
# The result archive's single write site (issue #122): a settled run
|
|
46
|
+
# retains its redacted result under its execution-specific reference
|
|
47
|
+
# (storage id + run sequence) so retrieval never needs a redeploy.
|
|
48
|
+
# A retention failure degrades to a warning — the durable transcript
|
|
49
|
+
# remains the fallback, and completion choreography must not stall.
|
|
50
|
+
def retain_result(inst)
|
|
51
|
+
return unless inst.settled? && @result_archive
|
|
52
|
+
|
|
53
|
+
@result_archive.retain(storage_id: inst.storage_id, id: inst.id, run: inst.run_seq,
|
|
54
|
+
state: inst.state, body: inst.result)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Rubik::Log.warn(:orchestrator, "result archive retain failed for #{inst.id}: #{e.class}: #{e.message}")
|
|
57
|
+
end
|
|
31
58
|
|
|
32
59
|
# Completion-time spelling of the shared auto-apply policy: the
|
|
33
60
|
# subagents flag OR the buffer.auto_apply mode decides (previously
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "notices"
|
|
4
|
+
require_relative "../provider_recovery"
|
|
5
|
+
|
|
6
|
+
module Rubik
|
|
7
|
+
module Orchestrator
|
|
8
|
+
module Subagent
|
|
9
|
+
# The subagent's context-recovery plane, extracted from StepRunner
|
|
10
|
+
# (module hygiene): routine compaction evaluation, the emergency
|
|
11
|
+
# compaction actuator, and the bus announcement. One vertical slice —
|
|
12
|
+
# history pruning and its observable event.
|
|
13
|
+
module ContextCompaction
|
|
14
|
+
def evaluate_subagent_context_compaction!
|
|
15
|
+
return unless @context_limit&.positive?
|
|
16
|
+
|
|
17
|
+
context_cfg = ProviderRecovery.compaction_config(@config)
|
|
18
|
+
needs = Rubik::Prompt::ContextCompactor.needs_compaction?(
|
|
19
|
+
@history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
|
|
20
|
+
)
|
|
21
|
+
return unless needs
|
|
22
|
+
|
|
23
|
+
compacted = Rubik::Prompt::ContextCompactor.compact_history(
|
|
24
|
+
@history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
|
|
25
|
+
)
|
|
26
|
+
before = @history.size
|
|
27
|
+
@history.replace(compacted)
|
|
28
|
+
# Journaled BEFORE the next step can prune further (issue #124): the
|
|
29
|
+
# pre-compaction event sequence stays reconstructible.
|
|
30
|
+
@journal&.record_compaction(run: @run_seq, layer: "context_compactor", before: before, after: @history.size)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Compaction actuator for the subagent path (mirrors Loop's seam): the
|
|
34
|
+
# body lives in TurnExecutor#emergency_compact! (one home for both
|
|
35
|
+
# channels); this delegates and the channel announces on the bus. Never
|
|
36
|
+
# call it while holding @monitor (the emit stays lock-free).
|
|
37
|
+
def emergency_compact_and_notify!(reason)
|
|
38
|
+
turn_executor.emergency_compact!(reason)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The object that owns the fact emits it — outside the monitor, so
|
|
42
|
+
# subscribers never run under the instance lock.
|
|
43
|
+
def emit_compacted_event(reason, layer)
|
|
44
|
+
@journal&.record_compaction(run: @run_seq, layer: layer, reason: reason)
|
|
45
|
+
Notices.emit(@event_bus, Notices::COMPACTED_EVENT, { id: @id, role: @role, state: @state, reason: reason.to_s, layer: layer })
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "budget"
|
|
4
|
+
require_relative "notices"
|
|
5
|
+
require_relative "conversation_id"
|
|
6
|
+
|
|
7
|
+
module Rubik
|
|
8
|
+
module Orchestrator
|
|
9
|
+
module Subagent
|
|
10
|
+
# A dependency-queued spawn specification (PM-style dispatch constraints,
|
|
11
|
+
# user decision 2026-10-01): a linked task does not fire concurrently —
|
|
12
|
+
# FS (finish-to-start, default) launches when the predecessor completes,
|
|
13
|
+
# SS (start-to-start) when it first turns running. A queued spec is NOT
|
|
14
|
+
# an Instance: no thread, no slot, no cost — the real spawn choreography
|
|
15
|
+
# runs only when it is released. FF/SF are deliberately unsupported v1
|
|
16
|
+
# (normalize_dep rejects them).
|
|
17
|
+
#
|
|
18
|
+
# `owner` (issue #125): the cancellation token of the task that requested
|
|
19
|
+
# this spec — nil for explicitly detached work. A cancelled owner makes
|
|
20
|
+
# the claim loops drop the spec before it ever starts.
|
|
21
|
+
class DeferredSpec
|
|
22
|
+
attr_reader :id, :parent_id, :role, :type_name, :prompt, :system_prompt,
|
|
23
|
+
:workspace, :model, :max_steps, :deps, :provider, :owner
|
|
24
|
+
attr_accessor :state, :cancel_reason
|
|
25
|
+
|
|
26
|
+
def initialize(id:, role:, prompt:, type_name:, deps:, parent_id: ConversationId::ROOT_ID, system_prompt: nil,
|
|
27
|
+
workspace: "inherit", model: "inherit", max_steps: nil, provider: nil, owner: nil)
|
|
28
|
+
@id = id
|
|
29
|
+
@parent_id = parent_id
|
|
30
|
+
@role = role
|
|
31
|
+
@prompt = prompt
|
|
32
|
+
@type_name = type_name
|
|
33
|
+
@deps = deps
|
|
34
|
+
@system_prompt = system_prompt
|
|
35
|
+
@workspace = workspace
|
|
36
|
+
@model = model
|
|
37
|
+
@max_steps = max_steps
|
|
38
|
+
@provider = provider
|
|
39
|
+
@owner = owner
|
|
40
|
+
@state = :queued
|
|
41
|
+
@cancel_reason = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def queued? = @state == :queued
|
|
45
|
+
def spawning? = @state == :spawning
|
|
46
|
+
def cancelled? = @state == :cancelled
|
|
47
|
+
|
|
48
|
+
# Duck-type yüzeyi: render'lar (list/status/wait) Instance ile
|
|
49
|
+
# DeferredSpec'i AYIRT ETMEDEN kullanır — dallanma yok.
|
|
50
|
+
def running? = false
|
|
51
|
+
def active? = false
|
|
52
|
+
def uptime = 0.0
|
|
53
|
+
def started_at = nil
|
|
54
|
+
def current_step = 0
|
|
55
|
+
def result = nil
|
|
56
|
+
def transcript_uri = nil
|
|
57
|
+
def last_error = @cancel_reason
|
|
58
|
+
def terminal_and_settled? = false
|
|
59
|
+
def redeployable? = false
|
|
60
|
+
# send_message'in deliverable? kapısı spec'e de değebilir (get,
|
|
61
|
+
# kuyruk fallback'i döndürür): bir spec henüz doğmadı — teslim
|
|
62
|
+
# edilemez.
|
|
63
|
+
def deliverable? = false
|
|
64
|
+
|
|
65
|
+
def depends_on = @deps.map { |d| "#{d[:id]}:#{d[:type]}" }
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
{ id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
|
|
69
|
+
depends_on: depends_on, current_step: 0, max_steps: @max_steps, uptime_seconds: 0.0,
|
|
70
|
+
started_at: nil, ended_at: nil, transcript: nil, last_error: @cancel_reason,
|
|
71
|
+
result_summary: nil, result: nil, detached: @owner.nil? }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -3,69 +3,11 @@
|
|
|
3
3
|
require_relative "budget"
|
|
4
4
|
require_relative "notices"
|
|
5
5
|
require_relative "conversation_id"
|
|
6
|
+
require_relative "deferred_spec"
|
|
6
7
|
|
|
7
8
|
module Rubik
|
|
8
9
|
module Orchestrator
|
|
9
10
|
module Subagent
|
|
10
|
-
# PM tarzı dispatch kısıtları (kullanıcı kararı 2026-10-01): bağlı
|
|
11
|
-
# işler aynı anda ateşlenmez — FS (finish-to-start, varsayılan) öncül
|
|
12
|
-
# tamamlanınca, SS (start-to-start) öncül İLK kez running'a geçince
|
|
13
|
-
# bırakılır. Bekleyen spec bir Instance DEĞİL: thread yok, slot yok,
|
|
14
|
-
# maliyet yok — bırakılınca gerçek spawn koreografisi oynar.
|
|
15
|
-
# FF/SF v1'de bilinçli olarak desteklenmez (normalize_dep reddeder).
|
|
16
|
-
class DeferredSpec
|
|
17
|
-
attr_reader :id, :parent_id, :role, :type_name, :prompt, :system_prompt,
|
|
18
|
-
:workspace, :model, :max_steps, :deps, :provider
|
|
19
|
-
attr_accessor :state, :cancel_reason
|
|
20
|
-
|
|
21
|
-
def initialize(id:, role:, prompt:, type_name:, deps:, parent_id: ConversationId::ROOT_ID, system_prompt: nil,
|
|
22
|
-
workspace: "inherit", model: "inherit", max_steps: nil, provider: nil)
|
|
23
|
-
@id = id
|
|
24
|
-
@parent_id = parent_id
|
|
25
|
-
@role = role
|
|
26
|
-
@prompt = prompt
|
|
27
|
-
@type_name = type_name
|
|
28
|
-
@deps = deps
|
|
29
|
-
@system_prompt = system_prompt
|
|
30
|
-
@workspace = workspace
|
|
31
|
-
@model = model
|
|
32
|
-
@max_steps = max_steps
|
|
33
|
-
@provider = provider
|
|
34
|
-
@state = :queued
|
|
35
|
-
@cancel_reason = nil
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def queued? = @state == :queued
|
|
39
|
-
def spawning? = @state == :spawning
|
|
40
|
-
def cancelled? = @state == :cancelled
|
|
41
|
-
|
|
42
|
-
# Duck-type yüzeyi: render'lar (list/status/wait) Instance ile
|
|
43
|
-
# DeferredSpec'i AYIRT ETMEDEN kullanır — dallanma yok.
|
|
44
|
-
def running? = false
|
|
45
|
-
def active? = false
|
|
46
|
-
def uptime = 0.0
|
|
47
|
-
def started_at = nil
|
|
48
|
-
def current_step = 0
|
|
49
|
-
def result = nil
|
|
50
|
-
def transcript_uri = nil
|
|
51
|
-
def last_error = @cancel_reason
|
|
52
|
-
def terminal_and_settled? = false
|
|
53
|
-
def redeployable? = false
|
|
54
|
-
# send_message'in deliverable? kapısı spec'e de değebilir (get,
|
|
55
|
-
# kuyruk fallback'i döndürür): bir spec henüz doğmadı — teslim
|
|
56
|
-
# edilemez.
|
|
57
|
-
def deliverable? = false
|
|
58
|
-
|
|
59
|
-
def depends_on = @deps.map { |d| "#{d[:id]}:#{d[:type]}" }
|
|
60
|
-
|
|
61
|
-
def to_h
|
|
62
|
-
{ id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
|
|
63
|
-
depends_on: depends_on, current_step: 0, max_steps: @max_steps, uptime_seconds: 0.0,
|
|
64
|
-
started_at: nil, ended_at: nil, transcript: nil, last_error: @cancel_reason,
|
|
65
|
-
result_summary: nil, result: nil }
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
11
|
# Saf graf fonksiyonları — kilit yok, IO yok, izole test edilebilir.
|
|
70
12
|
module DependencyGraph
|
|
71
13
|
module_function
|
|
@@ -130,12 +72,16 @@ module Rubik
|
|
|
130
72
|
# doğrula (döngü/ölü öncül/bilinmeyen referans => ArgumentError,
|
|
131
73
|
# HİÇBİR ŞEY rezerve edilmeden), sonra rezerve et ve hazır olanları
|
|
132
74
|
# talep et. Dönen çiftler: [:launched, Instance] | [:queued, DeferredSpec].
|
|
133
|
-
|
|
75
|
+
# `owner` (issue #125): çağıranın görev iptal token'ı — spec'e miras
|
|
76
|
+
# kalır; iptal edilmiş sahiple yeni iş RESERVE EDİLMEZ.
|
|
77
|
+
def spawn_batch(specs, owner: nil)
|
|
134
78
|
reserved = []
|
|
135
79
|
claimed = []
|
|
80
|
+
cancelled = []
|
|
136
81
|
@monitor.synchronize do
|
|
137
82
|
base = @counter
|
|
138
|
-
normalized = specs.each_with_index.map { |s, i| normalize_batch_spec(s, base + i + 1) }
|
|
83
|
+
normalized = specs.each_with_index.map { |s, i| normalize_batch_spec(s, base + i + 1, owner) }
|
|
84
|
+
normalized.each { |spec| refute_cancelled_owner!(spec.owner) }
|
|
139
85
|
refute_dead_dependencies!(normalized)
|
|
140
86
|
refute_unknown_dependencies!(normalized)
|
|
141
87
|
DependencyGraph.assert_acyclic!(dependency_graph(normalized))
|
|
@@ -145,12 +91,20 @@ module Rubik
|
|
|
145
91
|
end
|
|
146
92
|
@counter += normalized.size
|
|
147
93
|
reserved.each do |spec|
|
|
94
|
+
# Claim fence: cancellation may land between the entry guard and
|
|
95
|
+
# this loop — a cancelled owner never launches.
|
|
96
|
+
if owned_claim_blocked?(spec)
|
|
97
|
+
cancel_spec!(spec, "owner task cancelled (^C)")
|
|
98
|
+
cancelled << spec
|
|
99
|
+
next
|
|
100
|
+
end
|
|
148
101
|
next unless dependencies_evaluate(spec) == :ready
|
|
149
102
|
|
|
150
103
|
spec.state = :spawning
|
|
151
104
|
claimed << spec
|
|
152
105
|
end
|
|
153
106
|
end
|
|
107
|
+
announce_cancellations(cancelled)
|
|
154
108
|
|
|
155
109
|
reserved.map do |spec|
|
|
156
110
|
claimed.include?(spec) ? launch_claimed!(spec) : resolve_outcome(spec)
|
|
@@ -171,6 +125,16 @@ module Rubik
|
|
|
171
125
|
@deferred.each_value do |spec|
|
|
172
126
|
next unless spec.queued?
|
|
173
127
|
|
|
128
|
+
# Ownership fence (issue #125): a spec whose task was cancelled
|
|
129
|
+
# is dropped here — it never starts, and its dependents cascade
|
|
130
|
+
# to death through the same fixed-point wave as a dead
|
|
131
|
+
# predecessor.
|
|
132
|
+
if owned_claim_blocked?(spec)
|
|
133
|
+
cancel_spec!(spec, "owner task cancelled (^C)")
|
|
134
|
+
wave << spec
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
|
|
174
138
|
case dependencies_evaluate(spec)
|
|
175
139
|
when :ready
|
|
176
140
|
spec.state = :spawning
|
|
@@ -254,7 +218,7 @@ module Rubik
|
|
|
254
218
|
inst = spawn(role: spec.role, prompt: spec.prompt, type_name: spec.type_name,
|
|
255
219
|
system_prompt: spec.system_prompt, parent_id: spec.parent_id,
|
|
256
220
|
workspace: spec.workspace, model: spec.model, max_steps: spec.max_steps,
|
|
257
|
-
provider: spec.provider, reserved_id: spec.id)
|
|
221
|
+
provider: spec.provider, reserved_id: spec.id, owner: spec.owner)
|
|
258
222
|
|
|
259
223
|
killed_midflight = false
|
|
260
224
|
@monitor.synchronize do
|
|
@@ -309,7 +273,7 @@ module Rubik
|
|
|
309
273
|
end
|
|
310
274
|
end
|
|
311
275
|
|
|
312
|
-
def normalize_batch_spec(raw, provisional_id)
|
|
276
|
+
def normalize_batch_spec(raw, provisional_id, owner = nil)
|
|
313
277
|
role = raw[:role].to_s.strip
|
|
314
278
|
prompt = raw[:prompt].to_s.strip
|
|
315
279
|
raise ArgumentError, "Subagent role cannot be empty" if role.empty?
|
|
@@ -320,7 +284,8 @@ module Rubik
|
|
|
320
284
|
type_name: (raw[:type_name] || "research").to_s.strip, deps: DependencyGraph.normalize_deps(raw[:depends_on]),
|
|
321
285
|
system_prompt: raw[:system_prompt], workspace: (raw[:workspace] || "inherit").to_s,
|
|
322
286
|
model: (raw[:model] || "inherit").to_s, max_steps: (raw[:max_steps] || @default_max_steps).to_i,
|
|
323
|
-
provider: raw[:provider], parent_id: (raw[:parent_id] || ConversationId::ROOT_ID).to_s
|
|
287
|
+
provider: raw[:provider], parent_id: (raw[:parent_id] || ConversationId::ROOT_ID).to_s,
|
|
288
|
+
owner: raw[:detach] ? nil : owner
|
|
324
289
|
)
|
|
325
290
|
end
|
|
326
291
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transcript_reader"
|
|
4
|
+
|
|
5
|
+
module Rubik
|
|
6
|
+
module Orchestrator
|
|
7
|
+
module Subagent
|
|
8
|
+
# Execution-keyed read seams (issues #122/#123): resolve an execution by
|
|
9
|
+
# its unique storage id and serve its canonical transcript pages. Kept
|
|
10
|
+
# out of supervisor.rb to stay under the module-hygiene cap; the
|
|
11
|
+
# supervisor includes it and owns the state these methods read.
|
|
12
|
+
module ExecutionReads
|
|
13
|
+
# Tombstones included: a reaped execution stays addressable within
|
|
14
|
+
# retention. Display ids restart every process — storage ids are the
|
|
15
|
+
# durable address, so lookup keys on storage_id, never on the id.
|
|
16
|
+
def find_by_storage_id(storage_id)
|
|
17
|
+
sid = storage_id.to_s
|
|
18
|
+
return nil if sid.empty?
|
|
19
|
+
|
|
20
|
+
@monitor.synchronize do
|
|
21
|
+
(@instances.values + (@finished || {}).values).find do |inst|
|
|
22
|
+
inst.respond_to?(:storage_id) && inst.storage_id.to_s == sid
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Was this execution ever known to the runtime? Distinguishes an
|
|
28
|
+
# expired (reaped + transcript gone) address from an unknown one; the
|
|
29
|
+
# result archive keeps the last bounded proof after tombstone eviction.
|
|
30
|
+
def execution_tracked?(storage_id)
|
|
31
|
+
sid = storage_id.to_s
|
|
32
|
+
return true if find_by_storage_id(sid)
|
|
33
|
+
|
|
34
|
+
@result_archive.respond_to?(:known?) && @result_archive.known?(sid)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The canonical transcript read seam (issue #123): the supervisor owns
|
|
38
|
+
# both planes (roster + root/storage resolution), so one call serves
|
|
39
|
+
# live memory or the persisted snapshot with snapshot-stable pages.
|
|
40
|
+
def read_transcript(storage_id, cursor: 0, limit: nil, snapshot: nil)
|
|
41
|
+
sid = storage_id.to_s
|
|
42
|
+
Rubik::Orchestrator::Subagent::TranscriptReader.read(
|
|
43
|
+
storage_id: sid, instance: find_by_storage_id(sid), root_dir: @root_dir, storage: absorb_storage,
|
|
44
|
+
tracked: execution_tracked?(sid), cursor: cursor, limit: limit, snapshot: snapshot
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|