rubik 0.1.3 → 0.1.4

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.
@@ -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
- goal_text = args.join(" ").strip
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
- finalize_ledger = lambda do |status|
110
- next unless goal_ledger
111
-
112
- goal_ledger.finalize!(status)
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: lambda { |step_info|
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 = lambda do |assistant_content, streamed: nil, tokens: nil|
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
- # Tek hata kanalı (field incident 2026-10-12): eskiden yalnızca
160
- # normal :error dönüşü başarısızlık eşlemesini yapıyordu; loop'tan
161
- # kaçan bir ClientError (sağlayıcı read timeout) goal'u kayıtsız
162
- # 'running' bırakıyor, ledger terminal görmiyor ve session
163
- # dosyası sessiz kalıyordu. Rescue dalları da aynı kapanış
164
- # sırasını buradan çalıştırır. Kayıtçı '!goal ...' user_turn'unu
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
- ctx.active_loop = orchestrator
179
- begin
180
- res = orchestrator.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history)
181
-
182
- # Bütçe tükenince sor (execute_free_text simetrisi; aynı knob,
183
- # aynı +20). continue_loop boyunca active_loop DOLU kalmalı:
184
- # notice yönlendirmesi aktif döngüye bakar (lifecycle.rb
185
- # route_subagent_notice) — soruya beklerken gelen subagent
186
- # olayları loop inbox'a girer ve devam adımında boşaltılır.
187
- while !res[:ok] && res[:error]&.include?("Max steps budget exceeded") && ctx.config.fetch("agent.ask_continue_on_limit", true)
188
- ctx.out.puts "\n⚠️ [Rubik::Goal] #{res[:error]}"
189
- ans = ctx.ask_yes_no(" [Rubik::Goal] +20 adım ile devam edilsin mi?", default: "n")
190
- break unless ["y", "yes", "evet"].include?(ans)
191
-
192
- ctx.out.puts " [Rubik::Goal] Adım bütçesi +20 artırılıyor ve hedef sürdürülüyor..."
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
- rescue Interrupt
197
- # ^C hedefi 'running' bırakmasın; eşlenmiş kayıt otursun. Interrupt
198
- # yeniden yükselir — kullanıcı mesajını execute_command basar.
199
- goal_state.stop!("Interrupted by user (^C)")
200
- finalize_ledger.call(:stopped)
201
- record_goal_pair.call("[Goal attempt interrupted after #{goal_state.step_count} steps]")
202
- raise
203
- rescue Rubik::LLM::ClientError => e
204
- # execute_free_text simetrisi: sağlayıcı hatası (read timeout,
205
- # HTTP 4xx) kaçarsa da goal terminal görür. Yeniden yükseltmez —
206
- # spinner temizliği dispatch katmanına (execute_command) aittir.
207
- # next: rescue sonrası akış res'siz `if res[:ok]`'a düşmesin.
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
- if res[:ok]
217
- active = goal_active_subagent_count(ctx)
218
- if active.positive?
219
- # Defense behind the loop-level goal guard (GoalWait): a
220
- # success terminal escaping with live subagents means the
221
- # pipeline is mid-flight — an honest stop beats a false
222
- # "Goal Achieved" (field incident 2026-10-05).
223
- reason = "Goal loop concluded with #{active} active subagent(s) still running — not marked complete"
224
- goal_state.stop!(reason)
225
- finalize_ledger.call(:stopped)
226
- ctx.out.puts "\n⚠️ [Rubik::Goal] #{reason}."
227
- record_goal_pair.call("[Goal attempt ended after #{goal_state.step_count} steps: #{reason}]", tokens: res[:tokens])
228
- else
229
- goal_state.complete!(res[:output].to_s, tokens: res[:tokens])
230
- finalize_ledger.call(:completed)
231
- ctx.out.puts "\n🎉 Goal Achieved in #{res[:steps]} steps!"
232
- ctx.out.puts res[:output]
233
- # Keep the bang syntax out of the LLM context (Plan 53 D-8): the
234
- # session recorder already stores the literal "!goal ..." for replay.
235
- record_goal_pair.call(res[:output].to_s, streamed: res[:streamed] == true, tokens: res[:tokens])
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
- # Başarısızlık eşlemesi yukarıdaki tek hata kanalından akar:
241
- # GoalState → ledger finalize → kayıtçı çifti, istisna yoluyla
242
- # aynı sırayla.
243
- fail_goal.call(res[:error], tokens: res[:tokens], steps: res[:steps])
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
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
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.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır
@@ -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