rubik 0.1.2 → 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,95 +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
- ctx.render_step_event(step_info)
131
- }
166
+ on_step: build_goal_on_step(ctx, goal_state)
132
167
  )
133
168
 
134
169
  # Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
135
170
  # Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
136
171
  # restore için conversation_history VE record_assistant_response
137
172
  # birlikte yazılmalı (başarısızlıkta da).
138
- record_goal_pair = lambda do |assistant_content, streamed: nil, tokens: nil|
139
- ctx.conversation_history << { role: "user", content: "Goal: #{goal_text}" }
140
- ctx.conversation_history << { role: "assistant", content: assistant_content }
141
- ctx.recorder&.record_assistant_response(assistant_content, tokens: tokens, streamed: streamed)
142
- ctx.trim_conversation_window!
143
- end
173
+ record_goal_pair = build_goal_record_pair(ctx, goal_text)
144
174
 
145
175
  # Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
146
- # aşamalı plan varsa goal promptundan ÖNCE uygulanır.
176
+ # aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
147
177
  ctx.apply_staged_prune_plan!
148
- ctx.active_loop = orchestrator
149
- begin
150
- res = orchestrator.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history)
151
-
152
- # Bütçe tükenince sor (execute_free_text simetrisi; aynı knob,
153
- # aynı +20). continue_loop boyunca active_loop DOLU kalmalı:
154
- # notice yönlendirmesi aktif döngüye bakar (lifecycle.rb
155
- # route_subagent_notice) — soruya beklerken gelen subagent
156
- # olayları loop inbox'a girer ve devam adımında boşaltılır.
157
- while !res[:ok] && res[:error]&.include?("Max steps budget exceeded") && ctx.config.fetch("agent.ask_continue_on_limit", true)
158
- ctx.out.puts "\n⚠️ [Rubik::Goal] #{res[:error]}"
159
- ans = ctx.ask_yes_no(" [Rubik::Goal] +20 adım ile devam edilsin mi?", default: "n")
160
- break unless ["y", "yes", "evet"].include?(ans)
161
-
162
- ctx.out.puts " [Rubik::Goal] Adım bütçesi +20 artırılıyor ve hedef sürdürülüyor..."
178
+
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
185
+
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..."
163
246
  goal_state.extend!(goal_state.max_steps + 20)
164
- res = orchestrator.continue_loop(20)
247
+ res = orchestrator.continue_loop(20, auto: true)
248
+ next
165
249
  end
166
- rescue Interrupt
167
- # ^C hedefi 'running' bırakmasın; eşlenmiş kayıt otursun. Interrupt
168
- # yeniden yükselir — kullanıcı mesajını execute_command basar.
169
- goal_state.stop!("Interrupted by user (^C)")
170
- finalize_ledger.call(:stopped)
171
- record_goal_pair.call("[Goal attempt interrupted after #{goal_state.step_count} steps]")
172
- raise
173
- ensure
174
- ctx.active_loop = nil
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)
175
260
  end
176
- if res[:ok]
177
- active = goal_active_subagent_count(ctx)
178
- if active.positive?
179
- # Defense behind the loop-level goal guard (GoalWait): a
180
- # success terminal escaping with live subagents means the
181
- # pipeline is mid-flight — an honest stop beats a false
182
- # "Goal Achieved" (field incident 2026-10-05).
183
- reason = "Goal loop concluded with #{active} active subagent(s) still running — not marked complete"
184
- goal_state.stop!(reason)
185
- finalize_ledger.call(:stopped)
186
- ctx.out.puts "\n⚠️ [Rubik::Goal] #{reason}."
187
- record_goal_pair.call("[Goal attempt ended after #{goal_state.step_count} steps: #{reason}]", tokens: res[:tokens])
188
- else
189
- goal_state.complete!(res[:output].to_s, tokens: res[:tokens])
190
- finalize_ledger.call(:completed)
191
- ctx.out.puts "\n🎉 Goal Achieved in #{res[:steps]} steps!"
192
- ctx.out.puts res[:output]
193
- # Keep the bang syntax out of the LLM context (Plan 53 D-8): the
194
- # session recorder already stores the literal "!goal ..." for replay.
195
- record_goal_pair.call(res[:output].to_s, streamed: res[:streamed] == true, tokens: res[:tokens])
196
-
197
- ctx.out.puts "\nTip: Use `!diff` to review changes and `!apply` to commit to disk." if ctx.buffer_manager.dirty?
198
- 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)
199
280
  else
200
- # Başarısızlık eşlemesi: kayıtçı '!goal ...' user_turn'unu komut
201
- # girişinde çoktan yazdı — assistant_response olmadan session
202
- # restore EŞLEŞMEMİŞ bir 'Goal: ...' turu hidrate ederdi.
203
- # Adım sayısı: loop'un truncation hatası :steps'siz döner —
204
- # GoalState monotonik sayacı otoriter yedek.
205
- sanitized = Rubik::Platform.safe_utf8(res[:error].to_s)
206
- steps_used = res[:steps] || goal_state.step_count
207
- goal_state.fail!(sanitized, tokens: res[:tokens])
208
- finalize_ledger.call(:failed)
209
- ctx.out.puts "\n❌ Goal could not be completed: #{sanitized}"
210
- record_goal_pair.call("[Goal attempt ended after #{steps_used} steps: #{sanitized}]", tokens: res[:tokens])
281
+ fail_goal.call(e.message)
282
+ 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])
303
+ else
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?
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])
320
+ end
321
+ end
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])
211
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!
212
528
  end
213
529
  end
214
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
+
215
539
  # Defense-in-depth behind the loop-level goal guard: if a success
216
540
  # terminal escapes GoalWait with live subagent units, the goal must
217
541
  # stop honestly instead of celebrating a false completion. Fails
@@ -56,6 +56,11 @@ module Rubik
56
56
  rescue Interrupt
57
57
  Rubik::REPL::InterruptHandler.restore_console_after_interrupt
58
58
  @output.puts "\n⚠️ [Rubik] Komut kullanıcı tarafından iptal edildi (^C)."
59
+ ensure
60
+ # !goal dahil lifecycle komutları tracked dal ile aynı spinner
61
+ # garantisini taşır (field incident 2026-10-12: !goal'ten kaçan
62
+ # ClientError'da öksüz spinner terminali kilitli gösterdi).
63
+ kill_command_spinner!
59
64
  end
60
65
  else
61
66
  captured = StringIO.new
@@ -70,7 +75,7 @@ module Rubik
70
75
  @output.puts "\n⚠️ [Rubik] Komut kullanıcı tarafından iptal edildi (^C)."
71
76
  ensure
72
77
  # A command (!plan/!goal) may leave a live spinner if it died mid-wait.
73
- @ui_renderer&.spinner&.kill
78
+ kill_command_spinner!
74
79
  end
75
80
  output_text = captured.string.strip
76
81
  if !cmd_interrupted && !output_text.empty?
@@ -297,11 +302,7 @@ module Rubik
297
302
  rescue StandardError => e
298
303
  warn "[rubik] markdown tail flush failed: #{e.class}: #{e.message}"
299
304
  end
300
- begin
301
- renderer&.spinner&.kill
302
- rescue StandardError => e
303
- warn "[rubik] spinner cleanup failed: #{e.class}: #{e.message}"
304
- end
305
+ kill_spinner!(renderer&.spinner)
305
306
  flush_subagent_notices! if respond_to?(:flush_subagent_notices!)
306
307
  # Session review B3: notices stranded in the ended loop's inbox never
307
308
  # reached the model channel and owed no wake — an idle maestro learned
@@ -318,6 +319,21 @@ module Rubik
318
319
  end
319
320
  reap_dead_kernel_units! if respond_to?(:reap_dead_kernel_units!)
320
321
  end
322
+
323
+ # Shared spinner teardown. Cleanup must never mask the in-flight
324
+ # exception (AGENTS.md §5): a spinner whose tick thread died holding
325
+ # the mutex raises ThreadError from synchronize — report it to stderr
326
+ # and continue so the real failure surfaces.
327
+ def kill_spinner!(spinner)
328
+ spinner&.kill
329
+ rescue StandardError => e
330
+ warn "[rubik] spinner cleanup failed: #{e.class}: #{e.message}"
331
+ end
332
+
333
+ # Command-dispatch spinner cleanup (both branches).
334
+ def kill_command_spinner!
335
+ kill_spinner!(@ui_renderer&.spinner)
336
+ end
321
337
  end
322
338
  end
323
339
  end
@@ -20,6 +20,11 @@ module Rubik
20
20
  ASSIGNMENT_KEY_REGEX = /\b(password|passwd|api_key|secret)(\s*[:=]\s*)(['"][^'"]*['"]|[^\s,;'"]+)/i
21
21
  ASSIGNMENT_TOKEN_REGEX = /\b(token)(\s*[:=]\s*)(['"][^'"]*['"]|[^\s,;'"]+)/i
22
22
 
23
+ # PERF: Pre-compiled constants to avoid re-allocation inside hot loops
24
+ URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
25
+ URL_MASK = "\\1#{MASK_STRING}@".freeze
26
+ QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
27
+
23
28
  # Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
24
29
  # aile): sır görünümlü anahtarların değerleri desen eşleşmese bile
25
30
  # maskelenir; sayaç anahtarları (tokens, max_tokens) istisnadır.
@@ -33,10 +38,17 @@ module Rubik
33
38
  # scrub: redact ham geçmişe (tool çıktısı, prompt) dokunur — geçersiz
34
39
  # UTF-8 gsub!'ı ArgumentError ile düşürür; total olmak maskenin
35
40
  # kendisi bir arıza kaynağı olmasın demektir.
36
- result = text.dup.scrub
41
+ result = text.valid_encoding? ? text.dup : text.scrub
42
+
43
+ # PERF: kısa devre — sır tetikleyicisi yoksa 11 gsub turu atlanır.
44
+ # Tetik testi scrub'lanmış sonuç üstünde yapılır: match? ham geçersiz
45
+ # UTF-8'de ArgumentError düşürür; scrub sonrası metin hep geçerlidir
46
+ # ve desenler zaten bu metne uygulanıyordu, dolayısıyla eşdeğerdir.
47
+ return result unless result.match?(QUICK_CHECK_REGEX)
48
+
37
49
  PATTERNS.each do |pattern|
38
- if pattern == %r{(https?://[^:]+:)([^@]+)@}
39
- result.gsub!(pattern, "\\1#{MASK_STRING}@")
50
+ if pattern == URL_PATTERN
51
+ result.gsub!(pattern, URL_MASK)
40
52
  else
41
53
  result.gsub!(pattern, MASK_STRING)
42
54
  end
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.2"
4
+ VERSION = "0.1.4"
5
5
  end