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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -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/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/agent_execution.rb +405 -81
- data/lib/rubik/repl/input_execution.rb +22 -6
- data/lib/rubik/security/redactor.rb +15 -3
- data/lib/rubik/version.rb +1 -1
- metadata +4 -1
|
@@ -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,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:
|
|
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 =
|
|
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ı
|
|
176
|
+
# aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
|
|
147
177
|
ctx.apply_staged_prune_plan!
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ==
|
|
39
|
-
result.gsub!(pattern,
|
|
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