rubik 0.1.3 → 0.1.5
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 +53 -0
- data/lib/rubik/coeffects.rb +2 -2
- data/lib/rubik/config/defaults.rb +3 -5
- data/lib/rubik/context/scanner.rb +4 -1
- data/lib/rubik/llm/client.rb +39 -7
- data/lib/rubik/orchestrator/goal_ledger/ledger.rb +21 -48
- data/lib/rubik/orchestrator/goal_ledger/suspend_lifecycle.rb +136 -0
- data/lib/rubik/orchestrator/goal_ledger/xml_render.rb +61 -0
- data/lib/rubik/orchestrator/goal_registry.rb +67 -0
- data/lib/rubik/orchestrator/goal_state.rb +75 -5
- data/lib/rubik/orchestrator/loop.rb +122 -16
- data/lib/rubik/orchestrator/subagent/type_registry.rb +29 -31
- data/lib/rubik/orchestrator.rb +1 -0
- data/lib/rubik/prompt/context_builder.rb +1 -1
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/agent_execution.rb +401 -110
- data/lib/rubik/repl/terminal_guard.rb +5 -1
- data/lib/rubik/version.rb +1 -1
- metadata +6 -3
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
3
6
|
require_relative "base"
|
|
4
7
|
require_relative "../../runtime"
|
|
5
8
|
require_relative "../../sandbox"
|
|
@@ -14,7 +17,7 @@ module Rubik
|
|
|
14
17
|
# Whole-command synopses: shared by the spine registration (usage:)
|
|
15
18
|
# and the empty-args branches that print them, so the two cannot drift.
|
|
16
19
|
USAGE_PLAN = "Kullanım: !plan <görev açıklaması>"
|
|
17
|
-
USAGE_GOAL = "Kullanım: !goal <hedef açıklaması>"
|
|
20
|
+
USAGE_GOAL = "Kullanım: !goal [--steps N|-s N] <hedef açıklaması> | !goal resume | !goal list"
|
|
18
21
|
|
|
19
22
|
def self.register(shell)
|
|
20
23
|
register_plan_command(shell)
|
|
@@ -70,14 +73,53 @@ module Rubik
|
|
|
70
73
|
private_class_method def self.register_goal_command(shell)
|
|
71
74
|
shell.register_command("goal", description: "Run long-running autonomous goal loop (the maestro supervises a subagent pipeline) until all tests pass",
|
|
72
75
|
usage: USAGE_GOAL) do |args, ctx|
|
|
73
|
-
|
|
76
|
+
# Askı alt-komutları (issue #106): !goal resume | !goal list.
|
|
77
|
+
# Tek-argüman şartı: "Resume the deployment" gibi hedef metinleri
|
|
78
|
+
# alt-komut olarak yakalanamaz.
|
|
79
|
+
sub = args.first.to_s.downcase
|
|
80
|
+
next resume_suspended_goal(ctx) if args.length == 1 && sub == "resume"
|
|
81
|
+
next list_suspended_goals(ctx) if args.length == 1 && sub == "list"
|
|
82
|
+
|
|
83
|
+
custom_steps = nil
|
|
84
|
+
bad_steps = false
|
|
85
|
+
filtered_args = []
|
|
86
|
+
i = 0
|
|
87
|
+
while i < args.length
|
|
88
|
+
arg = args[i]
|
|
89
|
+
if (m = arg.match(/\A--steps=(\d+)\z/))
|
|
90
|
+
custom_steps = m[1].to_i
|
|
91
|
+
elsif arg.start_with?("--steps=")
|
|
92
|
+
# --steps=X ama X sayı değil: flag metne sızmasın.
|
|
93
|
+
bad_steps = true
|
|
94
|
+
elsif ["--steps", "-s"].include?(arg)
|
|
95
|
+
if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
|
|
96
|
+
custom_steps = args[i + 1].to_i
|
|
97
|
+
i += 1
|
|
98
|
+
else
|
|
99
|
+
# Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
|
|
100
|
+
# yanlış koşu yerine).
|
|
101
|
+
bad_steps = true
|
|
102
|
+
end
|
|
103
|
+
else
|
|
104
|
+
filtered_args << arg
|
|
105
|
+
end
|
|
106
|
+
i += 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if bad_steps || (custom_steps && custom_steps < 1)
|
|
110
|
+
ctx.out.puts USAGE_GOAL
|
|
111
|
+
ctx.out.puts "⚠️ [Rubik::Goal] --steps pozitif bir tam sayı bekliyor (örn. !goal --steps 200 <hedef>)"
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
goal_text = filtered_args.join(" ").strip
|
|
74
116
|
if goal_text.empty?
|
|
75
117
|
ctx.out.puts USAGE_GOAL
|
|
76
118
|
next
|
|
77
119
|
end
|
|
78
120
|
|
|
79
121
|
ctx.recorder&.record_user_turn("!goal #{goal_text}")
|
|
80
|
-
goal_steps = ctx.config.fetch("agent.goal_max_steps", 100).to_i
|
|
122
|
+
goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
|
|
81
123
|
warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
|
|
82
124
|
routed = ctx.router.route(task_type: :goal, prompt: goal_text)
|
|
83
125
|
ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
|
|
@@ -106,12 +148,10 @@ module Rubik
|
|
|
106
148
|
)
|
|
107
149
|
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
|
|
108
150
|
end
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
goal_state.ledger_id = goal_ledger.snapshot[:storage_id]
|
|
114
|
-
end
|
|
151
|
+
# Askı kimliği (issue #106): koşu boyunca SABİT — registry
|
|
152
|
+
# girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
|
|
153
|
+
# (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
|
|
154
|
+
goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
115
155
|
|
|
116
156
|
ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
|
|
117
157
|
ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
|
|
@@ -123,128 +163,379 @@ module Rubik
|
|
|
123
163
|
warn_at_ratio: warn_ratio,
|
|
124
164
|
context_limit: routed[:context_limit],
|
|
125
165
|
max_output_tokens: routed[:max_output_tokens],
|
|
126
|
-
on_step:
|
|
127
|
-
# Adım sayacı rubik://goal/state'e aksın: olaylar bir adım
|
|
128
|
-
# içinde tekrarlanabilir, GoalState monotoniktir (zararsız).
|
|
129
|
-
goal_state.update_steps(step_info[:step], step_info[:max_steps]) if step_info[:step].is_a?(Integer)
|
|
130
|
-
# Session teşhis edilebilirliği (field incident 2026-10-12):
|
|
131
|
-
# saatler süren !goal koşuları session dosyasına tek satır
|
|
132
|
-
# düşürüyordu — free-text turn simetrisinde tool adımları
|
|
133
|
-
# kayıtçıya akar; render ve LLM geçmişi değişmez.
|
|
134
|
-
case step_info[:type]&.to_sym
|
|
135
|
-
when :tool_call
|
|
136
|
-
ctx.recorder&.record_tool_call(step_info[:tool], step_info[:args], call_id: step_info[:call_id])
|
|
137
|
-
when :tool_result
|
|
138
|
-
ctx.recorder&.record_tool_result(step_info[:tool], step_info[:output], status: step_info[:status] || :ok, call_id: step_info[:call_id])
|
|
139
|
-
end
|
|
140
|
-
ctx.render_step_event(step_info)
|
|
141
|
-
}
|
|
166
|
+
on_step: build_goal_on_step(ctx, goal_state)
|
|
142
167
|
)
|
|
143
168
|
|
|
144
169
|
# Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
|
|
145
170
|
# Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
|
|
146
171
|
# restore için conversation_history VE record_assistant_response
|
|
147
172
|
# birlikte yazılmalı (başarısızlıkta da).
|
|
148
|
-
record_goal_pair =
|
|
149
|
-
ctx.conversation_history << { role: "user", content: "Goal: #{goal_text}" }
|
|
150
|
-
ctx.conversation_history << { role: "assistant", content: assistant_content }
|
|
151
|
-
ctx.recorder&.record_assistant_response(assistant_content, tokens: tokens, streamed: streamed)
|
|
152
|
-
ctx.trim_conversation_window!
|
|
153
|
-
end
|
|
173
|
+
record_goal_pair = build_goal_record_pair(ctx, goal_text)
|
|
154
174
|
|
|
155
175
|
# Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
|
|
156
176
|
# aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
|
|
157
177
|
ctx.apply_staged_prune_plan!
|
|
158
178
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
# komut girişinde çoktan yazdı — assistant_response olmadan
|
|
166
|
-
# session restore EŞLEŞMEMİŞ bir 'Goal: ...' turu hidrate ederdi.
|
|
167
|
-
# Adım sayısı: loop'un truncation hatası :steps'siz döner —
|
|
168
|
-
# GoalState monotonik sayacı otoriter yedek.
|
|
169
|
-
fail_goal = lambda do |reason, tokens: nil, steps: nil|
|
|
170
|
-
sanitized = Rubik::Platform.safe_utf8(reason.to_s)
|
|
171
|
-
steps_used = steps || goal_state.step_count
|
|
172
|
-
goal_state.fail!(sanitized, tokens: tokens)
|
|
173
|
-
finalize_ledger.call(:failed)
|
|
174
|
-
ctx.out.puts "\n❌ Goal could not be completed: #{sanitized}"
|
|
175
|
-
record_goal_pair.call("[Goal attempt ended after #{steps_used} steps: #{sanitized}]", tokens: tokens)
|
|
176
|
-
end
|
|
179
|
+
drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
|
|
180
|
+
goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
|
|
181
|
+
record_pair: record_goal_pair,
|
|
182
|
+
start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
|
|
183
|
+
end
|
|
184
|
+
end
|
|
177
185
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
# !goal sürücüsü (issue #106): koşu girişi + bütçe-soru döngüsü +
|
|
188
|
+
# tek hata kanalı + terminal eşlemesi — start ve resume yollarının
|
|
189
|
+
# ORTAK gövdesi. start callable'ı yolu seçer:
|
|
190
|
+
# başlangıç → loop.run (taze koşu)
|
|
191
|
+
# süreç-içi resume → loop.resume! (history prefix nesne-özdeş)
|
|
192
|
+
# çapraz-süreç resume → loop.resume_from (Σ penceresi)
|
|
193
|
+
# Askı dalı (#106): SADECE transient ClientError SUSPENDED'a gider;
|
|
194
|
+
# kalıcı hatalar (moderasyon/401/read-timeout) bugünkü fail_goal
|
|
195
|
+
# çıktısını bayt-baytına korur.
|
|
196
|
+
private_class_method def self.drive_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
|
|
197
|
+
goal_text:, goal_id:, record_pair:, start:)
|
|
198
|
+
finalize_ledger = lambda do |status|
|
|
199
|
+
next unless goal_ledger
|
|
200
|
+
|
|
201
|
+
goal_ledger.finalize!(status)
|
|
202
|
+
goal_state.ledger_id = goal_ledger.snapshot[:storage_id]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
fail_goal = lambda do |reason, tokens: nil, steps: nil|
|
|
206
|
+
sanitized = Rubik::Platform.safe_utf8(reason.to_s)
|
|
207
|
+
steps_used = steps || goal_state.step_count
|
|
208
|
+
goal_state.fail!(sanitized, tokens: tokens)
|
|
209
|
+
finalize_ledger.call(:failed)
|
|
210
|
+
forget_suspended_goal(ctx, goal_id)
|
|
211
|
+
ctx.out.puts "\n❌ Goal could not be completed: #{sanitized}"
|
|
212
|
+
record_pair.call("[Goal attempt ended after #{steps_used} steps: #{sanitized}]", tokens: tokens)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
suspend_goal = lambda do |reason, tokens: nil|
|
|
216
|
+
attempt = suspended_attempt(ctx, goal_id) + 1
|
|
217
|
+
goal_state.suspend!(reason, tokens: tokens)
|
|
218
|
+
goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
|
|
219
|
+
registry = goal_registry_for(ctx)
|
|
220
|
+
registry.register(Rubik::Orchestrator::GoalRegistry::Entry.new(
|
|
221
|
+
goal_id: goal_id, goal_text: goal_text, loop: orchestrator,
|
|
222
|
+
goal_state: goal_state, ledger: goal_ledger, reason: reason.to_s,
|
|
223
|
+
suspended_at: Time.now, attempt: attempt
|
|
224
|
+
))
|
|
225
|
+
ctx.out.puts "\n⏸️ [Rubik::Goal] Suspended after #{goal_state.step_count} steps (transient provider error): #{reason}"
|
|
226
|
+
ctx.out.puts " Loop, counters and Σ ledger are preserved — resume with `!goal resume`."
|
|
227
|
+
record_pair.call("[Goal suspended after #{goal_state.step_count} steps: #{reason}]", tokens: tokens)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
ctx.active_loop = orchestrator
|
|
231
|
+
begin
|
|
232
|
+
res = start.call(orchestrator)
|
|
233
|
+
|
|
234
|
+
# Bütçe tükenince sor (execute_free_text simetrisi; aynı knob,
|
|
235
|
+
# aynı +20). continue_loop boyunca active_loop DOLU kalmalı:
|
|
236
|
+
# notice yönlendirmesi aktif döngüye bakar (lifecycle.rb
|
|
237
|
+
# route_subagent_notice) — soruya beklerken gelen subagent
|
|
238
|
+
# olayları loop inbox'a girer ve devam adımında boşaltılır.
|
|
239
|
+
auto_extensions = 0
|
|
240
|
+
max_auto = ctx.config.fetch("agent.goal_auto_continue_limit", 0).to_i
|
|
241
|
+
while !res[:ok] && res[:error]&.include?("Max steps budget exceeded")
|
|
242
|
+
if auto_extensions < max_auto
|
|
243
|
+
auto_extensions += 1
|
|
244
|
+
ctx.out.puts "\n🤖 [Rubik::Goal] Adım bütçesi doldu (#{res[:error]}). " \
|
|
245
|
+
"Otonom devam hakkı (#{auto_extensions}/#{max_auto}) kullanılarak +20 adım ekleniyor..."
|
|
193
246
|
goal_state.extend!(goal_state.max_steps + 20)
|
|
194
|
-
res = orchestrator.continue_loop(20)
|
|
247
|
+
res = orchestrator.continue_loop(20, auto: true)
|
|
248
|
+
next
|
|
195
249
|
end
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
|
|
251
|
+
break unless ctx.config.fetch("agent.ask_continue_on_limit", true)
|
|
252
|
+
|
|
253
|
+
ctx.out.puts "\n⚠️ [Rubik::Goal] #{res[:error]}"
|
|
254
|
+
ans = ctx.ask_yes_no(" [Rubik::Goal] +20 adım ile devam edilsin mi?", default: "n")
|
|
255
|
+
break unless ["y", "yes", "evet"].include?(ans)
|
|
256
|
+
|
|
257
|
+
ctx.out.puts " [Rubik::Goal] Adım bütçesi +20 artırılıyor ve hedef sürdürülüyor..."
|
|
258
|
+
goal_state.extend!(goal_state.max_steps + 20)
|
|
259
|
+
res = orchestrator.continue_loop(20)
|
|
260
|
+
end
|
|
261
|
+
rescue Interrupt
|
|
262
|
+
# ^C hedefi 'running' bırakmasın; eşlenmiş kayıt otursun. Interrupt
|
|
263
|
+
# yeniden yükselir — kullanıcı mesajını execute_command basar.
|
|
264
|
+
# Resume üstüne ^C = askıyı terk (terminal :stopped damgası).
|
|
265
|
+
goal_state.stop!("Interrupted by user (^C)")
|
|
266
|
+
finalize_ledger.call(:stopped)
|
|
267
|
+
forget_suspended_goal(ctx, goal_id)
|
|
268
|
+
record_pair.call("[Goal attempt interrupted after #{goal_state.step_count} steps]")
|
|
269
|
+
raise
|
|
270
|
+
rescue Rubik::LLM::ClientError => e
|
|
271
|
+
# Tek hata kanalı (#106 uzantısı): transient transport hatası
|
|
272
|
+
# (RETRYABLE tükenmesi, tükenmiş 5xx, 429) goal'u SUSPENDED'a
|
|
273
|
+
# götürür — loop/counters/Σ korunur, REPL yaşar. Kalıcı hata
|
|
274
|
+
# (#105 çıktısı değişmeden) terminal fail. Yeniden yükseltmez —
|
|
275
|
+
# spinner temizliği dispatch katmanına (execute_command) aittir.
|
|
276
|
+
# next yerine return: rescue sonrası res'siz terminal
|
|
277
|
+
# eşlemesine düşülmesin.
|
|
278
|
+
if e.respond_to?(:transient?) && e.transient?
|
|
279
|
+
suspend_goal.call(e.message)
|
|
280
|
+
else
|
|
208
281
|
fail_goal.call(e.message)
|
|
209
|
-
next
|
|
210
|
-
rescue StandardError => e
|
|
211
|
-
fail_goal.call("#{e.class}: #{e.message}")
|
|
212
|
-
next
|
|
213
|
-
ensure
|
|
214
|
-
ctx.active_loop = nil
|
|
215
282
|
end
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
ctx.out.puts "\nTip: Use `!diff` to review changes and `!apply` to commit to disk." if ctx.buffer_manager.dirty?
|
|
238
|
-
end
|
|
283
|
+
return
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
fail_goal.call("#{e.class}: #{e.message}")
|
|
286
|
+
return
|
|
287
|
+
ensure
|
|
288
|
+
ctx.active_loop = nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if res[:ok]
|
|
292
|
+
active = goal_active_subagent_count(ctx)
|
|
293
|
+
if active.positive?
|
|
294
|
+
# Defense behind the loop-level goal guard (GoalWait): a
|
|
295
|
+
# success terminal escaping with live subagents means the
|
|
296
|
+
# pipeline is mid-flight — an honest stop beats a false
|
|
297
|
+
# "Goal Achieved" (field incident 2026-10-05).
|
|
298
|
+
reason = "Goal loop concluded with #{active} active subagent(s) still running — not marked complete"
|
|
299
|
+
goal_state.stop!(reason)
|
|
300
|
+
finalize_ledger.call(:stopped)
|
|
301
|
+
ctx.out.puts "\n⚠️ [Rubik::Goal] #{reason}."
|
|
302
|
+
record_pair.call("[Goal attempt ended after #{goal_state.step_count} steps: #{reason}]", tokens: res[:tokens])
|
|
239
303
|
else
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
304
|
+
goal_state.complete!(res[:output].to_s, tokens: res[:tokens])
|
|
305
|
+
finalize_ledger.call(:completed)
|
|
306
|
+
forget_suspended_goal(ctx, goal_id)
|
|
307
|
+
ctx.out.puts "\n🎉 Goal Achieved in #{res[:steps]} steps!"
|
|
308
|
+
ctx.out.puts res[:output]
|
|
309
|
+
# Keep the bang syntax out of the LLM context (Plan 53 D-8): the
|
|
310
|
+
# session recorder already stores the literal "!goal ..." for replay.
|
|
311
|
+
record_pair.call(res[:output].to_s, streamed: res[:streamed] == true, tokens: res[:tokens])
|
|
312
|
+
|
|
313
|
+
ctx.out.puts "\nTip: Use `!diff` to review changes and `!apply` to commit to disk." if ctx.buffer_manager.dirty?
|
|
244
314
|
end
|
|
315
|
+
else
|
|
316
|
+
# Başarısızlık eşlemesi yukarıdaki tek hata kanalından akar:
|
|
317
|
+
# GoalState → ledger finalize → kayıtçı çifti, istisna yoluyla
|
|
318
|
+
# aynı sırayla.
|
|
319
|
+
fail_goal.call(res[:error], tokens: res[:tokens], steps: res[:steps])
|
|
245
320
|
end
|
|
246
321
|
end
|
|
247
322
|
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
# !goal resume (issue #106). İki yol, dönüşüm sırasına göre:
|
|
325
|
+
# 1) Oturum kaydında canlı girdi varsa süreç-içi resume — hayatta
|
|
326
|
+
# kalan Loop örneği canlandırılır (subagent konuşmaları gerçekten
|
|
327
|
+
# süreklidir; süpervizör sürecin yaşıyor).
|
|
328
|
+
# 2) Yoksa çapraz-süreç Σ-only resume — storage'daki "suspended"
|
|
329
|
+
# satırından rehidrasyon. Alt ajan konuşmaları süreçle ölür;
|
|
330
|
+
# çıktı bunu tek cümleyle dürüstçe söyler.
|
|
331
|
+
private_class_method def self.resume_suspended_goal(ctx)
|
|
332
|
+
registry = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::GOAL_REGISTRY)
|
|
333
|
+
entry = registry.latest if registry.is_a?(Rubik::Orchestrator::GoalRegistry)
|
|
334
|
+
entry ? resume_in_process(ctx, entry) : resume_cross_process(ctx)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private_class_method def self.resume_in_process(ctx, entry)
|
|
338
|
+
ctx.recorder&.record_user_turn("!goal resume")
|
|
339
|
+
goal_state = entry.goal_state
|
|
340
|
+
goal_ledger = entry.ledger
|
|
341
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
|
|
342
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger) if goal_ledger
|
|
343
|
+
goal_ledger&.unsuspend!
|
|
344
|
+
goal_state.resume!
|
|
345
|
+
ctx.out.puts "[Rubik::Goal] Resuming '#{entry.goal_text}' (attempt #{entry.attempt + 1}, " \
|
|
346
|
+
"suspended: #{entry.reason})"
|
|
347
|
+
# Dürüst süreklilik cümlesi: alt ajan konuşmaları süreç-içi
|
|
348
|
+
# resume'da yaşar — çapraz-süreç yoldaki tek cümlelik uyarı burada
|
|
349
|
+
# YOKTUR (gerçek süreklilik).
|
|
350
|
+
record_pair = build_goal_record_pair(ctx, entry.goal_text)
|
|
351
|
+
ctx.apply_staged_prune_plan!
|
|
352
|
+
drive_goal_turn(ctx, orchestrator: entry.loop, goal_state: goal_state,
|
|
353
|
+
goal_ledger: goal_ledger, goal_text: entry.goal_text,
|
|
354
|
+
goal_id: entry.goal_id, record_pair: record_pair,
|
|
355
|
+
start: lambda { |loop_inst|
|
|
356
|
+
loop_inst.resume!(reason: entry.reason,
|
|
357
|
+
suspended_at: entry.suspended_at,
|
|
358
|
+
attempt: entry.attempt)
|
|
359
|
+
})
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private_class_method def self.resume_cross_process(ctx)
|
|
363
|
+
ctx.recorder&.record_user_turn("!goal resume")
|
|
364
|
+
store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
|
|
365
|
+
unless store
|
|
366
|
+
ctx.out.puts "ℹ️ [Rubik::Goal] No suspended goal in this session; storage unavailable — " \
|
|
367
|
+
"cross-process resume honestly refused."
|
|
368
|
+
return
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
row = suspended_rows(store).last
|
|
372
|
+
unless row
|
|
373
|
+
ctx.out.puts "ℹ️ [Rubik::Goal] No suspended goal found (session registry empty, no 'suspended' Σ rows)."
|
|
374
|
+
return
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
|
|
378
|
+
ledger = Rubik::Orchestrator::GoalLedger::Ledger.load(store: store, storage_id: row.id,
|
|
379
|
+
config: ctx.config, dmesg: dmesg,
|
|
380
|
+
event_bus: ctx.event_bus)
|
|
381
|
+
unless ledger
|
|
382
|
+
ctx.out.puts "⚠️ [Rubik::Goal] Suspended goal row #{row.id} could not be rehydrated — " \
|
|
383
|
+
"cross-process resume refused (corrupt or non-suspended stamp)."
|
|
384
|
+
return
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
meta = ledger.suspend_meta || {}
|
|
388
|
+
goal_text = ledger.snapshot[:objective].to_s
|
|
389
|
+
goal_steps = meta["max_steps"].to_i.positive? ? meta["max_steps"].to_i : ctx.config.fetch("agent.goal_max_steps", 100).to_i
|
|
390
|
+
warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
|
|
391
|
+
goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
|
|
392
|
+
goal_state.start!(goal_text, goal_steps)
|
|
393
|
+
goal_state.restore_counters!(steps: meta["steps"], max_steps: goal_steps,
|
|
394
|
+
extensions: meta["extensions"], tokens: meta["tokens"])
|
|
395
|
+
goal_state.ledger_id = ledger.snapshot[:storage_id]
|
|
396
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
|
|
397
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, ledger)
|
|
398
|
+
|
|
399
|
+
routed = ctx.router.route(task_type: :goal, prompt: goal_text)
|
|
400
|
+
ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
|
|
401
|
+
orchestrator = ctx.orchestrator_loop(
|
|
402
|
+
provider: routed[:provider],
|
|
403
|
+
model: routed[:model],
|
|
404
|
+
max_steps: goal_steps,
|
|
405
|
+
warn_at_ratio: warn_ratio,
|
|
406
|
+
context_limit: routed[:context_limit],
|
|
407
|
+
max_output_tokens: routed[:max_output_tokens],
|
|
408
|
+
on_step: build_goal_on_step(ctx, goal_state)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
ctx.out.puts "[Rubik::Goal] Cross-process resume of '#{goal_text}' (attempt #{meta['attempt'].to_i})..."
|
|
412
|
+
# Dürüst süreklilik cümlesi (tasarım sözleşmesi): alt ajan
|
|
413
|
+
# konuşmaları süreçle öldü; disk etkileri ve session kaydı yaşar.
|
|
414
|
+
ctx.out.puts " Subagent conversations did not survive the process restart — their disk " \
|
|
415
|
+
"effects and the session record have."
|
|
416
|
+
ledger.unsuspend!
|
|
417
|
+
record_pair = build_goal_record_pair(ctx, goal_text)
|
|
418
|
+
ctx.apply_staged_prune_plan!
|
|
419
|
+
drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
|
|
420
|
+
goal_ledger: ledger, goal_text: goal_text,
|
|
421
|
+
goal_id: meta["goal_id"].to_s.empty? ? row.id : meta["goal_id"],
|
|
422
|
+
record_pair: record_pair,
|
|
423
|
+
start: lambda { |loop_inst|
|
|
424
|
+
loop_inst.resume_from(goal_text: goal_text, ledger: ledger,
|
|
425
|
+
reason: meta["reason"].to_s,
|
|
426
|
+
suspended_at: parse_suspended_at(meta["suspended_at"]),
|
|
427
|
+
attempt: meta["attempt"].to_i,
|
|
428
|
+
counters: { steps: meta["steps"], max_steps: goal_steps,
|
|
429
|
+
tokens: meta["tokens"] })
|
|
430
|
+
})
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# !goal list (issue #106 Faz 2a): store'daki status == "suspended"
|
|
434
|
+
# Σ satırları — özetler zaten status taşıyor (Store::Summary).
|
|
435
|
+
private_class_method def self.list_suspended_goals(ctx)
|
|
436
|
+
ctx.recorder&.record_user_turn("!goal list")
|
|
437
|
+
store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
|
|
438
|
+
unless store
|
|
439
|
+
ctx.out.puts "ℹ️ [Rubik::Goal] Storage unavailable — the suspended-goal list needs goal.ledger storage."
|
|
440
|
+
return
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
rows = suspended_rows(store)
|
|
444
|
+
if rows.empty?
|
|
445
|
+
ctx.out.puts "ℹ️ [Rubik::Goal] No suspended goals."
|
|
446
|
+
return
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
ctx.out.puts "=== Suspended goals ==="
|
|
450
|
+
rows.each do |s|
|
|
451
|
+
ctx.out.puts " #{s.id} #{s.updated_at} #{s.title}"
|
|
452
|
+
end
|
|
453
|
+
ctx.out.puts "Resume the latest with: !goal resume"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Askı satırları: en eski → en yeni sıralı (updated_at; kuiratör
|
|
457
|
+
# list yüzeyiyle aynı alan). Sort anahtarı eşitse row sırası.
|
|
458
|
+
private_class_method def self.suspended_rows(store)
|
|
459
|
+
store.list(type: "goal").select { |s| s.status == "suspended" }.sort_by(&:updated_at)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Σ damgası meta'sı (issue #106): {goal_id, steps, max_steps,
|
|
463
|
+
# extensions, tokens, reason, suspended_at, attempt}. Sembol
|
|
464
|
+
# anahtarlar Ledger#suspend! içinde dizgeleştirilir.
|
|
465
|
+
private_class_method def self.suspend_stamp_meta(goal_state, goal_id, reason, attempt)
|
|
466
|
+
{ goal_id: goal_id, steps: goal_state.step_count, max_steps: goal_state.max_steps,
|
|
467
|
+
extensions: goal_state.extensions, tokens: goal_state.tokens.dup,
|
|
468
|
+
reason: reason.to_s.slice(0, 300), suspended_at: Time.now.utc.iso8601, attempt: attempt }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private_class_method def self.suspended_attempt(ctx, goal_id)
|
|
472
|
+
registry = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::GOAL_REGISTRY)
|
|
473
|
+
registry&.fetch(goal_id)&.attempt.to_i
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Oturum kaydı coeffect'i: ilk askıda provide edilir (provide-swap
|
|
477
|
+
# semantiği — Context#provide anahtar başına TEK delete-inverse
|
|
478
|
+
# tutar; tekrarlanan suspend/resume Ω'yı büyütmez).
|
|
479
|
+
private_class_method def self.goal_registry_for(ctx)
|
|
480
|
+
registry = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::GOAL_REGISTRY)
|
|
481
|
+
return registry if registry.is_a?(Rubik::Orchestrator::GoalRegistry)
|
|
482
|
+
|
|
483
|
+
registry = Rubik::Orchestrator::GoalRegistry.new
|
|
484
|
+
ctx.runtime_manager&.provide(Rubik::Coeffects::GOAL_REGISTRY, registry)
|
|
485
|
+
registry
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Terminal yollarda girdi temizliği: kayıt SADECE sağlanmışsa —
|
|
489
|
+
# askı hiç yaşanmamış bir koşunun terminali boş registry provide
|
|
490
|
+
# ETMEMELİ (Ω disiplini).
|
|
491
|
+
private_class_method def self.forget_suspended_goal(ctx, goal_id)
|
|
492
|
+
registry = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::GOAL_REGISTRY)
|
|
493
|
+
registry.delete(goal_id) if registry.is_a?(Rubik::Orchestrator::GoalRegistry)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Adım olayı lambda'sı (start + cross-process resume ortak): sayaç
|
|
497
|
+
# akışı, kayıtçı tool çiftleri, render — start yolundaki satır içi
|
|
498
|
+
# gövdenin birebir taşınması.
|
|
499
|
+
private_class_method def self.build_goal_on_step(ctx, goal_state)
|
|
500
|
+
lambda { |step_info|
|
|
501
|
+
# Adım sayacı rubik://goal/state'e aksın: olaylar bir adım
|
|
502
|
+
# içinde tekrarlanabilir, GoalState monotoniktir (zararsız).
|
|
503
|
+
goal_state.update_steps(step_info[:step], step_info[:max_steps]) if step_info[:step].is_a?(Integer)
|
|
504
|
+
# Session teşhis edilebilirliği (field incident 2026-10-12):
|
|
505
|
+
# saatler süren !goal koşuları session dosyasına tek satır
|
|
506
|
+
# düşürüyordu — free-text turn simetrisinde tool adımları
|
|
507
|
+
# kayıtçıya akar; render ve LLM geçmişi değişmez.
|
|
508
|
+
case step_info[:type]&.to_sym
|
|
509
|
+
when :tool_call
|
|
510
|
+
ctx.recorder&.record_tool_call(step_info[:tool], step_info[:args], call_id: step_info[:call_id])
|
|
511
|
+
when :tool_result
|
|
512
|
+
ctx.recorder&.record_tool_result(step_info[:tool], step_info[:output], status: step_info[:status] || :ok, call_id: step_info[:call_id])
|
|
513
|
+
end
|
|
514
|
+
ctx.render_step_event(step_info)
|
|
515
|
+
}
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
|
|
519
|
+
# Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
|
|
520
|
+
# restore için conversation_history VE record_assistant_response
|
|
521
|
+
# birlikte yazılmalı (başarısızlıkta da).
|
|
522
|
+
private_class_method def self.build_goal_record_pair(ctx, goal_text)
|
|
523
|
+
lambda do |assistant_content, streamed: nil, tokens: nil|
|
|
524
|
+
ctx.conversation_history << { role: "user", content: "Goal: #{goal_text}" }
|
|
525
|
+
ctx.conversation_history << { role: "assistant", content: assistant_content }
|
|
526
|
+
ctx.recorder&.record_assistant_response(assistant_content, tokens: tokens, streamed: streamed)
|
|
527
|
+
ctx.trim_conversation_window!
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Damga meta'sındaki suspended_at (iso8601) → Time; bozuk değer
|
|
532
|
+
# nil — Loop#append_resume_marker! "unknown" downtime basar.
|
|
533
|
+
private_class_method def self.parse_suspended_at(value)
|
|
534
|
+
Time.iso8601(value.to_s)
|
|
535
|
+
rescue ArgumentError, TypeError
|
|
536
|
+
nil
|
|
537
|
+
end
|
|
538
|
+
|
|
248
539
|
# Defense-in-depth behind the loop-level goal guard: if a success
|
|
249
540
|
# terminal escapes GoalWait with live subagent units, the goal must
|
|
250
541
|
# stop honestly instead of celebrating a false completion. Fails
|
|
@@ -108,7 +108,11 @@ module Rubik
|
|
|
108
108
|
# Plan 109: wake a blocked empty readline by injecting an Enter.
|
|
109
109
|
# Reline 0.7.0's IO gates (ANSI/Windows/Dumb) all poll and re-check
|
|
110
110
|
# their ungetc buffer (10ms/100ms/100ms), so the injection reaches
|
|
111
|
-
# the blocked reader on every gate.
|
|
111
|
+
# the blocked reader on every gate. The per-poll re-check is 0.7.0+
|
|
112
|
+
# behavior — rubik.gemspec floors that version and
|
|
113
|
+
# test/integration/test_reline_wakeup_contract.rb pins the real gate
|
|
114
|
+
# (on 0.6.x the injected Enter strands until the next keystroke).
|
|
115
|
+
# idle_and_empty? is re-evaluated
|
|
112
116
|
# inside this critical section (Monitor reentrancy makes the nested
|
|
113
117
|
# synchronize free); the residual window between check and gate
|
|
114
118
|
# consumption is inherent to Reline's thread model and accepted. A
|
data/lib/rubik/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubik
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Özgür Ulukır
|
|
@@ -190,14 +190,14 @@ dependencies:
|
|
|
190
190
|
requirements:
|
|
191
191
|
- - ">="
|
|
192
192
|
- !ruby/object:Gem::Version
|
|
193
|
-
version: '0'
|
|
193
|
+
version: '0.7'
|
|
194
194
|
type: :runtime
|
|
195
195
|
prerelease: false
|
|
196
196
|
version_requirements: !ruby/object:Gem::Requirement
|
|
197
197
|
requirements:
|
|
198
198
|
- - ">="
|
|
199
199
|
- !ruby/object:Gem::Version
|
|
200
|
-
version: '0'
|
|
200
|
+
version: '0.7'
|
|
201
201
|
- !ruby/object:Gem::Dependency
|
|
202
202
|
name: prism
|
|
203
203
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -337,9 +337,12 @@ files:
|
|
|
337
337
|
- lib/rubik/orchestrator/goal_ledger/observation_projection.rb
|
|
338
338
|
- lib/rubik/orchestrator/goal_ledger/patch.rb
|
|
339
339
|
- lib/rubik/orchestrator/goal_ledger/schema.rb
|
|
340
|
+
- lib/rubik/orchestrator/goal_ledger/suspend_lifecycle.rb
|
|
340
341
|
- lib/rubik/orchestrator/goal_ledger/validator.rb
|
|
341
342
|
- lib/rubik/orchestrator/goal_ledger/verifier.rb
|
|
342
343
|
- lib/rubik/orchestrator/goal_ledger/verify_spec.rb
|
|
344
|
+
- lib/rubik/orchestrator/goal_ledger/xml_render.rb
|
|
345
|
+
- lib/rubik/orchestrator/goal_registry.rb
|
|
343
346
|
- lib/rubik/orchestrator/goal_state.rb
|
|
344
347
|
- lib/rubik/orchestrator/goal_wait.rb
|
|
345
348
|
- lib/rubik/orchestrator/loop.rb
|