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