rubik 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +130 -0
  4. data/README.md +30 -17
  5. data/lib/rubik/ast/extraction_result.rb +37 -0
  6. data/lib/rubik/ast/extractor.rb +35 -9
  7. data/lib/rubik/ast/graph.rb +27 -1
  8. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  9. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  10. data/lib/rubik/buffer/apply_contract.rb +78 -0
  11. data/lib/rubik/buffer/manager.rb +61 -5
  12. data/lib/rubik/coeffects.rb +14 -15
  13. data/lib/rubik/concurrency/event_loop.rb +56 -44
  14. data/lib/rubik/concurrency/scheduler.rb +7 -24
  15. data/lib/rubik/config/defaults.rb +9 -6
  16. data/lib/rubik/config.rb +8 -0
  17. data/lib/rubik/context/git_lister.rb +6 -1
  18. data/lib/rubik/context/scanner.rb +15 -3
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  21. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  23. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  24. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  25. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  26. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  27. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  28. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  29. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  30. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  31. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  32. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  33. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  34. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  35. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  36. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  37. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  38. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  39. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  40. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  41. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  42. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  43. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  44. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  45. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  46. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  47. data/lib/rubik/platform/process.rb +74 -2
  48. data/lib/rubik/plugin/manager.rb +148 -71
  49. data/lib/rubik/plugin/package_inventory.rb +84 -0
  50. data/lib/rubik/plugin/requires_dag.rb +40 -0
  51. data/lib/rubik/plugin/storage.rb +100 -14
  52. data/lib/rubik/prompt/doctrine.rb +5 -3
  53. data/lib/rubik/repl/commands/buffer.rb +28 -18
  54. data/lib/rubik/repl/commands/subagents.rb +2 -0
  55. data/lib/rubik/repl/input_execution.rb +42 -0
  56. data/lib/rubik/repl/input_loop.rb +57 -7
  57. data/lib/rubik/repl/prompt_grace.rb +103 -0
  58. data/lib/rubik/runtime/repair_probe.rb +98 -0
  59. data/lib/rubik/security/blocked_guidance.rb +19 -0
  60. data/lib/rubik/security/guard.rb +115 -6
  61. data/lib/rubik/security/redactor.rb +7 -7
  62. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  63. data/lib/rubik/tools/builtins/exec.rb +76 -28
  64. data/lib/rubik/tools/builtins/files.rb +6 -1
  65. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  66. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  67. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  68. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  69. data/lib/rubik/tools/truncator.rb +19 -2
  70. data/lib/rubik/tools/uri_resolver.rb +84 -20
  71. data/lib/rubik/uris.rb +11 -0
  72. data/lib/rubik/version.rb +1 -1
  73. metadata +20 -1
@@ -0,0 +1,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
@@ -5,6 +5,10 @@ require_relative "transcript_formatter"
5
5
  require_relative "storage_id"
6
6
  require_relative "lifecycle"
7
7
  require_relative "step_runner"
8
+ require_relative "run_cycle"
9
+ require_relative "journal"
10
+ require_relative "teardown"
11
+ require_relative "operation_tracker"
8
12
  require_relative "budget"
9
13
  require_relative "questioner"
10
14
  require_relative "reuse"
@@ -12,6 +16,7 @@ require_relative "notices"
12
16
  require_relative "narration"
13
17
  require_relative "resilience"
14
18
  require_relative "../../prompt"
19
+ require_relative "../../uris"
15
20
 
16
21
  module Rubik
17
22
  module Orchestrator
@@ -23,11 +28,13 @@ module Rubik
23
28
  # transition!, and every predicate.
24
29
  include Lifecycle
25
30
  include StepRunner
31
+ include RunCycle
32
+ include Teardown
26
33
  include Questioner
27
34
  include Redeploy
28
35
  include Reaper
29
36
 
30
- attr_accessor :parent_id, :unit
37
+ attr_accessor :parent_id, :unit, :owner_task
31
38
  # `state`: the sanctioned raw reader — display/serialization/kernel
32
39
  # bridge only; control flow asks the Lifecycle predicates. `result`
33
40
  # is TERMINAL-ONLY (real report / nil-provider stub); pauses narrate
@@ -36,7 +43,8 @@ module Rubik
36
43
  :workspace, :model, :context, :tool_registry, :buffer_manager,
37
44
  :state, :started_at, :ended_at, :result,
38
45
  :last_error, :storage_id, :transcript_uri, :history,
39
- :context_limit, :max_output_tokens, :config, :pause_facts, :prompt_reconstructible
46
+ :context_limit, :max_output_tokens, :config, :pause_facts, :prompt_reconstructible,
47
+ :run_seq, :journal, :operations
40
48
 
41
49
  # Budget delegation: step counters live in Subagent::Budget (reader contract preserved).
42
50
  def current_step = @budget.current_step
@@ -46,7 +54,7 @@ module Rubik
46
54
  system_prompt: nil, workspace: "inherit", model: "inherit",
47
55
  provider: nil, buffer_manager: nil, max_steps: Budget::DEFAULT_MAX_STEPS, root_dir: Dir.pwd,
48
56
  context_limit: nil, config: nil, event_bus: nil, provider_gate: nil, transcript_storage: nil,
49
- prompt_reconstructible: false)
57
+ prompt_reconstructible: false, owner_task: nil)
50
58
  @id = id.to_s
51
59
  @parent_id = parent_id.to_s
52
60
  @role = role.to_s.strip
@@ -71,13 +79,26 @@ module Rubik
71
79
  @state = :pending
72
80
  @history = []
73
81
  @inbox = Queue.new
82
+ @owner_task = owner_task # issue #125: nil = explicitly detached (survives ^C)
83
+ @cancelled_by_operator = false
84
+ # Operation telemetry (issue #126): current operation + model/tool
85
+ # buckets, monotonic-clock based.
86
+ @operations = OperationTracker.new
74
87
  @started_at = @ended_at = @result = @last_error = @transcript_path = @pause_facts = nil
88
+ # Each run (initial or redeploy) owns a sequence number: it keys the
89
+ # retained-result reference so an earlier run's result is never
90
+ # overwritten by its redeploy (issue #122).
91
+ @run_seq = 0
75
92
  # Grant telemetry (session review B4/B11): grants consumed this run —
76
93
  # the halt message says "no FURTHER expansion" only when one landed.
77
94
  @grants_received = 0
78
95
  # SSOT (Plan 110): minted once; absorb (transcript_storage) kökü değiştirir.
79
96
  @storage_id = StorageId.mint(@id)
80
97
  @transcript_uri = TranscriptFormatter.mint_path(@root_dir, @storage_id, storage: transcript_storage)
98
+ # Append-only execution journal (issue #124): lands beside the
99
+ # transcript (absorbed or classic root), survives compaction and
100
+ # redeploys, bounded by subagents.journal.max_bytes.
101
+ @journal = Journal.new(path: File.join(File.dirname(@transcript_uri), Journal::FILENAME), config: @config)
81
102
  @monitor = Monitor.new
82
103
  @budget_cond = @monitor.new_cond
83
104
  @error_cond = @monitor.new_cond
@@ -87,6 +108,33 @@ module Rubik
87
108
 
88
109
  def uptime = @started_at ? ((@ended_at || Time.now) - @started_at).to_f : 0.0
89
110
 
111
+ # Execution-specific canonical reference to this run's retained result
112
+ # (issue #122): stable across reaping; the result archive answers
113
+ # explicitly when the run was never retained, evicted, or expired.
114
+ def result_ref
115
+ Rubik::Uris.subagent_result(@storage_id, run: @run_seq)
116
+ end
117
+
118
+ # Canonical transcript read reference (issue #123): ONE address for
119
+ # live memory and the persisted snapshot — the reader reports which
120
+ # snapshot it served. Bare display ids are not addresses.
121
+ def transcript_ref
122
+ Rubik::Uris.subagent_transcript(@storage_id)
123
+ end
124
+
125
+ # Immutable sequence snapshot under the instance lock: the read side of
126
+ # the transcript contract pages over a stable copy while the worker
127
+ # keeps appending (entries themselves are append-only after write).
128
+ def history_snapshot
129
+ @monitor.synchronize { @history.dup }
130
+ end
131
+
132
+ # Diagnostic pointers (issue #124): the append-only journal path and
133
+ # its last persistence failure (nil while healthy) — a failed journal
134
+ # write must be observable, never silently dropped.
135
+ def journal_path = @journal&.path
136
+ def journal_error = @journal&.last_error
137
+
90
138
  # Inbox girdisi: mesaj metni + GERÇEK gönderen id'si. nil veya makine
91
139
  # adı "agent-0" gönderenler render'da maestro persona'sına çevrilir
92
140
  # (iki-düzlem adlandırma sözleşmesi) — subagent gönderenler kendi
@@ -137,68 +185,45 @@ module Rubik
137
185
  return false if @thread&.alive?
138
186
 
139
187
  @started_at = Time.now
188
+ @run_seq += 1
140
189
  transition!(:running)
141
190
  @unit&.sync_state!
191
+ @journal&.record_run_started(run: @run_seq, role: @role, type: @type_name, prompt: @prompt)
142
192
  end
143
193
 
194
+ ready = Queue.new
195
+ run_gate = Queue.new
144
196
  @thread = Thread.new do
145
- run_execution_cycle
146
- ensure
147
- @ended_at ||= Time.now
148
- save_transcript! if @transcript_path.nil?
149
- cleanup_context
150
- on_complete&.call(self)
151
- end
152
- true
153
- end
154
-
155
- def kill
156
- @monitor.synchronize do
157
- return true if disposed?
158
-
159
- transition!(:canceling)
160
- @ended_at = Time.now
161
- @budget_cond.broadcast
162
- @error_cond.broadcast
163
- end
164
-
165
- begin; @thread&.kill if @thread&.alive?; rescue StandardError; nil; end
166
-
167
- cleanup
168
- true
169
- end
170
-
171
- def cleanup
172
- @monitor.synchronize do
173
- return if disposed?
174
-
175
- @budget_cond.broadcast
176
- @error_cond.broadcast
177
- cleanup_context
178
- transition!(:disposed)
179
- end
180
- end
181
-
182
- # Idempotent under monitor: check-and-set of @context_cleaned_up (ensure block vs kill cleanup race).
183
- def cleanup_context
184
- @monitor.synchronize do
185
- return if @context_cleaned_up
186
-
187
- @context_cleaned_up = true
188
- begin
189
- @context&.dispose unless @context&.disposed?
190
- rescue StandardError => e
191
- @last_error ||= "#{e.class}: #{e.message}"
197
+ Thread.handle_interrupt(Object => :never) do
198
+ ready << true # cleanup is armed before cancellation is enabled
199
+ Thread.handle_interrupt(Object => :immediate) do
200
+ run_gate.pop
201
+ run_execution_cycle
202
+ end
203
+ ensure
204
+ @ended_at ||= Time.now
205
+ @journal&.record_run_finished(run: @run_seq, state: @state.to_s, result: @result,
206
+ result_ref: (result_ref if settled?))
207
+ save_transcript! if @transcript_path.nil?
208
+ @cancelled_by_operator ? cleanup : cleanup_context
209
+ on_complete&.call(self)
192
210
  end
193
211
  end
212
+ ready.pop
213
+ run_gate << true
214
+ true
194
215
  end
195
216
 
196
217
  def to_h
197
218
  @monitor.synchronize do
198
219
  { id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
199
- **@budget.to_h, grants_received: @grants_received, uptime_seconds: uptime.round(2),
220
+ **@budget.to_h, grants_received: @grants_received, run: @run_seq,
221
+ result_ref: result_ref, owner: @owner_task&.id, detached: @owner_task.nil?, uptime_seconds: uptime.round(2),
222
+ operation: operations.current, timings: operations.timings,
223
+ last_progress_seconds_ago: operations.last_progress_seconds_ago,
200
224
  started_at: @started_at&.iso8601, ended_at: @ended_at&.iso8601,
201
- transcript: @transcript_uri, last_error: @last_error,
225
+ transcript: @transcript_uri, transcript_ref: transcript_ref,
226
+ journal: journal_path, journal_error: journal_error, last_error: @last_error,
202
227
  result_summary: @result&.to_s&.slice(0, 100), result: @result, state_note: state_note }
203
228
  end
204
229
  end
@@ -211,60 +236,14 @@ module Rubik
211
236
  end
212
237
  end
213
238
 
214
- def format_transcript
215
- TranscriptFormatter.format(self, @history, @monitor)
216
- end
217
-
218
239
  private
219
240
 
220
- # The run's single failure drain: record why, land on :failed, sync
221
- # the unit, persist — the loud prefix keeps an FSM violation
222
- # distinguishable from a provider failure.
223
- def fail_run(reason)
224
- @monitor.synchronize do
225
- @last_error = reason
226
- transition!(:failed)
227
- @unit&.sync_state!
228
- end
229
- save_transcript!
230
- end
231
-
232
241
  # Private by design: a grant must pair with the terminal guard, the
233
242
  # history note, and the broadcast — all lifecycle facts owned here.
234
243
  # Tests drive budget states through supervisor + provider, never by
235
244
  # reaching into this reader.
236
245
  attr_reader :budget
237
246
 
238
- def save_transcript!
239
- saved = TranscriptFormatter.save!(@transcript_uri, @history, @monitor,
240
- prompt_reconstructible: @prompt_reconstructible)
241
- @transcript_path = saved
242
- # A failed save must not be silent: the advertised path would 404 silently.
243
- @last_error ||= "Transcript save failed for #{@transcript_uri}: see stderr" unless saved
244
- end
245
-
246
- def run_execution_cycle
247
- # Fresh runs seed; a redeployed run continues its preserved history.
248
- @history.push({ role: "system", content: build_effective_system_prompt }, { role: "user", content: @prompt }) if @history.empty?
249
-
250
- unless @provider
251
- @budget.consume
252
- @result = Narration.completed_stub_line(@id, @role, @prompt)
253
- transition!(:completed)
254
- @unit&.sync_state!
255
- save_transcript!
256
- return
257
- end
258
-
259
- execute_steps_with_provider
260
- @unit&.sync_state!
261
- save_transcript!
262
- rescue StateError => e
263
- fail_run("LIFECYCLE INVARIANT VIOLATED: #{e.class}: #{e.message}")
264
- rescue StandardError => e
265
- fail_run("#{e.class}: #{e.message}")
266
- end
267
-
268
247
  # The single budget-grant core (Candidate 03): terminal guard ->
269
248
  # grant -> resume -> note -> broadcast. Note wording lives in
270
249
  # Budget.grant_note/message_grant_note (audience boundary: the
@@ -366,18 +345,6 @@ module Rubik
366
345
  end
367
346
  end
368
347
  end
369
-
370
- def build_effective_system_prompt
371
- base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
372
- "Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
373
- "Allocated Step Budget: #{@budget.max_steps} steps.\n\n" \
374
- "A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). Pace yourself: " \
375
- "when remaining steps (Y - X) run low, conclude the task or prepare a progress summary for the maestro " \
376
- "supervisor — it may extend your budget or steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
377
- "ask_question tool — it blocks until the maestro answers or times out; ask only when the answer truly changes your work.\n\n" \
378
- "Perform your assigned task with high precision and provide a structured synthesis report."
379
- @system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
380
- end
381
348
  end
382
349
  end
383
350
  end