rubik 0.4.2 → 0.5.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.
@@ -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,18 @@ 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
33
30
 
34
- attr_reader :store, :supervisor, :dmesg, :session_slug, :gate
31
+ attr_reader :store, :config, :supervisor, :dmesg, :session_slug, :gate
35
32
 
36
33
  def initialize(store:, config: nil, event_bus: nil, supervisor: nil, dmesg: nil,
37
34
  context: nil, session_slug: nil, max_queue: MAX_QUEUE)
@@ -47,34 +44,27 @@ module Rubik
47
44
  @model = fetch_cfg("subagents.vakanuvis.model", nil)
48
45
  @retention = fetch_cfg("subagents.vakanuvis.retention", "supersede").to_s
49
46
  @distiller_enabled = fetch_cfg("subagents.vakanuvis.distiller", true) != false
47
+ @auto_digest = fetch_cfg("subagents.vakanuvis.auto_digest", true) != false
50
48
  @root_turns = fetch_cfg("subagents.vakanuvis.root_turns", true) != false
51
49
  @max_queue = max_queue.to_i
52
50
  @queue = Thread::Queue.new
53
51
  @monitor = Monitor.new
54
- @seq = 0
55
- @goal_ordinal = 0
56
- @turn_ordinal = 0
57
- @events_since_distill = 0
58
- @dropped = 0
52
+ @seq = @goal_ordinal = @turn_ordinal = @events_since_distill = @dropped = @busy = 0
59
53
  @stopped = false
60
- @busy = 0
61
54
  @worker = nil
55
+ @touched_topics = Set.new
62
56
  # Damıtıcı doğum kapısı: kuyruk + sicil + kirli işaret Gate'de
63
57
  # (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))
58
+ @gate = DistillGate::Gate.new(
59
+ store: @store, supervisor: @supervisor, dmesg: @dmesg, event_bus: @event_bus, enabled: @distiller_enabled, max_steps: @max_steps,
60
+ model: @model, session_slug: @session_slug,
61
+ max_digest_jobs: fetch_cfg("subagents.vakanuvis.max_digest_jobs", DistillGate::Gate::DEFAULT_MAX_DIGEST_JOBS)
62
+ )
69
63
  end
70
64
 
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
65
+ # Oturum slug'ı: tek süreçte deterministik, süreçler arasında
66
+ # ayrışır. Replay testleri sabit slug enjekte eder.
67
+ def self.mint_session_slug = "#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}-#{Process.pid}"
78
68
 
79
69
  # ---- yaşam döngüsü -------------------------------------------------
80
70
 
@@ -87,10 +77,9 @@ module Rubik
87
77
 
88
78
  def stopped? = @monitor.synchronize { @stopped }
89
79
 
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.
80
+ # Dispose yolu: FIFO'daki her şey (STOP dahil öncesindeki olaylar) işlenir,
81
+ # sonra thread sonlanır. Kalan olay yarımda kalmaz; thread 2 saniyede durmazsa
82
+ # (yavaş bir flush/spawn içinde) kill gelir ve işlenemeyen olay sayısı dmesg'e düşer — sessiz kayıp yok.
94
83
  def shutdown
95
84
  @monitor.synchronize do
96
85
  return if @stopped
@@ -99,6 +88,11 @@ module Rubik
99
88
  @busy += 1 # STOP işaretinin de işleniş sayılır
100
89
  @queue.push(STOP)
101
90
  end
91
+ begin
92
+ auto_digest_touched!(spawn: false)
93
+ rescue StandardError => e
94
+ Vakanuvis.log_via(@dmesg, :warn, "shutdown auto-digest failed (#{e.class}: #{e.message}) — worker teardown continues")
95
+ end
102
96
  @worker&.join(2)
103
97
  if @worker&.alive?
104
98
  Vakanuvis.log_via(@dmesg, :warn, "worker did not stop in 2s — killing with #{queue_size} event(s) unprocessed")
@@ -113,8 +107,7 @@ module Rubik
113
107
  !busy?
114
108
  end
115
109
 
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.
110
+ # 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
111
  def busy? = @monitor.synchronize { @busy.positive? }
119
112
 
120
113
  # ---- olay giriş kapıları (kworker thread'inde çağrılır) -----------
@@ -140,10 +133,9 @@ module Rubik
140
133
  # ---- talep düzlemi (maestro aracı buraya delege eder; K7) ---------
141
134
 
142
135
  # 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.
136
+ # RECALL_TYPES kısıtı + ilk RECALL_LIMIT kayıt ⇒ aynı depo ⇒ aynı digest.
137
+ # type: dizisi (v2 §4-V8) transcript/note gibi arama dışı tipleri hiç parse
138
+ # etmez; sıralama politika kararına aittir — Store#search'in [type,row] sözleşmesi değişmez.
147
139
  def recall(query, types: RECALL_TYPES)
148
140
  q = query.to_s.strip
149
141
  raise ArgumentError, "recall: 'query' is required" if q.empty?
@@ -152,19 +144,29 @@ module Rubik
152
144
  allowed = RECALL_TYPES if allowed.empty?
153
145
  hits = DistillGate.active_recall_hits(@store, query: q, type: allowed).first(RECALL_LIMIT)
154
146
  unless hits.empty?
155
- lines = hits.map { |r| recall_line(r) }
156
147
  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>')."
148
+ return "#{header}\n#{hits.map { |r| recall_line(r) }.join("\n")}\nRead a full record with read_file(path: '<uri above>')."
158
149
  end
159
150
 
160
151
  relaxed_digest(q, allowed)
161
152
  end
162
153
 
154
+ def auto_digest? = @auto_digest
155
+
156
+ def touched_topics = @monitor.synchronize { @touched_topics.to_a }
157
+
163
158
  # record tool thread'inden, sweep worker'dan koşar — get-then-put
164
159
  # 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ü).
160
+ # monitoru asla geri çağırmadığı için kilit sırası tekyönlü). Kayıt
161
+ # + touched-topic izi TEK kritik bölümde: aradaki pencere, koşan
162
+ # bir drain'in konuyu kaçırmasın (review #346).
166
163
  def record(topic:, body:, links: {}, tags: [])
167
- @monitor.synchronize { MemorySteward.record(@store, topic: topic, body: body, links: links, tags: tags) }
164
+ clean_topic = topic.to_s.strip
165
+ @monitor.synchronize do
166
+ rec = MemorySteward.record(@store, topic: clean_topic, body: body, links: links, tags: tags)
167
+ @touched_topics << clean_topic unless clean_topic.empty?
168
+ rec
169
+ end
168
170
  end
169
171
 
170
172
  def forget(ref:)
@@ -190,8 +192,7 @@ module Rubik
190
192
  def digest_status
191
193
  targets = @gate.job_targets
192
194
  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."
195
+ return "Digest queue: nothing queued, nothing in flight. recall shows compiled pages; a submitted digest appears here until it compiles."
195
196
  end
196
197
 
197
198
  "Digest queue: #{targets[:queued].size} queued (#{digest_target_summary(targets[:queued])}), " \
@@ -201,23 +202,12 @@ module Rubik
201
202
  # Soğuk-başlangıç yönelimi (issue #99 P2): ilk sorgu sözcük dağı
202
203
  # tahmin etmek zorunda kalmadan hangi konular var? Aktif ve mem.*
203
204
  # 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
205
+ def topics(limit: nil)
206
+ lines, total = Orientation.topic_lines(@store, limit: limit)
207
+ return "No memory topics recorded yet — teach the chronicler with vakanuvis(action: 'record', topic: ..., body: ...)." if lines.empty?
213
208
 
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")}"
209
+ count_label = limit && total > lines.size ? "#{lines.size} of #{total}" : lines.size.to_s
210
+ "Recorded topics (#{count_label}):\n#{lines.join("\n")}"
221
211
  end
222
212
 
223
213
  private
@@ -228,13 +218,12 @@ module Rubik
228
218
  shown.join(", ")
229
219
  end
230
220
 
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).
221
+ # v2 §4-V2 + issue #99 P2: AND boşluğu tek tip körlük değil — en yakın
222
+ # kısmi eşleşmeler dürüstçe "(fallback)" damgasıyla sunulur (gerçek
223
+ # eşleşme değildir), transcript izleri kapsam ipucu olarak sayılır. Ne
224
+ # fallback ne transcript izi varsa bugünkü dil/terim yönlendirmesi birebir
225
+ # döner (keyword-AND'da en sık neden TR sorgu ↔ EN gövde uyuşmazlığıdır;
226
+ # yanlış "hemen yeniden kaydet" yönlendirmesi burada kesilir).
238
227
  def relaxed_digest(query, allowed)
239
228
  q = query.to_s.strip
240
229
  # Süpersede edilmiş sayılar "en yakın kısmi eşleşme" olarak
@@ -244,9 +233,8 @@ module Rubik
244
233
  .first(RECALL_FALLBACK_LIMIT)
245
234
  transcripts = @store.search(query: q, type: "transcript")
246
235
  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."
236
+ return "No chronicled knowledge matches '#{AnnalWriter.one_line(q, 80)}'. Recall is keyword-AND: retry with fewer or other-language " \
237
+ "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
238
  end
251
239
 
252
240
  parts = []
@@ -272,23 +260,16 @@ module Rubik
272
260
  slice.empty? ? "(no body)" : slice
273
261
  end
274
262
 
275
- def recall_line(record)
276
- "- #{record['title']} [#{record['id']}] #{first_body_line(record)} — #{@store.uri_for(record['type'], record['id'])}"
277
- end
263
+ def recall_line(record) = "- #{record['title']} [#{record['id']}] #{first_body_line(record)} — #{@store.uri_for(record['type'], record['id'])}"
278
264
 
265
+ # Abonelik geri alımı dispatcher'a aittir (§1A: subscribe'a verilen context'e
266
+ # unregister inverse'i kendisi bağlar); TURN aboneliği R7 knob'unun arkasındadır.
279
267
  def subscribe_events
280
268
  return unless @event_bus.respond_to?(:subscribe)
281
269
 
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
270
  @event_bus.subscribe(SUBAGENT_EVENT, context: @context) { |p| enqueue_event(:subagent, p) }
286
271
  @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) }
272
+ @event_bus.subscribe(TURN_EVENT, context: @context) { |p| enqueue_event(:turn, p) } if @root_turns
292
273
  end
293
274
 
294
275
  # Taşma düşürmesi asla STOP işaretini yutamaz (yutsa worker hiç
@@ -335,20 +316,18 @@ module Rubik
335
316
  # Önce kronik, sonra hasat; iki düzlemin hataları birbirini
336
317
  # engellemez. Annal yazılamasa da digest tamamlanır/geri kuyruğa alınır.
337
318
  seq = next_seq
338
- line = AnnalWriter.subagent_line(seq, payload)
339
319
  begin
340
- append_session([[event_key(payload), line]])
320
+ append_session([[event_key(payload), AnnalWriter.subagent_line(seq, payload)]])
341
321
  ensure
342
322
  @gate.completion_event(payload)
343
323
  end
344
324
  maintain if flush_due?
345
325
  end
346
326
 
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).
327
+ # Kök-turn kroniği: ordinal session-slug süreç-başına mint edildiğinden
328
+ # harness-lokal sayaç yeterli (yeni süreç = yeni session annal; goal
329
+ # sırasındaki mağazadan-türetme burada gereksiz). Turn olayı flush
330
+ # sayacına sayılır ama koşulsuz maintain tetiklemez — turn sıklığı yüksek (R8).
352
331
  def handle_turn(payload)
353
332
  ordinal = @monitor.synchronize { @turn_ordinal += 1 }
354
333
  seq = next_seq
@@ -360,14 +339,12 @@ module Rubik
360
339
  ordinal = next_goal_ordinal
361
340
  enriched = payload.merge(goal_ordinal: ordinal)
362
341
  seq = next_seq
363
- goal_body = [AnnalWriter.goal_header_line(enriched[:goal])] +
364
- AnnalWriter.goal_lines(seq, enriched).map { |_k, l| l }
342
+ goal_body = [AnnalWriter.goal_header_line(enriched[:goal])] + AnnalWriter.goal_lines(seq, enriched).map { |_k, l| l }
365
343
  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) })
344
+ title: AnnalWriter.goal_title(ordinal, enriched[:goal]), body: goal_body.join("\n"),
345
+ tags: Vocabulary::ANNAL_TAGS + [Vocabulary::GOAL_TAG], links: { "session" => Vocabulary.session_ref(@session_slug) })
369
346
  append_session(AnnalWriter.goal_lines(seq, enriched))
370
- maintain
347
+ maintain(goal_end: true)
371
348
  end
372
349
 
373
350
  # Goal sırası mağazadan türetilir (refs'teki en büyük annal.goal.<n>
@@ -384,8 +361,7 @@ module Rubik
384
361
  # flush_events eşiği aşılınca true döner ve sayaç sıfırlanır.
385
362
  def flush_due?
386
363
  @monitor.synchronize do
387
- @events_since_distill += 1
388
- crossed = @events_since_distill >= @flush_events
364
+ crossed = (@events_since_distill += 1) >= @flush_events
389
365
  @events_since_distill = 0 if crossed
390
366
  crossed
391
367
  end
@@ -393,16 +369,49 @@ module Rubik
393
369
 
394
370
  # Flush bakımı (yalnızca flush noktalarında — flush_events eşiği
395
371
  # 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
372
+ # yeniden hesap + dokunulan hafıza konularının otomatik damıtımı +
373
+ # damıtıcı doğum kararı. v2 §4-V5: toplu doğum yalnızca kirli işaretle;
374
+ # hedefli damıtım TÜM konuları kuyrukladıysa toplu doğum atlanır — kayıp/red varsa batch telafi eder.
375
+ def maintain(goal_end: false)
400
376
  # Sweep record() ile aynı kilide koşar (tek-yazar disiplini):
401
377
  # get-then-put dansı çift satır açamaz.
402
378
  swept = @monitor.synchronize { MemorySteward.sweep_rogue_memory(@store) }
403
379
  Vakanuvis.log_via(@dmesg, :info, "steward swept #{swept} rogue memory row(s)") if swept.positive?
404
380
  WikiSkeleton.rebuild_home(@store)
405
- @gate.flush_tick
381
+ digested = auto_digest_touched!(spawn: !stopped?) if goal_end
382
+ @gate.flush_tick unless stopped? || digested
383
+ end
384
+
385
+ # Otomatik damıtım kapısı (issue #341): hedef tamamlanmasında veya
386
+ # kapanışta dokunulan konuları hedeflenmiş wiki derlemesi için kuyruklar;
387
+ # spawn=false (kapanış) zombi LLM doğumunu engeller. Konu-başı yalıtım
388
+ # (review #346): tek başarısız/red'li submit kalan konuları kaybettirmez —
389
+ # kayıplar @touched_topics'e geri alınır, bir sonraki flush noktası dener.
390
+ def auto_digest_touched!(spawn: true)
391
+ return false unless @auto_digest && @distiller_enabled
392
+
393
+ spawn_allowed = spawn && !stopped?
394
+ topics = @monitor.synchronize { @touched_topics.to_a.tap { @touched_topics.clear } }
395
+ return false if topics.empty?
396
+
397
+ stranded = topics.reject do |topic|
398
+ _receipt, status = @gate.submit_with_status(topic, spawn: spawn_allowed)
399
+ next true unless status == :full
400
+
401
+ Vakanuvis.log_via(@dmesg, :warn, "auto-digest refused for '#{topic}' (gate capacity full) — kept for the next flush point")
402
+ false
403
+ rescue StandardError => e
404
+ Vakanuvis.log_via(@dmesg, :warn, "auto-digest submit failed for '#{topic}' (#{e.class}: #{e.message})")
405
+ false
406
+ end
407
+ @monitor.synchronize { stranded.each { |t| @touched_topics << t } } unless stranded.empty?
408
+ unless spawn_allowed
409
+ queued = topics - stranded
410
+ note = "shutdown: #{queued.size} touched topic(s) queued but not spawned — they will not compile this session: #{queued.join(', ')}"
411
+ note += "; #{stranded.size} refused/failed topic(s) kept for the next flush point (warnings above)" unless stranded.empty?
412
+ Vakanuvis.log_via(@dmesg, :warn, note)
413
+ end
414
+ stranded.empty?
406
415
  end
407
416
 
408
417
  def append_session(lines)
@@ -414,18 +423,12 @@ module Rubik
414
423
  @dropped = 0
415
424
  end
416
425
  end
417
- AnnalWriter.append(@store, ref: Vocabulary.session_ref(@session_slug),
418
- title: AnnalWriter.session_title(@session_slug),
419
- lines: lines + overflow.to_a)
426
+ AnnalWriter.append(@store, ref: Vocabulary.session_ref(@session_slug), title: AnnalWriter.session_title(@session_slug), lines: lines + overflow.to_a)
420
427
  end
421
428
 
422
429
  def next_seq = @monitor.synchronize { @seq += 1 }
423
430
 
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
431
+ def event_key(payload) = "subagent.#{payload[:storage_id].to_s.empty? ? payload[:id] : payload[:storage_id]}.#{payload[:state]}"
429
432
 
430
433
  def fetch_cfg(key, default)
431
434
  @config.respond_to?(:fetch) ? @config.fetch(key, default) : default