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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a799e64cfe31504bbb5c2da2dff00070725c885e04abb896addfc32ed48d3f87
|
|
4
|
+
data.tar.gz: 8dbaabb4fba7e1dc076924b0be25527307700728bc6e21e2f9e842edc0b8987b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd0f68f48ddf4f8b35c4ac625abc97a8e8673162529cb21806b664fa7b5d6c718d45e299f91b9ef4bebf7437371b6282655fff2e02574c7ec16171259c00311f
|
|
7
|
+
data.tar.gz: fa4a485d719537c0bd40d56d32fb97794c9b9de8288bf2f0a0159c5290a02c1d2906071d70e75585dda0b2d677980047a899d94634270b03bc9d13559444955f
|
data/CHANGELOG.md
CHANGED
|
@@ -10,8 +10,66 @@ 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
|
+
|
|
42
|
+
## [0.1.3] - 2026-10-12
|
|
43
|
+
|
|
44
|
+
A robustness release straight from a field incident: the `!goal` loop
|
|
45
|
+
no longer strands state when the provider fails mid-goal, and the
|
|
46
|
+
redactor gets measurably cheaper on secret-free input.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **`!goal` survives provider errors (#105):** an api read-timeout
|
|
51
|
+
`ClientError` escaped the goal handler (only `Interrupt` was
|
|
52
|
+
rescued), leaving `GoalState` stuck in `:running`, the ledger
|
|
53
|
+
unfinalized and the session silent while the batch spinner kept
|
|
54
|
+
cycling with the cursor hidden. One `fail_goal` channel now serves
|
|
55
|
+
the `:error` return plus `ClientError`/`StandardError` rescues
|
|
56
|
+
(`Interrupt` still means `:stopped`); both command-dispatch branches
|
|
57
|
+
kill the leaked spinner instead of masking the real failure; and
|
|
58
|
+
goal steps record their tool calls to the session like free-text
|
|
59
|
+
turns.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- **Redactor short-circuits secret-free lines (#104):** pre-compiled
|
|
64
|
+
quick-check constants plus a trigger check before scrubbing cut
|
|
65
|
+
allocations by ~68% on a mixed redact workload (172,940 -> 54,864
|
|
66
|
+
objects per 20x2000-line pass) with byte-for-byte output parity;
|
|
67
|
+
invalid-UTF-8 input stays total (masked/scrubbed) instead of raising
|
|
68
|
+
ArgumentError.
|
|
69
|
+
|
|
13
70
|
## [0.1.2] - 2026-10-11
|
|
14
71
|
|
|
72
|
+
|
|
15
73
|
The Anthropic correctness release: the provider now speaks the modern
|
|
16
74
|
extended-thinking contract, so pinning any current Claude model works
|
|
17
75
|
instead of failing with HTTP 400.
|
data/lib/rubik/coeffects.rb
CHANGED
|
@@ -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
|
-
"
|
|
32
|
-
"
|
|
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.
|
|
374
|
+
paths.sort_by { |p| p.tr("/", "\x00") }
|
|
372
375
|
end
|
|
373
376
|
|
|
374
377
|
def parse_gitignore(dir = @root_dir)
|
data/lib/rubik/llm/client.rb
CHANGED
|
@@ -8,18 +8,36 @@ require_relative "../platform"
|
|
|
8
8
|
|
|
9
9
|
module Rubik
|
|
10
10
|
module LLM
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|