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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +42 -0
  4. data/lib/rubik/coeffects.rb +14 -15
  5. data/lib/rubik/config/defaults.rb +9 -6
  6. data/lib/rubik/config.rb +8 -0
  7. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  8. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  9. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  10. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  11. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  12. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  13. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  14. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  15. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  16. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  17. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  18. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  19. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  21. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  22. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  23. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  25. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  26. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  27. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  28. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  29. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  30. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  31. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  32. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  33. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  34. data/lib/rubik/prompt/doctrine.rb +5 -3
  35. data/lib/rubik/repl/commands/subagents.rb +2 -0
  36. data/lib/rubik/repl/input_execution.rb +42 -0
  37. data/lib/rubik/repl/input_loop.rb +57 -7
  38. data/lib/rubik/repl/prompt_grace.rb +103 -0
  39. data/lib/rubik/security/redactor.rb +7 -7
  40. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  41. data/lib/rubik/tools/builtins/exec.rb +76 -28
  42. data/lib/rubik/tools/builtins/files.rb +6 -1
  43. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  44. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  45. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  46. data/lib/rubik/tools/truncator.rb +19 -2
  47. data/lib/rubik/tools/uri_resolver.rb +84 -20
  48. data/lib/rubik/uris.rb +11 -0
  49. data/lib/rubik/version.rb +1 -1
  50. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bced6a0406961cf92cb6e9bcba59607c5517e4a14a205fca253d07cb461b6391
4
- data.tar.gz: 2b1812eecdf480852cbaaae6d3ef507de561b10e0f628e87e7c831969db8dbd2
3
+ metadata.gz: f281eb015bdfadb67a18d2a61972c2559f448a0458915393443a39c042b8a1d9
4
+ data.tar.gz: dedd94f98ab0785e0688e256b8be35244d764c88ccbe87c9367836715c3c788d
5
5
  SHA512:
6
- metadata.gz: e084f7f8f448a21537c39f0b96b2ef218ad02c7f1699aaf6da07318c1049a224d893595a90109dadc6838cfa2db92fd70302f9374065dd1cb869d12ee7441425
7
- data.tar.gz: 7b19fb4c8ff589ffc386f65fe83d01117132c600e7fd316fc990d3047cb7eeb8f6d7c4a6f5498857d1a185d4be43ac41f4e47d6b8530c31c5fb24dec908e2c12
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,500+ unit, integration, and contract tests. |
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
@@ -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 mixing in Questioner):
54
- # supervisor#spawn provides each instance into its own child context so
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: supervisor#spawn (and redeploy)
59
- # provide each subagent's conversation id into its own child context so
60
- # a subagent-plane message routes to the maestro under its REAL sender
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
- # provided by Vakanuvis::Service at shell boot (Phase 4) and resolved
66
- # by the vakanuvis tool (recall/record/forget/digest). Unsatisfied when
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 the
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
- # Bitmiş subagent bu grace (sn) sonrasında reap edilir (0 = kapalı);
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
- # Maestro kapısı: maestro write_file/replace_file_content/run_command/
156
- # run_tests araçlarını doğrudan ÇALIŞTIRAMAZ — subagent'a devir zorunlu
157
- # (false = doktrin yalnız prompt düzeyinde, kapı kapalı):
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
- # Result is deliberately absent: notices announce, the model retrieves
23
- # via manage_subagents wait — the wait protocol stays authoritative.
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, { id: inst.id, role: inst.role, state: inst.state, max_steps: inst.max_steps, storage_id: inst.storage_id })
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
- private
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
- def spawn_batch(specs)
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