rubik 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +125 -1
  4. data/lib/rubik/ast/cache_store.rb +33 -0
  5. data/lib/rubik/ast/graph.rb +2 -1
  6. data/lib/rubik/benchmark/runner.rb +14 -0
  7. data/lib/rubik/benchmark/vakanuvis_adoption.rb +439 -0
  8. data/lib/rubik/config/defaults.rb +7 -1
  9. data/lib/rubik/log.rb +4 -3
  10. data/lib/rubik/orchestrator/budget_manager.rb +2 -0
  11. data/lib/rubik/orchestrator/loop.rb +1 -0
  12. data/lib/rubik/orchestrator/shell_deps.rb +4 -0
  13. data/lib/rubik/orchestrator/step_executor.rb +3 -3
  14. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +31 -11
  15. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -0
  16. data/lib/rubik/prompt/doctrine.rb +10 -15
  17. data/lib/rubik/prompt/emergency_compactor.rb +7 -3
  18. data/lib/rubik/prompt/tool_teachings.rb +29 -35
  19. data/lib/rubik/repl/commands/agent_context.rb +1 -0
  20. data/lib/rubik/repl/commands/agent_execution.rb +7 -0
  21. data/lib/rubik/repl/commands/system.rb +2 -0
  22. data/lib/rubik/repl/input_execution.rb +5 -1
  23. data/lib/rubik/repl/turn_recorder.rb +3 -0
  24. data/lib/rubik/security/redactor.rb +13 -9
  25. data/lib/rubik/storage/vakanuvis/annal_writer.rb +51 -4
  26. data/lib/rubik/storage/vakanuvis/distill_gate.rb +14 -2
  27. data/lib/rubik/storage/vakanuvis/gate.rb +23 -15
  28. data/lib/rubik/storage/vakanuvis/harness.rb +149 -121
  29. data/lib/rubik/storage/vakanuvis/job.rb +25 -0
  30. data/lib/rubik/storage/vakanuvis/orientation.rb +277 -0
  31. data/lib/rubik/storage/vakanuvis/service.rb +5 -1
  32. data/lib/rubik/storage/vakanuvis.rb +20 -1
  33. data/lib/rubik/tools/builtins/vakanuvis.rb +13 -15
  34. data/lib/rubik/tools/truncator.rb +53 -0
  35. data/lib/rubik/version.rb +1 -1
  36. metadata +4 -1
@@ -5,12 +5,11 @@ require "monitor"
5
5
  module Rubik
6
6
  module Storage
7
7
  module Vakanuvis
8
- # Düzlem 1 — Deterministik Çekirdek: tek worker thread + sınırlı
9
- # FIFO kuyruk. Olay kapıları mevcut bus abonelikleridir (subagent
10
- # tamamlanma + goal bitişi — K3); NE ZAMAN, NEREYE, NASIL yazılacağı
11
- # burada kural tabanlıdır. Sessizlik sözleşmesi (§4.3): WAKE_KINDS
12
- # dışında kalır, notice yaymaz — sadece dmesg/stderr. Ömür: ctx
13
- # dispose'u LIFO ile kapatır (worker kill + abonelik geri alımı).
8
+ # Düzlem 1 — Deterministik Çekirdek: tek worker thread + sınırlı FIFO kuyruk.
9
+ # Olay kapıları mevcut bus abonelikleridir (subagent tamamlanma + goal
10
+ # bitişi — K3); NE ZAMAN, NEREYE, NASIL yazılacağı burada kural tabanlıdır.
11
+ # Sessizlik sözleşmesi (§4.3): WAKE_KINDS dışında kalır, notice yaymaz —
12
+ # sadece dmesg/stderr. Ömür: ctx dispose'u LIFO ile kapatır (worker kill + abonelik geri alımı).
14
13
  class Harness
15
14
  STOP = :__vakanuvis_stop__
16
15
  MAX_QUEUE = 200
@@ -18,20 +17,21 @@ module Rubik
18
17
  RECALL_FALLBACK_LIMIT = 3
19
18
  RECALL_BODY_SLICE = 140
20
19
  RECALL_TYPES = ["memory", "wiki"].freeze
21
- # v2 §4-V4: damıtıcı kaynak disiplini — yalnız TAMAMLANMIŞ
22
- # transcript'ler, damıtıcının kendisi Hariç (özyineleme kirliliği:
23
- # canlı depoda transcript satırları 'vakanuvis' etiketi taşır).
20
+ # v2 §4-V4: damıtıcı kaynak disiplini — yalnız TAMAMLANMIŞ transcript'ler,
21
+ # damıtıcının kendisi Hariç (özyineleme kirliliği: canlı depoda transcript satırları 'vakanuvis' etiketi taşır).
24
22
  SOURCE_TRANSCRIPT_LIMIT = 8
25
23
  # Literaller, depolama katmanını orchestrator require'larından
26
24
  # uzak tutar; test ikizleri her ikisini de sabitlere pinler:
27
- # Notices::COMPLETED_EVENT ve GoalState::FINISH_EVENT. TURN_EVENT
28
- # REPL TurnRecorder#emit_turn_finished kapısıdır (root-turn planı
29
- # 2026-10-04).
25
+ # Notices::COMPLETED_EVENT ve GoalState::FINISH_EVENT. TURN_EVENT,
26
+ # REPL TurnRecorder#emit_turn_finished kapısıdır (root-turn planı 2026-10-04).
30
27
  SUBAGENT_EVENT = :subagent_completed
31
28
  GOAL_EVENT = :goal_finished
32
29
  TURN_EVENT = :turn_finished
30
+ # Askı kroniği (tarama kartı 2, 2026-11-01): GoalState::SUSPEND_EVENT — abonelik
31
+ # :goal akışına yazar; worker status=="suspended" ile ayırt eder.
32
+ SUSPEND_EVENT = :goal_suspended
33
33
 
34
- attr_reader :store, :supervisor, :dmesg, :session_slug, :gate
34
+ attr_reader :store, :config, :supervisor, :dmesg, :session_slug, :gate
35
35
 
36
36
  def initialize(store:, config: nil, event_bus: nil, supervisor: nil, dmesg: nil,
37
37
  context: nil, session_slug: nil, max_queue: MAX_QUEUE)
@@ -47,34 +47,27 @@ module Rubik
47
47
  @model = fetch_cfg("subagents.vakanuvis.model", nil)
48
48
  @retention = fetch_cfg("subagents.vakanuvis.retention", "supersede").to_s
49
49
  @distiller_enabled = fetch_cfg("subagents.vakanuvis.distiller", true) != false
50
+ @auto_digest = fetch_cfg("subagents.vakanuvis.auto_digest", true) != false
50
51
  @root_turns = fetch_cfg("subagents.vakanuvis.root_turns", true) != false
51
52
  @max_queue = max_queue.to_i
52
53
  @queue = Thread::Queue.new
53
54
  @monitor = Monitor.new
54
- @seq = 0
55
- @goal_ordinal = 0
56
- @turn_ordinal = 0
57
- @events_since_distill = 0
58
- @dropped = 0
55
+ @seq = @goal_ordinal = @turn_ordinal = @events_since_distill = @dropped = @busy = 0
59
56
  @stopped = false
60
- @busy = 0
61
57
  @worker = nil
58
+ @touched_topics = Set.new
62
59
  # Damıtıcı doğum kapısı: kuyruk + sicil + kirli işaret Gate'de
63
60
  # (kurucu enjeksiyonu — gate Harness'e geri dokunmaz).
64
- @gate = DistillGate::Gate.new(store: @store, supervisor: @supervisor, dmesg: @dmesg,
65
- event_bus: @event_bus, enabled: @distiller_enabled,
66
- max_steps: @max_steps, model: @model, session_slug: @session_slug,
67
- max_digest_jobs: fetch_cfg("subagents.vakanuvis.max_digest_jobs",
68
- DistillGate::Gate::DEFAULT_MAX_DIGEST_JOBS))
61
+ @gate = DistillGate::Gate.new(
62
+ store: @store, supervisor: @supervisor, dmesg: @dmesg, event_bus: @event_bus, enabled: @distiller_enabled, max_steps: @max_steps,
63
+ model: @model, session_slug: @session_slug,
64
+ max_digest_jobs: fetch_cfg("subagents.vakanuvis.max_digest_jobs", DistillGate::Gate::DEFAULT_MAX_DIGEST_JOBS)
65
+ )
69
66
  end
70
67
 
71
- class << self
72
- # Oturum slug'ı: tek süreçte deterministik, süreçler arasında
73
- # ayrışır. Replay testleri sabit slug enjekte eder.
74
- def mint_session_slug
75
- "#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}-#{Process.pid}"
76
- end
77
- end
68
+ # Oturum slug'ı: tek süreçte deterministik, süreçler arasında
69
+ # ayrışır. Replay testleri sabit slug enjekte eder.
70
+ def self.mint_session_slug = "#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}-#{Process.pid}"
78
71
 
79
72
  # ---- yaşam döngüsü -------------------------------------------------
80
73
 
@@ -87,10 +80,9 @@ module Rubik
87
80
 
88
81
  def stopped? = @monitor.synchronize { @stopped }
89
82
 
90
- # Dispose yolu: FIFO'daki her şey (STOP dahil öncesindeki olaylar)
91
- # işlenir, sonra thread sonlanır. Kalan olay yarımda kalmaz; thread
92
- # 2 saniyede durmazsa (yavaş bir flush/spawn içinde) kill gelir ve
93
- # işlenemeyen olay sayısı dmesg'e düşer — sessiz kayıp yok.
83
+ # Dispose yolu: FIFO'daki her şey (STOP dahil öncesindeki olaylar) işlenir,
84
+ # sonra thread sonlanır. Kalan olay yarımda kalmaz; thread 2 saniyede durmazsa
85
+ # (yavaş bir flush/spawn içinde) kill gelir ve işlenemeyen olay sayısı dmesg'e düşer — sessiz kayıp yok.
94
86
  def shutdown
95
87
  @monitor.synchronize do
96
88
  return if @stopped
@@ -99,6 +91,11 @@ module Rubik
99
91
  @busy += 1 # STOP işaretinin de işleniş sayılır
100
92
  @queue.push(STOP)
101
93
  end
94
+ begin
95
+ auto_digest_touched!(spawn: false)
96
+ rescue StandardError => e
97
+ Vakanuvis.log_via(@dmesg, :warn, "shutdown auto-digest failed (#{e.class}: #{e.message}) — worker teardown continues")
98
+ end
102
99
  @worker&.join(2)
103
100
  if @worker&.alive?
104
101
  Vakanuvis.log_via(@dmesg, :warn, "worker did not stop in 2s — killing with #{queue_size} event(s) unprocessed")
@@ -113,8 +110,7 @@ module Rubik
113
110
  !busy?
114
111
  end
115
112
 
116
- # Kuyrukta + uçuştaki olay sayısı (push'ta artar, işlenince/düşürülünce
117
- # azalır) — pop ile işaretleme arasındaki yarış penceresini kapatır.
113
+ # Kuyrukta + uçuştaki olay sayısı (push'ta artar, işlenince/düşürülünce azalır) — pop ile işaretleme arasındaki yarış penceresini kapatır.
118
114
  def busy? = @monitor.synchronize { @busy.positive? }
119
115
 
120
116
  # ---- olay giriş kapıları (kworker thread'inde çağrılır) -----------
@@ -140,10 +136,9 @@ module Rubik
140
136
  # ---- talep düzlemi (maestro aracı buraya delege eder; K7) ---------
141
137
 
142
138
  # Tam deterministik, LLM'siz: Store#search_ranked BM25-lite sırası +
143
- # RECALL_TYPES kısıtı + ilk RECALL_LIMIT kayıt ⇒ aynı depo ⇒ aynı
144
- # digest. type: dizisi (v2 §4-V8) transcript/note gibi arama dışı
145
- # tipleri hiç parse etmez; sıralama politika kararına aittir —
146
- # Store#search'in [type,row] sözleşmesi değişmez.
139
+ # RECALL_TYPES kısıtı + ilk RECALL_LIMIT kayıt ⇒ aynı depo ⇒ aynı digest.
140
+ # type: dizisi (v2 §4-V8) transcript/note gibi arama dışı tipleri hiç parse
141
+ # etmez; sıralama politika kararına aittir — Store#search'in [type,row] sözleşmesi değişmez.
147
142
  def recall(query, types: RECALL_TYPES)
148
143
  q = query.to_s.strip
149
144
  raise ArgumentError, "recall: 'query' is required" if q.empty?
@@ -152,19 +147,29 @@ module Rubik
152
147
  allowed = RECALL_TYPES if allowed.empty?
153
148
  hits = DistillGate.active_recall_hits(@store, query: q, type: allowed).first(RECALL_LIMIT)
154
149
  unless hits.empty?
155
- lines = hits.map { |r| recall_line(r) }
156
150
  header = "Recalled #{hits.size} entit#{hits.size == 1 ? 'y' : 'ies'} for '#{AnnalWriter.one_line(q, 80)}':"
157
- return "#{header}\n#{lines.join("\n")}\nRead a full record with read_file(path: '<uri above>')."
151
+ return "#{header}\n#{hits.map { |r| recall_line(r) }.join("\n")}\nRead a full record with read_file(path: '<uri above>')."
158
152
  end
159
153
 
160
154
  relaxed_digest(q, allowed)
161
155
  end
162
156
 
157
+ def auto_digest? = @auto_digest
158
+
159
+ def touched_topics = @monitor.synchronize { @touched_topics.to_a }
160
+
163
161
  # record tool thread'inden, sweep worker'dan koşar — get-then-put
164
162
  # dansını tek kilide alınca çift satır açma yarışı kapanır (store
165
- # monitoru asla geri çağırmadığı için kilit sırası tekyönlü).
163
+ # monitoru asla geri çağırmadığı için kilit sırası tekyönlü). Kayıt
164
+ # + touched-topic izi TEK kritik bölümde: aradaki pencere, koşan
165
+ # bir drain'in konuyu kaçırmasın (review #346).
166
166
  def record(topic:, body:, links: {}, tags: [])
167
- @monitor.synchronize { MemorySteward.record(@store, topic: topic, body: body, links: links, tags: tags) }
167
+ clean_topic = topic.to_s.strip
168
+ @monitor.synchronize do
169
+ rec = MemorySteward.record(@store, topic: clean_topic, body: body, links: links, tags: tags)
170
+ @touched_topics << clean_topic unless clean_topic.empty?
171
+ rec
172
+ end
168
173
  end
169
174
 
170
175
  def forget(ref:)
@@ -190,8 +195,7 @@ module Rubik
190
195
  def digest_status
191
196
  targets = @gate.job_targets
192
197
  if targets[:queued].empty? && targets[:in_flight].empty?
193
- return "Digest queue: nothing queued, nothing in flight. " \
194
- "recall shows compiled pages; a submitted digest appears here until it compiles."
198
+ return "Digest queue: nothing queued, nothing in flight. recall shows compiled pages; a submitted digest appears here until it compiles."
195
199
  end
196
200
 
197
201
  "Digest queue: #{targets[:queued].size} queued (#{digest_target_summary(targets[:queued])}), " \
@@ -201,23 +205,12 @@ module Rubik
201
205
  # Soğuk-başlangıç yönelimi (issue #99 P2): ilk sorgu sözcük dağı
202
206
  # tahmin etmek zorunda kalmadan hangi konular var? Aktif ve mem.*
203
207
  # bağlı kartlar [ref, row] sırasıyla — deterministik.
204
- def topics
205
- bound = @store.refs.select { |ref, _| Vocabulary.memory_ref?(ref) }
206
- cards = @store.list(type: "memory").select do |s|
207
- s.status == Vocabulary::STATUS_ACTIVE && bound.value?(s.id)
208
- end
209
- if cards.empty?
210
- return "No memory topics recorded yet — teach the chronicler with " \
211
- "vakanuvis(action: 'record', topic: ..., body: ...)."
212
- end
208
+ def topics(limit: nil)
209
+ lines, total = Orientation.topic_lines(@store, limit: limit)
210
+ return "No memory topics recorded yet — teach the chronicler with vakanuvis(action: 'record', topic: ..., body: ...)." if lines.empty?
213
211
 
214
- lines = cards.sort_by { |s| [bound.key(s.id), s.row] }.filter_map do |s|
215
- record = @store.get(type: "memory", id: s.id)
216
- next unless record # list→get arası silme yarışı (review L2)
217
-
218
- "- #{bound.key(s.id)} — #{s.title} — #{first_body_line(record)} — #{@store.uri_for('memory', s.id)}"
219
- end
220
- "Recorded topics (#{lines.size}):\n#{lines.join("\n")}"
212
+ count_label = limit && total > lines.size ? "#{lines.size} of #{total}" : lines.size.to_s
213
+ "Recorded topics (#{count_label}):\n#{lines.join("\n")}"
221
214
  end
222
215
 
223
216
  private
@@ -228,13 +221,12 @@ module Rubik
228
221
  shown.join(", ")
229
222
  end
230
223
 
231
- # v2 §4-V2 + issue #99 P2: AND boşluğu tek tip körlük değil — en
232
- # yakın kısmi eşleşmeler dürüstçe "(fallback)" damgasıyla sunulur
233
- # (gerçek eşleşme değildir), transcript izleri kapsam ipucu olarak
234
- # sayılır. Ne fallback ne transcript izi varsa bugünkü dil/terim
235
- # yönlendirmesi birebir döner (keyword-AND'da en sık neden TR
236
- # sorgu ↔ EN gövde uyuşmazlığıdır; yanlış "hemen yeniden kaydet"
237
- # yönlendirmesi burada kesilir).
224
+ # v2 §4-V2 + issue #99 P2: AND boşluğu tek tip körlük değil — en yakın
225
+ # kısmi eşleşmeler dürüstçe "(fallback)" damgasıyla sunulur (gerçek
226
+ # eşleşme değildir), transcript izleri kapsam ipucu olarak sayılır. Ne
227
+ # fallback ne transcript izi varsa bugünkü dil/terim yönlendirmesi birebir
228
+ # döner (keyword-AND'da en sık neden TR sorgu ↔ EN gövde uyuşmazlığıdır;
229
+ # yanlış "hemen yeniden kaydet" yönlendirmesi burada kesilir).
238
230
  def relaxed_digest(query, allowed)
239
231
  q = query.to_s.strip
240
232
  # Süpersede edilmiş sayılar "en yakın kısmi eşleşme" olarak
@@ -244,9 +236,8 @@ module Rubik
244
236
  .first(RECALL_FALLBACK_LIMIT)
245
237
  transcripts = @store.search(query: q, type: "transcript")
246
238
  if relaxed.empty? && transcripts.empty?
247
- return "No chronicled knowledge matches '#{AnnalWriter.one_line(q, 80)}'. Recall is keyword-AND: retry with " \
248
- "fewer or other-language terms first (e.g. the TR/EN Keywords: lines). If the knowledge is genuinely " \
249
- "missing, record it (vakanuvis action 'record') or queue a digest."
239
+ return "No chronicled knowledge matches '#{AnnalWriter.one_line(q, 80)}'. Recall is keyword-AND: retry with fewer or other-language " \
240
+ "terms first (e.g. the TR/EN Keywords: lines). If the knowledge is genuinely missing, record it (vakanuvis action 'record') or queue a digest."
250
241
  end
251
242
 
252
243
  parts = []
@@ -272,23 +263,17 @@ module Rubik
272
263
  slice.empty? ? "(no body)" : slice
273
264
  end
274
265
 
275
- def recall_line(record)
276
- "- #{record['title']} [#{record['id']}] #{first_body_line(record)} — #{@store.uri_for(record['type'], record['id'])}"
277
- end
266
+ def recall_line(record) = "- #{record['title']} [#{record['id']}] #{first_body_line(record)} — #{@store.uri_for(record['type'], record['id'])}"
278
267
 
268
+ # Abonelik geri alımı dispatcher'a aittir (§1A: subscribe'a verilen context'e
269
+ # unregister inverse'i kendisi bağlar); TURN aboneliği R7 knob'unun arkasındadır.
279
270
  def subscribe_events
280
271
  return unless @event_bus.respond_to?(:subscribe)
281
272
 
282
- # Aboneliğin geri alımı ctx.on_dispose ile aynı nefeste (§1A):
283
- # dispatcher subscribe'a verilen context'e unregister inverse'i
284
- # kendisi bağlar.
285
273
  @event_bus.subscribe(SUBAGENT_EVENT, context: @context) { |p| enqueue_event(:subagent, p) }
286
274
  @event_bus.subscribe(GOAL_EVENT, context: @context) { |p| enqueue_event(:goal, p) }
287
- return unless @root_turns
288
-
289
- # Kök-turn kapısı knob arkasında (R7): kapalıyken abonelik hiç
290
- # kurulmaz, :turn_finished olayı kronikçiye akmaz.
291
- @event_bus.subscribe(TURN_EVENT, context: @context) { |p| enqueue_event(:turn, p) }
275
+ @event_bus.subscribe(SUSPEND_EVENT, context: @context) { |p| enqueue_event(:goal, p) }
276
+ @event_bus.subscribe(TURN_EVENT, context: @context) { |p| enqueue_event(:turn, p) } if @root_turns
292
277
  end
293
278
 
294
279
  # Taşma düşürmesi asla STOP işaretini yutamaz (yutsa worker hiç
@@ -314,13 +299,19 @@ module Rubik
314
299
  break if kind == STOP
315
300
 
316
301
  handle_event(kind, payload)
302
+ rescue StandardError => e
303
+ # Last-ditch net: a raise escaping handle_event's own rescue
304
+ # would kill the worker with queued events unconsumed — and
305
+ # shutdown's alive?-check stays blind to that loss.
306
+ Vakanuvis.log_via(@dmesg, :warn, "chronicler worker event dispatch failed (#{e.class}: #{e.message}) — worker continues")
317
307
  ensure
318
308
  @monitor.synchronize { @busy -= 1 }
319
309
  end
320
310
  end
321
311
 
322
312
  # TranscriptIndexer disiplini: kronikçinin kendi hatası akışı
323
- # asla bozmaz — uyarı düşer, sonraki olay işlenir.
313
+ # asla bozmaz — uyarı düşer, sonraki olay işlenir. Kimlik eki
314
+ # (F7) AnnalWriter.event_context'te.
324
315
  def handle_event(kind, payload)
325
316
  case kind
326
317
  when :subagent then handle_subagent(payload)
@@ -328,27 +319,27 @@ module Rubik
328
319
  when :turn then handle_turn(payload)
329
320
  end
330
321
  rescue StandardError => e
331
- Vakanuvis.log_via(@dmesg, :warn, "event handling failed (#{kind}): #{e.class}: #{e.message}")
322
+ # İlk backtrace karesi (event_loop.rb kuralı): depo hatasını
323
+ # annal/digest hatasından ayırt eder.
324
+ Vakanuvis.log_via(@dmesg, :warn, "event handling failed (#{kind}#{AnnalWriter.event_context(kind, payload)}): #{e.class}: #{e.message} @ #{(e.backtrace || []).first}")
332
325
  end
333
326
 
334
327
  def handle_subagent(payload)
335
328
  # Önce kronik, sonra hasat; iki düzlemin hataları birbirini
336
329
  # engellemez. Annal yazılamasa da digest tamamlanır/geri kuyruğa alınır.
337
330
  seq = next_seq
338
- line = AnnalWriter.subagent_line(seq, payload)
339
331
  begin
340
- append_session([[event_key(payload), line]])
332
+ append_session([[event_key(payload), AnnalWriter.subagent_line(seq, payload)]])
341
333
  ensure
342
334
  @gate.completion_event(payload)
343
335
  end
344
336
  maintain if flush_due?
345
337
  end
346
338
 
347
- # Kök-turn kroniği: ordinal session-slug süreç-başına mint
348
- # edildiğinden harness-lokal sayaç yeterli (yeni süreç = yeni
349
- # session annal; goal sırasındaki mağazadan-türetme burada
350
- # gereksiz). Turn olayı flush sayacına sayılır ama koşulsuz
351
- # maintain tetiklemez — turn sıklığı yüksek (R8).
339
+ # Kök-turn kroniği: ordinal session-slug süreç-başına mint edildiğinden
340
+ # harness-lokal sayaç yeterli (yeni süreç = yeni session annal; goal
341
+ # sırasındaki mağazadan-türetme burada gereksiz). Turn olayı flush
342
+ # sayacına sayılır ama koşulsuz maintain tetiklemez — turn sıklığı yüksek (R8).
352
343
  def handle_turn(payload)
353
344
  ordinal = @monitor.synchronize { @turn_ordinal += 1 }
354
345
  seq = next_seq
@@ -357,35 +348,35 @@ module Rubik
357
348
  end
358
349
 
359
350
  def handle_goal(payload)
351
+ if payload[:status].to_s == "suspended" # askı kroniği: satır düşer, mint/upsert/maintain yok (kart 2)
352
+ seq = next_seq # anahtar hedef kimliği — sıra peek'i değil (issue #350)
353
+ return append_session(AnnalWriter.suspend_line(seq, payload))
354
+ end
360
355
  ordinal = next_goal_ordinal
361
356
  enriched = payload.merge(goal_ordinal: ordinal)
362
357
  seq = next_seq
363
- goal_body = [AnnalWriter.goal_header_line(enriched[:goal])] +
364
- AnnalWriter.goal_lines(seq, enriched).map { |_k, l| l }
358
+ goal_body = [AnnalWriter.goal_header_line(enriched[:goal])] + AnnalWriter.goal_lines(seq, enriched).map { |_k, l| l }
365
359
  Vakanuvis.upsert(@store, type: "note", ref: Vocabulary.goal_ref(ordinal),
366
- title: AnnalWriter.goal_title(ordinal, enriched[:goal]),
367
- body: goal_body.join("\n"), tags: Vocabulary::ANNAL_TAGS + [Vocabulary::GOAL_TAG],
368
- links: { "session" => Vocabulary.session_ref(@session_slug) })
360
+ title: AnnalWriter.goal_title(ordinal, enriched[:goal]), body: goal_body.join("\n"),
361
+ tags: Vocabulary::ANNAL_TAGS + [Vocabulary::GOAL_TAG], links: { "session" => Vocabulary.session_ref(@session_slug) })
369
362
  append_session(AnnalWriter.goal_lines(seq, enriched))
370
- maintain
363
+ maintain(goal_end: true, goal_ordinal: ordinal)
371
364
  end
372
365
 
373
366
  # Goal sırası mağazadan türetilir (refs'teki en büyük annal.goal.<n>
374
- # + 1): süreç yeniden başlasa bile aynı depoda numara çakışmaz —
375
- # determinizm depo durumundan, saatlerden değil.
376
- def next_goal_ordinal
377
- @monitor.synchronize do
378
- max_ref = @store.refs.keys.filter_map { |k| Vocabulary.goal_ordinal(k) }.max.to_i
379
- @goal_ordinal = [@goal_ordinal, max_ref].max + 1
380
- end
367
+ # + 1): süreç yeniden başlasa bile aynı depoda numara çakışmaz.
368
+ # Askı artık peek ETMEZ (issue #350) — sıra yalnız bitişte mint edilir.
369
+ def next_goal_ordinal = @goal_ordinal = peek_goal_ordinal
370
+
371
+ def peek_goal_ordinal
372
+ @monitor.synchronize { [@goal_ordinal, @store.refs.keys.filter_map { |k| Vocabulary.goal_ordinal(k) }.max.to_i].max + 1 }
381
373
  end
382
374
 
383
375
  # Flush sayacı (R8): subagent ve turn olayları aynı ölçüde sayılır;
384
376
  # flush_events eşiği aşılınca true döner ve sayaç sıfırlanır.
385
377
  def flush_due?
386
378
  @monitor.synchronize do
387
- @events_since_distill += 1
388
- crossed = @events_since_distill >= @flush_events
379
+ crossed = (@events_since_distill += 1) >= @flush_events
389
380
  @events_since_distill = 0 if crossed
390
381
  crossed
391
382
  end
@@ -393,16 +384,59 @@ module Rubik
393
384
 
394
385
  # Flush bakımı (yalnızca flush noktalarında — flush_events eşiği
395
386
  # aşımı veya goal bitişi): kaçak memory süpürme (K9) + wiki home
396
- # yeniden hesap + damıtıcı doğum kararı. v2 §4-V5: toplu doğum
397
- # yalnızca kirli işaretle — kalıcı içerik değişmediyse (yalnızca
398
- # turn satırları biriktiyse) damıtıcı yakılmaz.
399
- def maintain
387
+ # yeniden hesap + dokunulan hafıza konularının otomatik damıtımı +
388
+ # damıtıcı doğum kararı. v2 §4-V5: toplu doğum yalnızca kirli işaretle;
389
+ # hedefli damıtım TÜM konuları kuyrukladıysa toplu doğum atlanır — kayıp/red varsa batch telafi eder.
390
+ def maintain(goal_end: false, goal_ordinal: nil)
400
391
  # Sweep record() ile aynı kilide koşar (tek-yazar disiplini):
401
392
  # get-then-put dansı çift satır açamaz.
402
393
  swept = @monitor.synchronize { MemorySteward.sweep_rogue_memory(@store) }
403
394
  Vakanuvis.log_via(@dmesg, :info, "steward swept #{swept} rogue memory row(s)") if swept.positive?
404
395
  WikiSkeleton.rebuild_home(@store)
405
- @gate.flush_tick
396
+ digested = auto_digest_touched!(spawn: !stopped?, goal_ordinal: goal_ordinal) if goal_end
397
+ @gate.flush_tick unless stopped? || digested
398
+ end
399
+
400
+ # Otomatik damıtım kapısı (issue #341): hedef tamamlanmasında veya
401
+ # kapanışta dokunulan konuları hedeflenmiş wiki derlemesi için kuyruklar;
402
+ # spawn=false (kapanış) zombi LLM doğumunu engeller. Konu-başı yalıtım
403
+ # (review #346): tek başarısız/red'li submit kalan konuları kaybettirmez —
404
+ # kayıplar @touched_topics'e geri alınır, bir sonraki flush noktası dener.
405
+ def auto_digest_touched!(spawn: true, goal_ordinal: nil)
406
+ return false unless @auto_digest && @distiller_enabled
407
+
408
+ spawn_allowed = spawn && !stopped?
409
+ topics = @monitor.synchronize { @touched_topics.to_a.tap { @touched_topics.clear } }
410
+ return false if topics.empty?
411
+
412
+ stranded = topics.reject do |topic|
413
+ _receipt, status = @gate.submit_with_status(topic, spawn: spawn_allowed, goal_ordinal: goal_ordinal)
414
+ next true if status == :duplicate && Vakanuvis.log_duplicate_digest(@dmesg, topic, goal_ordinal) # F9: sessiz başarı değil
415
+ next true unless status == :full
416
+
417
+ Vakanuvis.log_via(@dmesg, :warn, "auto-digest refused for '#{topic}' (gate capacity full) — kept for the next goal end or shutdown")
418
+ false
419
+ rescue StandardError => e
420
+ Vakanuvis.log_via(@dmesg, :warn, "auto-digest submit failed for '#{topic}' (#{e.class}: #{e.message})")
421
+ false
422
+ end
423
+ @monitor.synchronize { stranded.each { |t| @touched_topics << t } } unless stranded.empty?
424
+ unless spawn_allowed
425
+ queued = topics - stranded
426
+ note = "shutdown: #{queued.size} touched topic(s) queued but not spawned — they will not compile this session: #{queued.join(', ')}"
427
+ note += "; #{stranded.size} refused/failed topic(s) kept for the next flush point (warnings above)" unless stranded.empty?
428
+ Vakanuvis.log_via(@dmesg, :warn, note)
429
+ end
430
+ # Kapalı doğum: kapı :accepted dedi ama damıtıcı hiç doğamaz —
431
+ # uyarı yoksa topic'ler tüm oturum kuyrukta sessizce birikirdi.
432
+ if spawn_allowed && @gate.respond_to?(:spawn_allowed?) && !@gate.spawn_allowed?
433
+ queued = topics - stranded
434
+ unless queued.empty?
435
+ note = "auto-digest queued #{queued.size} touched topic(s) but no distiller can run (spawn disabled)"
436
+ Vakanuvis.log_via(@dmesg, :warn, "#{note} — they will not compile this session: #{queued.join(', ')}")
437
+ end
438
+ end
439
+ stranded.empty?
406
440
  end
407
441
 
408
442
  def append_session(lines)
@@ -414,18 +448,12 @@ module Rubik
414
448
  @dropped = 0
415
449
  end
416
450
  end
417
- AnnalWriter.append(@store, ref: Vocabulary.session_ref(@session_slug),
418
- title: AnnalWriter.session_title(@session_slug),
419
- lines: lines + overflow.to_a)
451
+ AnnalWriter.append(@store, ref: Vocabulary.session_ref(@session_slug), title: AnnalWriter.session_title(@session_slug), lines: lines + overflow.to_a)
420
452
  end
421
453
 
422
454
  def next_seq = @monitor.synchronize { @seq += 1 }
423
455
 
424
- def event_key(payload)
425
- sid = payload[:storage_id].to_s
426
- sid = payload[:id].to_s if sid.empty?
427
- "subagent.#{sid}.#{payload[:state]}"
428
- end
456
+ def event_key(payload) = "subagent.#{payload[:storage_id].to_s.empty? ? payload[:id] : payload[:storage_id]}.#{payload[:state]}"
429
457
 
430
458
  def fetch_cfg(key, default)
431
459
  @config.respond_to?(:fetch) ? @config.fetch(key, default) : default
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Storage
5
+ module Vakanuvis
6
+ module DistillGate
7
+ # Digest iş sözleşmesinin tek evi (tarama kartı 1, 2026-11-01 — defer #9
8
+ # kapanışı): kuyrukta iki ayrı Hash literal mint ediliyor, tüketiciler
9
+ # dört anahtarı [] ile okuyor ve öksüz :goal_id hiçbir mint sitesinde
10
+ # yazılmıyordu. Job bu şekli tiper: Struct#[] sembol okumasını korur
11
+ # (mevcut okuma siteleri değişmez), keyword_init mint sitelerini
12
+ # sözlüğe sabitler, var olmayan anahtar imkânsızlaşır.
13
+ #
14
+ # - query: damıtılacak konu sorgusu (toplu işlerde nil)
15
+ # - target: hedef wiki ref (Vocabulary.wiki_ref) — nil ⇒ toplu derleme;
16
+ # nil-target ayırt etme sözleşmesi değişmez, batch onun açık bayrağıdır
17
+ # - transcript_uris: kaynak disiplinine uyan tamamlanmış transcript URI'leri
18
+ # - goal_ordinal: goal-end auto-digest taşıyıcısı (Q2, 2026-11-01) — brief
19
+ # goal kaydını (annal.goal.<n> note'u) kaynak listesine ekler;
20
+ # kapanış ve toplu işlerde nil, satır brief'e hiç girmez
21
+ Job = Struct.new(:query, :target, :transcript_uris, :batch, :goal_ordinal, keyword_init: true)
22
+ end
23
+ end
24
+ end
25
+ end