rubik 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3548e6b569bb11a7c6c6349bca95300d93cd375c65860095ff18d362cd289ed
4
- data.tar.gz: 207e5ed22d5180f7b219b546d0322ac2f597703dec1157ec44da48feb899ab1e
3
+ metadata.gz: a799e64cfe31504bbb5c2da2dff00070725c885e04abb896addfc32ed48d3f87
4
+ data.tar.gz: 8dbaabb4fba7e1dc076924b0be25527307700728bc6e21e2f9e842edc0b8987b
5
5
  SHA512:
6
- metadata.gz: a6b485c11a72537a00fbe813889f74b92e18edaa1445ec59bc3834bf6e56c377cc60ac628f059878e6d3fc0a1e27d4f63fa1f6161bac84513af4b3640a37c4b6
7
- data.tar.gz: 228ce20578fc055bde12ffe0883343f21b74a9b499e718c1e953fc40c9e5054d3c21a27f5e3b986890efba08d3ae23ec77a37498fd197e37a3449de5524d0943
6
+ metadata.gz: bd0f68f48ddf4f8b35c4ac625abc97a8e8673162529cb21806b664fa7b5d6c718d45e299f91b9ef4bebf7437371b6282655fff2e02574c7ec16171259c00311f
7
+ data.tar.gz: fa4a485d719537c0bd40d56d32fb97794c9b9de8288bf2f0a0159c5290a02c1d2906071d70e75585dda0b2d677980047a899d94634270b03bc9d13559444955f
data/CHANGELOG.md CHANGED
@@ -10,6 +10,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  Nothing yet.
12
12
 
13
+ ## [0.1.4] - 2026-10-13
14
+
15
+ The autonomy release: `!goal` becomes resumable across transient
16
+ provider outages, and the maestro gets a leaner, more proactive
17
+ contract for driving subagents unattended.
18
+
19
+ ### Added
20
+
21
+ - **Resumable `!goal` (#107):** transient provider transport failures
22
+ (exhausted connect/DNS retries, exhausted 5xx, HTTP 429) suspend the
23
+ goal instead of failing terminally — the loop, its counters and the
24
+ Σ ledger survive. `!goal resume` continues in-process (same Loop
25
+ instance) or cross-process (Σ-stamp rehydration with an honest
26
+ continuity notice); schema-corrupt Σ bodies are refused at
27
+ `Ledger.load` and stale suspend stamps are stripped from rehydrated
28
+ links. Issue #106 Faz 1+2a (Faz 2b deferred).
29
+ - **Lean maestro autonomy:** subagents carry a 4-part final-report
30
+ contract (research/coder), doctrine gains proactive budget guidance
31
+ and predecessor-transcript chaining, `agent.goal_auto_continue_limit`
32
+ grants unattended `!goal` runs up to 20 extra auto-extensions with an
33
+ honest `auto=true` notice, and `!goal --steps N` sizes the step
34
+ budget up front with usage-error validation.
35
+
36
+ ### Changed
37
+
38
+ - **Scanner orders paths via NUL translation:** `order_like_walker`
39
+ keys paths with `String#tr("/", "\0")` instead of `Array#split` —
40
+ order-equivalent with fewer allocations.
41
+
13
42
  ## [0.1.3] - 2026-10-12
14
43
 
15
44
  A robustness release straight from a field incident: the `!goal` loop
@@ -41,11 +41,11 @@ module Rubik
41
41
  DMESG = "dmesg"
42
42
  DISPATCHER = "dispatcher"
43
43
  INIT = "init"
44
- # Live !goal run snapshot (Rubik::Orchestrator::GoalState), provided by
45
- # the goal command and rendered to agents via read_file rubik://goal/state.
44
+ # Live !goal run snapshot (Rubik::Orchestrator::GoalState), provided by the goal command and rendered to agents via read_file rubik://goal/state.
46
45
  GOAL_STATE = "goal_state"
47
46
  # Structured goal ledger (GoalLedger::Ledger), provided by !goal when goal.ledger.enabled; unsatisfied outside a running !goal.
48
47
  GOAL_LEDGER = "goal_ledger"
48
+ GOAL_REGISTRY = "goal_registry" # suspended-goal registry (GoalRegistry, issue #106)
49
49
  # Central rubik://storage store (Rubik::Storage::Store), provided by
50
50
  # Storage::Service at shell boot and resolved by the storage tool /
51
51
  # read_file rubik://storage routes / supervisor transcript indexing.
@@ -27,11 +27,9 @@ module Rubik
27
27
  },
28
28
  "providers" => {},
29
29
  "agent" => {
30
- "max_steps" => 40,
31
- "goal_max_steps" => 100,
32
- "plan_max_steps" => 15,
33
- "warn_at_ratio" => 0.75,
34
- "ask_continue_on_limit" => true, "goal_wait_timeout" => 300
30
+ "max_steps" => 40, "goal_max_steps" => 100, "plan_max_steps" => 15,
31
+ "warn_at_ratio" => 0.75, "ask_continue_on_limit" => true,
32
+ "goal_auto_continue_limit" => 0, "goal_wait_timeout" => 300
35
33
  },
36
34
  # rubik://storage sanal FS-DB (Rubik::Storage): hedef/plan/bellek/wiki/transcript varlıkları
37
35
  # + worktree'yi temiz tutan artifact absorb'u. root "~" içerebilir; project nil ise slug türetilir.
@@ -367,8 +367,11 @@ module Rubik
367
367
  # element-wise compare of the component arrays ("a/x" before "a.txt",
368
368
  # since "a" < "a.txt" at the first component; "lib/deep/core.rb" before
369
369
  # "lib/util.rb" via "deep" < "util").
370
+ # PERF: replacing "/" with NUL byte ("\x00") is equivalent to array component
371
+ # comparison because NUL (0x00) is strictly less than any valid path byte,
372
+ # avoiding intermediate Array allocations per path (~8.4x faster, ~62.5% fewer allocations).
370
373
  def order_like_walker(paths)
371
- paths.sort_by { |p| p.split("/") }
374
+ paths.sort_by { |p| p.tr("/", "\x00") }
372
375
  end
373
376
 
374
377
  def parse_gitignore(dir = @root_dir)
@@ -8,18 +8,36 @@ require_relative "../platform"
8
8
 
9
9
  module Rubik
10
10
  module LLM
11
- class ClientError < StandardError; end
11
+ # Transport failure classification (issue #106): the client raises
12
+ # message-string ClientErrors, so callers cannot read the message to
13
+ # tell a transient transport failure (DNS, connection reset, exhausted
14
+ # 5xx, rate limit) from a permanent one (4xx content/auth/moderation,
15
+ # read timeout, malformed response). The flag is decided AT THE RAISE
16
+ # POINT — precedent: RateLimitError's retry_after: kwarg. Default is
17
+ # permanent (false); only the raise sites that provably saw a
18
+ # transport-class failure opt into transient.
19
+ class ClientError < StandardError
20
+ attr_reader :transient
21
+
22
+ def initialize(message, transient: false)
23
+ super(message)
24
+ @transient = transient == true
25
+ end
26
+
27
+ def transient? = @transient
28
+ end
12
29
 
13
30
  # HTTP 429 (rate limited), carrying the parsed Retry-After hint when the
14
31
  # server advertised one. Subclass of ClientError so every existing
15
32
  # `rescue ClientError` site keeps working; the typed class lets callers
16
33
  # distinguish "provider throttled us" from every other failure without
17
- # parsing message strings.
34
+ # parsing message strings. Rate limiting is transient by nature — the
35
+ # subclass answers transient? true without a kwarg at every raise site.
18
36
  class RateLimitError < ClientError
19
37
  attr_reader :retry_after
20
38
 
21
39
  def initialize(message, retry_after: nil)
22
- super(message)
40
+ super(message, transient: true)
23
41
  @retry_after = retry_after
24
42
  end
25
43
  end
@@ -283,7 +301,13 @@ module Rubik
283
301
  # raw makes every ClientError message binary and the UTF-8 output
284
302
  # layer raises inside the REPL's rescue clause (masking the real
285
303
  # HTTP error as Encoding::CompatibilityError).
286
- raise ClientError, "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}"
304
+ # Exhausted 5xx is transport-class (provider outage) → transient;
305
+ # a 4xx is content/auth/moderation → permanent. One raise point
306
+ # for both, the flag decided by status (issue #106).
307
+ raise ClientError.new(
308
+ "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
309
+ transient: status >= 500
310
+ )
287
311
  end
288
312
 
289
313
  begin
@@ -309,7 +333,10 @@ module Rubik
309
333
  next
310
334
  end
311
335
  end
312
- raise ClientError, "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})"
336
+ raise ClientError.new(
337
+ "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})",
338
+ transient: true
339
+ )
313
340
  rescue StandardError => e
314
341
  raise ClientError, "Network error calling #{uri.host}: #{e.class} #{e.message}"
315
342
  end
@@ -466,7 +493,7 @@ module Rubik
466
493
  # socket manifests exactly like this.
467
494
  clean_retry = !state[:surfaced] && attempts < 2
468
495
  retry if clean_retry
469
- raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
496
+ raise ClientError.new("Network error during stream from #{uri.host}: #{e.message}", transient: true)
470
497
  rescue StandardError => e
471
498
  # Everything else is wrapped once without retrying. That includes
472
499
  # Net::ReadTimeout — same policy as post_json: the server may
@@ -502,7 +529,12 @@ module Rubik
502
529
  # is a Net::ReadAdapter sink, not the bytes (fresh-eye R7).
503
530
  raise rate_limit_error(response, nil, body_chunks.join) if status_code == 429
504
531
 
505
- raise ClientError, "HTTP Request failed with code #{status_code}: #{Rubik::Platform.safe_utf8(body_chunks.join)}"
532
+ # Same status-decided split as the batch path (issue #106):
533
+ # exhausted 5xx → transient, 4xx → permanent.
534
+ raise ClientError.new(
535
+ "HTTP Request failed with code #{status_code}: #{Rubik::Platform.safe_utf8(body_chunks.join)}",
536
+ transient: status_code >= 500
537
+ )
506
538
  end
507
539
 
508
540
  response.read_body do |chunk|
@@ -5,6 +5,8 @@ require "json"
5
5
  require_relative "../../prompt/context_compactor"
6
6
  require_relative "../../prompt/conversation_rows"
7
7
  require_relative "../../uris"
8
+ require_relative "suspend_lifecycle"
9
+ require_relative "xml_render"
8
10
 
9
11
  module Rubik
10
12
  module Orchestrator
@@ -27,6 +29,13 @@ module Rubik
27
29
  class Ledger
28
30
  Result = Struct.new(:status, :reasons, :rev, :uri, keyword_init: true)
29
31
 
32
+ # Askı yaşam döngüsü (issue #106): suspend!/unsuspend! damgaları +
33
+ # restore_from_row! + suspend_meta — instance-method include ile
34
+ # private yardımcılara (row_title, canonical_json, final_result,
35
+ # log, degrade) doğal erişir. Σ XML render yüzeyi XmlRender'da.
36
+ include SuspendLifecycle
37
+ include XmlRender
38
+
30
39
  # A planı üretim şemasını SAHİPLENİR: goal skeleton örnek örneği
31
40
  # üretim yüzeyi olur (frozen; verify alanları optional+allow_null —
32
41
  # re-verification planı doldurur).
@@ -52,6 +61,7 @@ module Rubik
52
61
  @finalized = false
53
62
  @updated_at = nil
54
63
  @last_drift_note = nil
64
+ @suspend_meta = nil
55
65
  @doc = seed_doc(goal_text)
56
66
  end
57
67
 
@@ -82,6 +92,12 @@ module Rubik
82
92
  ledger
83
93
  end
84
94
 
95
+ # Çapraz-süreç resume fabrikası — gövde SuspendLifecycle'ta
96
+ # (satır cap'i disiplini; bozuk/askı-olmayan damga → dürüst nil).
97
+ def load(store:, storage_id:, config: nil, dmesg: nil, event_bus: nil)
98
+ SuspendLifecycle.load(store: store, storage_id: storage_id, config: config, dmesg: dmesg, event_bus: event_bus)
99
+ end
100
+
85
101
  def config_get(config, key, default)
86
102
  return default if config.nil?
87
103
 
@@ -214,58 +230,15 @@ module Rubik
214
230
  @monitor.synchronize { DriftNote.render(snap: deep_dup(@doc)) }
215
231
  end
216
232
 
217
- def to_xml
218
- @monitor.synchronize { render_xml }
219
- end
220
-
221
- # §4.1 render: GoalState#to_xml mold'u — tüm metin xml_escape'ten
222
- # geçer; boş koleksiyonların etiketi HİÇ basılmaz ("yokluk, boşluk
223
- # değil" kuralı); optional attribute'lar (storage_id, verify) yalnız
224
- # set iken basılır.
225
- def render_xml
226
- esc = Rubik::Platform.method(:xml_escape)
227
- attrs = %(rev="#{@doc['rev']}")
228
- attrs += %( storage_id="#{esc.call(@storage_id)}") if @storage_id
229
- attrs += %( updated_at="#{@updated_at.utc.iso8601}") if @updated_at
230
- body = []
231
- body << %( <objective>#{esc.call(@doc['objective'].to_s)}</objective>)
232
- tasks = @doc["tasks"].map do |task|
233
- text = task["title"].to_s
234
- text += " — notes: #{task['notes']}" if task["notes"]
235
- attrs_t = %(id="#{esc.call(task['id'].to_s)}" status="#{esc.call(task['status'].to_s)}")
236
- attrs_t += %( verify="#{esc.call(task['verify'].to_s)}") if task["verify"]
237
- task["title"] || task["notes"] ? %( <task #{attrs_t}>#{esc.call(text)}</task>) : %( <task #{attrs_t} />)
238
- end
239
- body << "<tasks>\n#{tasks.join("\n")}\n </tasks>" unless tasks.empty?
240
- findings = @doc["findings"].map do |f|
241
- %( <finding id="#{esc.call(f['id'].to_s)}" source_step="#{f['source_step']}">#{esc.call(f['text'].to_s)}</finding>)
242
- end
243
- body << "<findings>\n#{findings.join("\n")}\n </findings>" unless findings.empty?
244
- decisions = @doc["decisions"].map do |dec|
245
- attrs_d = %(id="#{esc.call(dec['id'].to_s)}")
246
- attrs_d += %( rationale="#{esc.call(dec['rationale'].to_s)}") if dec["rationale"]
247
- %( <decision #{attrs_d}>#{esc.call(dec['text'].to_s)}</decision>)
248
- end
249
- body << "<decisions>\n#{decisions.join("\n")}\n </decisions>" unless decisions.empty?
250
- questions = @doc["open_questions"].map { |q| %( <question>#{esc.call(q.to_s)}</question>) }
251
- body << "<open_questions>\n#{questions.join("\n")}\n </open_questions>" unless questions.empty?
252
- notes = @doc["environment_notes"].map do |note|
253
- attrs_n = %(id="#{esc.call(note['id'].to_s)}")
254
- attrs_n += %( verify="#{esc.call(note['verify'].to_s)}") if note["verify"]
255
- %( <note #{attrs_n}>#{esc.call(note['text'].to_s)}</note>)
256
- end
257
- body << "<environment_notes>\n#{notes.join("\n")}\n </environment_notes>" unless notes.empty?
258
- <<~XML.strip
259
- <goal_ledger #{attrs}>
260
- #{body.join("\n")}
261
- </goal_ledger>
262
- XML
263
- end
233
+ # (Σ XML render yüzeyi — to_xml / render_xml — XmlRender sayfası.)
264
234
 
265
235
  def snapshot
266
236
  @monitor.synchronize do
267
237
  { rev: @doc["rev"], objective: @doc["objective"], storage_id: @storage_id,
268
- updated_at: @updated_at, finalized: @finalized, tasks: @doc["tasks"].size,
238
+ updated_at: @updated_at, finalized: @finalized,
239
+ suspended: @suspend_meta.is_a?(Hash) && !@finalized,
240
+ suspend_meta: @suspend_meta ? deep_dup(@suspend_meta) : nil,
241
+ tasks: @doc["tasks"].size,
269
242
  findings: @doc["findings"].size, decisions: @doc["decisions"].size,
270
243
  open_questions: @doc["open_questions"].size, environment_notes: @doc["environment_notes"].size }
271
244
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubik
6
+ module Orchestrator
7
+ module GoalLedger
8
+ # Askı yaşam döngüsü (issue #106): Σ'nın suspend/resume yarısı —
9
+ # Ledger'a include edilir (instance-method mixin; Ledger'ın private
10
+ # yardımcılarına ve @monitor'una doğal erişim, satır cap'i disiplini).
11
+ #
12
+ # Suspend damgası, finalize! deseninin birebir aynısıdır: son
13
+ # revizyon satırına satır-adresli put (id korunur, version++, gövde
14
+ # değişmez); yalnızca storage status → "suspended" ve links'e sayaç
15
+ # meta'sı {goal_id, steps, max_steps, extensions, tokens, reason,
16
+ # suspended_at, attempt} (JSON dizesi — normalize_links değerleri
17
+ # dizgeleştirir) yazılır. Askı TERMINAL DEĞİLDİR: @finalized
18
+ # dokunulmaz, sonraki finalize! damganın üzerine yazar. Store yoksa
19
+ # degrade sözleşmesi: tek dürüst warn, bellek-içi resume çalışır,
20
+ # çapraz-süreç resume dürüstçe reddedilir — asla çökmez.
21
+ module SuspendLifecycle
22
+ # Σ çalışma gövdesinin zorunlu koleksiyonları (Ledger.seed_doc moldu).
23
+ SIGMA_COLLECTIONS = ["tasks", "findings", "decisions", "open_questions", "environment_notes"].freeze
24
+
25
+ # Askı damgası meta'sı (load yolunda satırdan, suspend! yolunda
26
+ # parametreden): yukarıdaki şema; yoksa nil.
27
+ attr_reader :suspend_meta
28
+
29
+ def suspend!(meta)
30
+ @monitor.synchronize do
31
+ return nil if @finalized
32
+
33
+ @suspend_meta = deep_dup(meta.to_h.transform_keys(&:to_s))
34
+ if @store && @storage_id
35
+ links = @last_links.merge("suspended" => Rubik::Platform.safe_json_generate(@suspend_meta))
36
+ row = @store.put(type: "goal", id: @storage_id, title: row_title, body: canonical_json,
37
+ tags: ["goal-ledger"], links: links, status: "suspended")
38
+ @storage_id = row["id"]
39
+ else
40
+ degrade_storage_once
41
+ end
42
+ log(:suspended, "goal ledger suspended at rev #{@doc['rev']} (attempt #{@suspend_meta['attempt']})")
43
+ final_result
44
+ end
45
+ rescue StandardError => e
46
+ degrade("suspend stamp", e)
47
+ @monitor.synchronize { final_result }
48
+ end
49
+
50
+ # Damganın temizlenmesi (resume anı): satır tekrar "active" olur —
51
+ # aksi halde koşan bir goal !goal list'te askı görünürdür. version++
52
+ # tek maliyet; ilk kabul zaten yeni satır açar. finalize! ile aynı
53
+ # idempotent guard; bellek-içi meta temizlenir (artık askı yok).
54
+ def unsuspend!
55
+ @monitor.synchronize do
56
+ return nil if @finalized
57
+ return nil unless @store && @storage_id
58
+
59
+ row = @store.put(type: "goal", id: @storage_id, title: row_title, body: canonical_json,
60
+ tags: ["goal-ledger"], links: @last_links, status: "active")
61
+ @storage_id = row["id"]
62
+ @suspend_meta = nil
63
+ log(:unsuspended, "goal ledger resume at rev #{@doc['rev']}")
64
+ final_result
65
+ end
66
+ rescue StandardError => e
67
+ degrade("unsuspend stamp", e)
68
+ @monitor.synchronize { final_result }
69
+ end
70
+
71
+ # Ledger.load'un kurulum adımı: satır + JSON gövdeden çalışma
72
+ # durumunu geri yükler — YAZMA YOKTUR. @last_links zincir için
73
+ # korunur AMA bayat "suspended" damgası düşülür: aksi halde resume
74
+ # sonrası hiç ΔΣ kabulü olmadan gelen bir unsuspend!/finalize! ölü
75
+ # damgayı satır links'ine geri yazar (status "active"/"failed"
76
+ # iken links'te askı meta'sı). Taze damga suspend!'ın kendi
77
+ # merge'ünden gelir — restore'dan değil.
78
+ def restore_from_row!(row:, doc:)
79
+ @monitor.synchronize do
80
+ @doc = deep_dup(doc)
81
+ @storage_id = row["id"]
82
+ @last_links = deep_dup(row["links"].to_h.transform_keys(&:to_s))
83
+ @last_links.delete("suspended")
84
+ @suspend_meta = parse_suspend_meta(row)
85
+ @rejections = 0
86
+ end
87
+ self
88
+ end
89
+
90
+ class << self
91
+ # Çapraz-süreç resume fabrikası: askı damgası taşıyan Σ satırından
92
+ # defteri yeniden kurar (gövde JSON'u → @doc, links → @last_links,
93
+ # damga meta'sı → suspend_meta). YAZMA YOKTUR — load saf
94
+ # kurulumdur; ilk kabul yeni satır açar. Dürüst red: store /
95
+ # storage_id yok, satır yok, status "suspended" değil ya da gövde
96
+ # bozuksa / Σ şeması eksikse nil — resume handler'ı tek cümlelik
97
+ # ret basar. Şema red'i SAHİLENDİĞİNDE olmalı: eksik koleksiyonlu
98
+ # bir gövde geçse resume render'da NoMethodError ile ölür, oysa
99
+ # unsuspend! damgayı çoktan tüketmiş olurdu.
100
+ def load(store:, storage_id:, config: nil, dmesg: nil, event_bus: nil)
101
+ return nil unless store && storage_id
102
+
103
+ row = store.get(type: "goal", id: storage_id.to_s)
104
+ return nil unless row.is_a?(Hash) && row["status"] == "suspended"
105
+
106
+ doc = JSON.parse(row["body"].to_s)
107
+ return nil unless doc.is_a?(Hash) && doc["rev"].is_a?(Integer) &&
108
+ doc["objective"] && SIGMA_COLLECTIONS.all? { |key| doc[key].is_a?(Array) }
109
+
110
+ ledger = Ledger.new(goal_text: doc["objective"].to_s,
111
+ max_retries: Ledger.max_patch_retries(config),
112
+ store: store, dmesg: dmesg, event_bus: event_bus)
113
+ ledger.restore_from_row!(row: row, doc: doc)
114
+ ledger
115
+ rescue StandardError => e
116
+ Rubik::Log.warn(:goal_ledger, "goal ledger load from suspend stamp failed " \
117
+ "(#{e.class}: #{e.message}); cross-process resume refused")
118
+ nil
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # Satır links'indeki askı damgası meta'sını çözer (bozuk JSON →
125
+ # nil: load dürüst devam eder, yalnız sayaç geri yüklenemez).
126
+ def parse_suspend_meta(row)
127
+ raw = row.dig("links", "suspended")
128
+ parsed = JSON.parse(raw.to_s)
129
+ parsed.is_a?(Hash) ? parsed : nil
130
+ rescue StandardError
131
+ nil
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Orchestrator
5
+ module GoalLedger
6
+ # Σ XML render sayfası: rubik://goal/ledger read_file gövdesi.
7
+ # Ledger'a include edilir — SAF render'dır (durum sahibi Ledger'da
8
+ # kalır; bu sayfa yalnız @monitor altında okur), sayfa disiplini
9
+ # (hygiene satır cap'leri) altındaki Ledger'ı rahatlatır.
10
+ module XmlRender
11
+ def to_xml
12
+ @monitor.synchronize { render_xml }
13
+ end
14
+
15
+ # §4.1 render: GoalState#to_xml mold'u — tüm metin xml_escape'ten
16
+ # geçer; boş koleksiyonların etiketi HİÇ basılmaz ("yokluk, boşluk
17
+ # değil" kuralı); optional attribute'lar (storage_id, verify) yalnız
18
+ # set iken basılır.
19
+ def render_xml
20
+ esc = Rubik::Platform.method(:xml_escape)
21
+ attrs = %(rev="#{@doc['rev']}")
22
+ attrs += %( storage_id="#{esc.call(@storage_id)}") if @storage_id
23
+ attrs += %( updated_at="#{@updated_at.utc.iso8601}") if @updated_at
24
+ body = []
25
+ body << %( <objective>#{esc.call(@doc['objective'].to_s)}</objective>)
26
+ tasks = @doc["tasks"].map do |task|
27
+ text = task["title"].to_s
28
+ text += " — notes: #{task['notes']}" if task["notes"]
29
+ attrs_t = %(id="#{esc.call(task['id'].to_s)}" status="#{esc.call(task['status'].to_s)}")
30
+ attrs_t += %( verify="#{esc.call(task['verify'].to_s)}") if task["verify"]
31
+ task["title"] || task["notes"] ? %( <task #{attrs_t}>#{esc.call(text)}</task>) : %( <task #{attrs_t} />)
32
+ end
33
+ body << "<tasks>\n#{tasks.join("\n")}\n </tasks>" unless tasks.empty?
34
+ findings = @doc["findings"].map do |f|
35
+ %( <finding id="#{esc.call(f['id'].to_s)}" source_step="#{f['source_step']}">#{esc.call(f['text'].to_s)}</finding>)
36
+ end
37
+ body << "<findings>\n#{findings.join("\n")}\n </findings>" unless findings.empty?
38
+ decisions = @doc["decisions"].map do |dec|
39
+ attrs_d = %(id="#{esc.call(dec['id'].to_s)}")
40
+ attrs_d += %( rationale="#{esc.call(dec['rationale'].to_s)}") if dec["rationale"]
41
+ %( <decision #{attrs_d}>#{esc.call(dec['text'].to_s)}</decision>)
42
+ end
43
+ body << "<decisions>\n#{decisions.join("\n")}\n </decisions>" unless decisions.empty?
44
+ questions = @doc["open_questions"].map { |q| %( <question>#{esc.call(q.to_s)}</question>) }
45
+ body << "<open_questions>\n#{questions.join("\n")}\n </open_questions>" unless questions.empty?
46
+ notes = @doc["environment_notes"].map do |note|
47
+ attrs_n = %(id="#{esc.call(note['id'].to_s)}")
48
+ attrs_n += %( verify="#{esc.call(note['verify'].to_s)}") if note["verify"]
49
+ %( <note #{attrs_n}>#{esc.call(note['text'].to_s)}</note>)
50
+ end
51
+ body << "<environment_notes>\n#{notes.join("\n")}\n </environment_notes>" unless notes.empty?
52
+ <<~XML.strip
53
+ <goal_ledger #{attrs}>
54
+ #{body.join("\n")}
55
+ </goal_ledger>
56
+ XML
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Rubik
6
+ module Orchestrator
7
+ # Oturum düzeyi askı kaydı (issue #106 Faz 1): süreç içinde hayatta
8
+ # kalan askılı !goal koşularının canlı parçalarını (Loop, GoalState,
9
+ # Ledger) goal_id başına TEK girdide tutar. !goal resume en güncel
10
+ # girdiyi canlandırır; terminal yollar girdiyi siler.
11
+ #
12
+ # Zero-restart sözleşmesi: register aynı goal_id ile 1000 kez çağrılsa
13
+ # tek girdi kalır (swap, yığma yok — Context#provide inverse
14
+ # disiplininin aynısı) ve silinmiş girdi sözlüğü büyümez. Coeffect
15
+ # olarak sağlanır (Coeffects::GOAL_REGISTRY): ilk askıda provide
16
+ # edilir, sonraki provide'lar değer swap'idir, oturum dispose'u tek
17
+ # delete-inverse ile temizler.
18
+ class GoalRegistry
19
+ # Askılı koşunun canlandırılabilir parçaları. loop: hayatta kalan
20
+ # Loop örneği (history prefix'i sağlam); ledger: nil olabilir
21
+ # (goal.ledger.enabled=false koşuları — süreç içi resume çalışır,
22
+ # çapraz-süreç resume dürüstçe reddedilir).
23
+ Entry = Struct.new(:goal_id, :goal_text, :loop, :goal_state, :ledger,
24
+ :reason, :suspended_at, :attempt, keyword_init: true)
25
+
26
+ def initialize
27
+ @monitor = Monitor.new
28
+ @entries = {}
29
+ end
30
+
31
+ # goal_id başına tek girdi: aynı id tekrar askıya alınırsa (resume →
32
+ # yeniden askı) girdi güncellenir, yenisi eklenmez. delete+set bilinçli
33
+ # bir çift işlemdir: girdiyi Hash'in sonuna taşır — böylece ekleme
34
+ # sırası askı recency'sine eşit olur (eski koşu yeniden askıya düşünce
35
+ # !goal resume EN SON askılaşanı canlandırmaya devam eder).
36
+ def register(entry)
37
+ @monitor.synchronize do
38
+ @entries.delete(entry.goal_id)
39
+ @entries[entry.goal_id] = entry
40
+ end
41
+ self
42
+ end
43
+
44
+ def fetch(goal_id)
45
+ @monitor.synchronize { @entries[goal_id] }
46
+ end
47
+
48
+ # En güncel askı: register'ın delete+set'i sayesinde Hash ekleme
49
+ # sırası = askı recency'si.
50
+ def latest
51
+ @monitor.synchronize { @entries.values.last }
52
+ end
53
+
54
+ def delete(goal_id)
55
+ @monitor.synchronize { @entries.delete(goal_id) }
56
+ end
57
+
58
+ def size
59
+ @monitor.synchronize { @entries.size }
60
+ end
61
+
62
+ def empty?
63
+ @monitor.synchronize { @entries.empty? }
64
+ end
65
+ end
66
+ end
67
+ end