lex-mind-growth 0.2.7 → 0.3.1
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/lib/legion/extensions/mind_growth/helpers/concept_proposal.rb +23 -0
- data/lib/legion/extensions/mind_growth/helpers/proposal_persistence.rb +152 -0
- data/lib/legion/extensions/mind_growth/helpers/proposal_store.rb +39 -4
- data/lib/legion/extensions/mind_growth/hooks/governance_listener.rb +38 -0
- data/lib/legion/extensions/mind_growth/runners/builder.rb +35 -4
- data/lib/legion/extensions/mind_growth/runners/evolver.rb +23 -4
- data/lib/legion/extensions/mind_growth/runners/governance.rb +48 -2
- data/lib/legion/extensions/mind_growth/runners/orchestrator.rb +103 -0
- data/lib/legion/extensions/mind_growth/runners/proposer.rb +39 -12
- data/lib/legion/extensions/mind_growth/version.rb +1 -1
- data/lib/legion/extensions/mind_growth.rb +15 -0
- data/spec/legion/extensions/mind_growth/helpers/constants_spec.rb +47 -0
- data/spec/legion/extensions/mind_growth/helpers/proposal_persistence_spec.rb +120 -0
- data/spec/legion/extensions/mind_growth/hooks/governance_callback_spec.rb +69 -0
- data/spec/legion/extensions/mind_growth/integration_spec.rb +140 -0
- data/spec/legion/extensions/mind_growth/runners/builder_spec.rb +9 -0
- data/spec/legion/extensions/mind_growth/runners/evolver_spec.rb +9 -0
- data/spec/legion/extensions/mind_growth/runners/post_build_pipeline_spec.rb +106 -0
- data/spec/legion/extensions/mind_growth/runners/proposer_spec.rb +28 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9b428be29ef5df316acb3e4b9d6a01b826168046e1133e50c293e1ecc1a1186
|
|
4
|
+
data.tar.gz: 7a38b04f5d93dea41f294cb0011a9f74511cd24bfe9687af792edc1940dcfd03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33078449b48cf7cae04ceabd91690ae4900f47bc89275aab55005a2f1cb28242e5ef0780ef6321166b09e7b89a79b34b34afbd2d293243807a64c366c5303323
|
|
7
|
+
data.tar.gz: 6d77136475fc7cde737949355135e60fa90891559f29f02ee71aa522e80d5be82cbbda09e41b16fc4293070eae6c9b0466acd564e1441700c326112905035ef6
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module MindGrowth
|
|
@@ -59,6 +61,27 @@ module Legion
|
|
|
59
61
|
@built_at = Time.now.utc if new_status == :passing
|
|
60
62
|
end
|
|
61
63
|
|
|
64
|
+
def self.from_h(hash)
|
|
65
|
+
h = hash.transform_keys(&:to_sym)
|
|
66
|
+
proposal = allocate
|
|
67
|
+
proposal.instance_variable_set(:@id, h[:id])
|
|
68
|
+
proposal.instance_variable_set(:@name, h[:name])
|
|
69
|
+
proposal.instance_variable_set(:@module_name, h[:module_name])
|
|
70
|
+
proposal.instance_variable_set(:@category, h[:category]&.to_sym)
|
|
71
|
+
proposal.instance_variable_set(:@description, h[:description])
|
|
72
|
+
proposal.instance_variable_set(:@metaphor, h[:metaphor])
|
|
73
|
+
proposal.instance_variable_set(:@helpers, h[:helpers] || [])
|
|
74
|
+
proposal.instance_variable_set(:@runner_methods, h[:runner_methods] || [])
|
|
75
|
+
proposal.instance_variable_set(:@rationale, h[:rationale])
|
|
76
|
+
proposal.instance_variable_set(:@scores, (h[:scores] || {}).transform_keys(&:to_sym))
|
|
77
|
+
proposal.instance_variable_set(:@status, h[:status]&.to_sym || :proposed)
|
|
78
|
+
proposal.instance_variable_set(:@origin, h[:origin]&.to_sym || :proposer)
|
|
79
|
+
proposal.instance_variable_set(:@created_at, h[:created_at] ? Time.parse(h[:created_at].to_s) : Time.now.utc)
|
|
80
|
+
proposal.instance_variable_set(:@evaluated_at, h[:evaluated_at] ? Time.parse(h[:evaluated_at].to_s) : nil)
|
|
81
|
+
proposal.instance_variable_set(:@built_at, h[:built_at] ? Time.parse(h[:built_at].to_s) : nil)
|
|
82
|
+
proposal
|
|
83
|
+
end
|
|
84
|
+
|
|
62
85
|
def to_h
|
|
63
86
|
FIELDS.to_h { |f| [f, send(f)] }
|
|
64
87
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module MindGrowth
|
|
6
|
+
module Helpers
|
|
7
|
+
# Cache-backed persistence for proposals and governance votes.
|
|
8
|
+
# Best-effort: degrades to in-memory-only when cache is unavailable.
|
|
9
|
+
class ProposalPersistence
|
|
10
|
+
PROPOSAL_TTL = 604_800 # 7 days
|
|
11
|
+
VOTES_KEY_SUFFIX = ':votes'
|
|
12
|
+
INDEX_KEY_SUFFIX = ':index'
|
|
13
|
+
|
|
14
|
+
def initialize(namespace: 'legion_mind_growth')
|
|
15
|
+
@namespace = namespace
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def save_proposal(proposal_hash)
|
|
19
|
+
return false unless cache_available?
|
|
20
|
+
|
|
21
|
+
key = proposal_key(proposal_hash[:id])
|
|
22
|
+
Legion::Cache.set_sync(key, serialize(proposal_hash), ttl: PROPOSAL_TTL)
|
|
23
|
+
update_index(proposal_hash[:id], :add)
|
|
24
|
+
true
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
log.error "[proposal_persistence] save_proposal failed for #{proposal_hash[:id]}: #{e.message}"
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load_proposal(id)
|
|
31
|
+
return nil unless cache_available?
|
|
32
|
+
|
|
33
|
+
raw = Legion::Cache.get(proposal_key(id)) # rubocop:disable Legion/HelperMigration/DirectCache
|
|
34
|
+
return nil unless raw
|
|
35
|
+
|
|
36
|
+
deserialize(raw)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
log.error "[proposal_persistence] load_proposal failed for #{id}: #{e.message}"
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete_proposal(id)
|
|
43
|
+
return false unless cache_available?
|
|
44
|
+
|
|
45
|
+
Legion::Cache.delete_sync(proposal_key(id))
|
|
46
|
+
update_index(id, :remove)
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
log.error "[proposal_persistence] delete_proposal failed for #{id}: #{e.message}"
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_all_proposals
|
|
54
|
+
return {} unless cache_available?
|
|
55
|
+
|
|
56
|
+
ids = load_index
|
|
57
|
+
return {} if ids.empty?
|
|
58
|
+
|
|
59
|
+
ids.each_with_object({}) do |id, result|
|
|
60
|
+
p = load_proposal(id)
|
|
61
|
+
result[id] = p if p
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
log.error "[proposal_persistence] load_all_proposals failed: #{e.message}"
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def delete_all_proposals
|
|
69
|
+
return false unless cache_available?
|
|
70
|
+
|
|
71
|
+
ids = load_index
|
|
72
|
+
ids.each { |id| Legion::Cache.delete_sync(proposal_key(id)) }
|
|
73
|
+
Legion::Cache.delete_sync(index_key)
|
|
74
|
+
true
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
log.error "[proposal_persistence] delete_all_proposals failed: #{e.message}"
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def save_votes(votes_hash)
|
|
81
|
+
return false unless cache_available?
|
|
82
|
+
|
|
83
|
+
Legion::Cache.set_sync(votes_key, serialize(votes_hash), ttl: PROPOSAL_TTL)
|
|
84
|
+
true
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
log.error "[proposal_persistence] save_votes failed: #{e.message}"
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def load_votes
|
|
91
|
+
return {} unless cache_available?
|
|
92
|
+
|
|
93
|
+
raw = Legion::Cache.get(votes_key) # rubocop:disable Legion/HelperMigration/DirectCache
|
|
94
|
+
return {} unless raw
|
|
95
|
+
|
|
96
|
+
result = deserialize(raw)
|
|
97
|
+
result.is_a?(Hash) ? result : {}
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
log.error "[proposal_persistence] load_votes failed: #{e.message}"
|
|
100
|
+
{}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def cache_available?
|
|
106
|
+
defined?(Legion::Cache) && Legion::Cache.connected? # rubocop:disable Legion/HelperMigration/DirectCache
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def proposal_key(id) = "#{@namespace}:proposal:#{id}"
|
|
110
|
+
def votes_key = "#{@namespace}#{VOTES_KEY_SUFFIX}"
|
|
111
|
+
def index_key = "#{@namespace}#{INDEX_KEY_SUFFIX}"
|
|
112
|
+
|
|
113
|
+
def update_index(id, operation)
|
|
114
|
+
ids = load_index
|
|
115
|
+
case operation
|
|
116
|
+
when :add then ids << id unless ids.include?(id)
|
|
117
|
+
when :remove then ids.delete(id)
|
|
118
|
+
end
|
|
119
|
+
Legion::Cache.set_sync(index_key, serialize(ids), ttl: PROPOSAL_TTL)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def load_index
|
|
123
|
+
raw = Legion::Cache.get(index_key) # rubocop:disable Legion/HelperMigration/DirectCache
|
|
124
|
+
return [] unless raw
|
|
125
|
+
|
|
126
|
+
result = deserialize(raw)
|
|
127
|
+
result.is_a?(Array) ? result : []
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
log.error "[proposal_persistence] load_index failed: #{e.message}"
|
|
130
|
+
[]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def serialize(obj)
|
|
134
|
+
Legion::JSON.dump(obj) # rubocop:disable Legion/HelperMigration/DirectJson
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def deserialize(raw)
|
|
138
|
+
return raw if raw.is_a?(Hash) || raw.is_a?(Array)
|
|
139
|
+
|
|
140
|
+
Legion::JSON.load(raw) # rubocop:disable Legion/HelperMigration/DirectJson
|
|
141
|
+
rescue StandardError => _e
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def log
|
|
146
|
+
Legion::Logging
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -7,15 +7,18 @@ module Legion
|
|
|
7
7
|
class ProposalStore
|
|
8
8
|
MAX_PROPOSALS = 500
|
|
9
9
|
|
|
10
|
-
def initialize
|
|
11
|
-
@proposals
|
|
12
|
-
@mutex
|
|
10
|
+
def initialize(rehydrate: true)
|
|
11
|
+
@proposals = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@persistence = ProposalPersistence.new
|
|
14
|
+
rehydrate_from_cache if rehydrate
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def store(proposal)
|
|
16
18
|
@mutex.synchronize do
|
|
17
19
|
evict_oldest if @proposals.size >= MAX_PROPOSALS
|
|
18
20
|
@proposals[proposal.id] = proposal
|
|
21
|
+
@persistence.save_proposal(proposal.to_h)
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
|
|
@@ -23,6 +26,13 @@ module Legion
|
|
|
23
26
|
@mutex.synchronize { @proposals[id] }
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
def update(proposal)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@proposals[proposal.id] = proposal
|
|
32
|
+
@persistence.save_proposal(proposal.to_h)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def all
|
|
27
37
|
@mutex.synchronize { @proposals.values.dup }
|
|
28
38
|
end
|
|
@@ -58,11 +68,36 @@ module Legion
|
|
|
58
68
|
@mutex.synchronize { @proposals.clear }
|
|
59
69
|
end
|
|
60
70
|
|
|
71
|
+
def clear_persisted!
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@proposals.clear
|
|
74
|
+
@persistence.delete_all_proposals
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
61
78
|
private
|
|
62
79
|
|
|
63
80
|
def evict_oldest
|
|
64
81
|
oldest = @proposals.values.min_by { |p| p.created_at.to_f }
|
|
65
|
-
|
|
82
|
+
return unless oldest
|
|
83
|
+
|
|
84
|
+
@proposals.delete(oldest.id)
|
|
85
|
+
@persistence.delete_proposal(oldest.id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def rehydrate_from_cache
|
|
89
|
+
cached = @persistence.load_all_proposals
|
|
90
|
+
cached.each do |id, hash|
|
|
91
|
+
proposal = ConceptProposal.from_h(hash)
|
|
92
|
+
@proposals[id] = proposal
|
|
93
|
+
end
|
|
94
|
+
log.info "[proposal_store] rehydrated #{cached.size} proposals from cache" unless cached.empty?
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
log.error "[proposal_store] rehydrate_from_cache failed: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log
|
|
100
|
+
Legion::Logging
|
|
66
101
|
end
|
|
67
102
|
end
|
|
68
103
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module MindGrowth
|
|
6
|
+
module Hooks
|
|
7
|
+
module GovernanceListener
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def register
|
|
11
|
+
return unless defined?(Legion::Events)
|
|
12
|
+
|
|
13
|
+
Legion::Events.on('governance.quorum_reached') do |event|
|
|
14
|
+
next unless event[:verdict] == :approved
|
|
15
|
+
|
|
16
|
+
base_path = event[:base_path] || GovernanceListener.configured_base_path
|
|
17
|
+
GovernanceListener.log.info "[governance_listener] quorum approved for #{event[:proposal_id]}, triggering build"
|
|
18
|
+
Runners::Governance.governance_resolved(proposal_id: event[:proposal_id], base_path: base_path)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configured_base_path
|
|
23
|
+
return nil unless defined?(Legion::Settings)
|
|
24
|
+
|
|
25
|
+
Legion::Settings.dig(:mind_growth, :base_path)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
log.debug "[governance_listener] configured_base_path failed: #{e.message}"
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def log
|
|
32
|
+
Legion::Logging
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -16,6 +16,7 @@ module Legion
|
|
|
16
16
|
|
|
17
17
|
pipeline = Helpers::BuildPipeline.new(proposal)
|
|
18
18
|
proposal.transition!(:building)
|
|
19
|
+
persist_proposal(proposal)
|
|
19
20
|
base_path ||= ::Dir.pwd
|
|
20
21
|
|
|
21
22
|
run_stage(pipeline, :scaffold, -> { scaffold_stage(proposal, base_path) })
|
|
@@ -25,6 +26,7 @@ module Legion
|
|
|
25
26
|
run_stage(pipeline, :register, -> { register_stage(proposal) }) unless pipeline.failed?
|
|
26
27
|
|
|
27
28
|
proposal.transition!(pipeline.complete? ? :passing : :build_failed)
|
|
29
|
+
persist_proposal(proposal)
|
|
28
30
|
log.info "[mind_growth:builder] #{proposal.name}: #{pipeline.stage}"
|
|
29
31
|
{ success: pipeline.complete?, pipeline: pipeline.to_h, proposal: proposal.to_h }
|
|
30
32
|
rescue ArgumentError => e
|
|
@@ -46,6 +48,12 @@ module Legion
|
|
|
46
48
|
Runners::Proposer.get_proposal_object(proposal_id)
|
|
47
49
|
end
|
|
48
50
|
|
|
51
|
+
def persist_proposal(proposal)
|
|
52
|
+
return unless defined?(Runners::Proposer) && Runners::Proposer.respond_to?(:persist_proposal)
|
|
53
|
+
|
|
54
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
55
|
+
end
|
|
56
|
+
|
|
49
57
|
def run_stage(pipeline, stage, callable)
|
|
50
58
|
return if pipeline.stage != stage
|
|
51
59
|
|
|
@@ -246,11 +254,15 @@ module Legion
|
|
|
246
254
|
|
|
247
255
|
def legacy_implement_file(file_path, proposal)
|
|
248
256
|
stub_content = ::File.read(file_path)
|
|
257
|
+
prompt = file_implementation_prompt(stub_content, proposal)
|
|
249
258
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
259
|
+
response = Legion::LLM.chat( # rubocop:disable Legion/HelperMigration/DirectLlm
|
|
260
|
+
message: prompt,
|
|
261
|
+
caller: { extension: 'lex-mind-growth', operation: 'build' },
|
|
262
|
+
intent: { capability: :reasoning }
|
|
263
|
+
)
|
|
264
|
+
content = implementation_content(response, prompt)
|
|
265
|
+
code = extract_ruby_code(content)
|
|
254
266
|
|
|
255
267
|
::File.write(file_path, code)
|
|
256
268
|
{ success: true, path: file_path }
|
|
@@ -298,7 +310,26 @@ module Legion
|
|
|
298
310
|
parts.join("\n")
|
|
299
311
|
end
|
|
300
312
|
|
|
313
|
+
def implementation_content(response, prompt)
|
|
314
|
+
return response.strip if response.is_a?(String)
|
|
315
|
+
return response.content if response.respond_to?(:content)
|
|
316
|
+
|
|
317
|
+
if response.respond_to?(:ask)
|
|
318
|
+
response.with_instructions(implementation_instructions) if response.respond_to?(:with_instructions)
|
|
319
|
+
asked = response.ask(prompt)
|
|
320
|
+
return implementation_content(asked, prompt)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
return nil unless response.is_a?(Hash)
|
|
324
|
+
|
|
325
|
+
response[:content] || response['content'] ||
|
|
326
|
+
response.dig(:message, :content) || response.dig('message', 'content') ||
|
|
327
|
+
response[:response] || response['response']
|
|
328
|
+
end
|
|
329
|
+
|
|
301
330
|
def extract_ruby_code(content)
|
|
331
|
+
return '' unless content
|
|
332
|
+
|
|
302
333
|
code = if content.match?(/```ruby\s*\n/)
|
|
303
334
|
content.match(/```ruby\s*\n(.*?)```/m)&.captures&.first || content
|
|
304
335
|
elsif content.match?(/```\s*\n/)
|
|
@@ -129,18 +129,35 @@ module Legion
|
|
|
129
129
|
def llm_suggestions(name, fitness, weaknesses)
|
|
130
130
|
# rubocop:disable Legion/HelperMigration/DirectLlm
|
|
131
131
|
response = Legion::LLM.chat(
|
|
132
|
-
|
|
132
|
+
message: improvement_prompt(name, fitness, weaknesses),
|
|
133
|
+
caller: {
|
|
133
134
|
extension: 'lex-mind-growth',
|
|
134
135
|
operation: 'evolver',
|
|
135
136
|
phase: 'suggest'
|
|
136
137
|
}
|
|
137
|
-
)
|
|
138
|
+
)
|
|
138
139
|
# rubocop:enable Legion/HelperMigration/DirectLlm
|
|
139
|
-
parse_llm_suggestions(response
|
|
140
|
+
parse_llm_suggestions(llm_content(response, improvement_prompt(name, fitness, weaknesses)))
|
|
140
141
|
rescue StandardError => _e
|
|
141
142
|
nil
|
|
142
143
|
end
|
|
143
144
|
|
|
145
|
+
def llm_content(response, prompt)
|
|
146
|
+
return response.strip if response.is_a?(String)
|
|
147
|
+
return response.content if response.respond_to?(:content)
|
|
148
|
+
|
|
149
|
+
if response.respond_to?(:ask)
|
|
150
|
+
asked = response.ask(prompt)
|
|
151
|
+
return llm_content(asked, prompt)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return nil unless response.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
response[:content] || response['content'] ||
|
|
157
|
+
response.dig(:message, :content) || response.dig('message', 'content') ||
|
|
158
|
+
response[:response] || response['response']
|
|
159
|
+
end
|
|
160
|
+
|
|
144
161
|
def improvement_prompt(name, fitness, weaknesses)
|
|
145
162
|
<<~PROMPT
|
|
146
163
|
The LegionIO cognitive extension "#{name}" has a fitness score of #{fitness.round(3)}.
|
|
@@ -153,12 +170,14 @@ module Legion
|
|
|
153
170
|
end
|
|
154
171
|
|
|
155
172
|
def parse_llm_suggestions(content)
|
|
173
|
+
return nil unless content
|
|
174
|
+
|
|
156
175
|
cleaned = content.gsub(/```(?:json)?\s*\n?/, '').strip
|
|
157
176
|
data = ::JSON.parse(cleaned)
|
|
158
177
|
return nil unless data.is_a?(Array)
|
|
159
178
|
|
|
160
179
|
data.map(&:to_s).reject(&:empty?)
|
|
161
|
-
rescue ::JSON::ParserError => _e
|
|
180
|
+
rescue ::JSON::ParserError, NoMethodError => _e
|
|
162
181
|
nil
|
|
163
182
|
end
|
|
164
183
|
|
|
@@ -19,6 +19,7 @@ module Legion
|
|
|
19
19
|
return { success: false, error: :invalid_status, current_status: proposal.status } unless %i[proposed evaluating].include?(proposal.status)
|
|
20
20
|
|
|
21
21
|
proposal.transition!(:evaluating)
|
|
22
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
22
23
|
{ success: true, proposal_id: proposal_id, status: :evaluating }
|
|
23
24
|
rescue ArgumentError => e
|
|
24
25
|
{ success: false, error: e.message }
|
|
@@ -28,15 +29,54 @@ module Legion
|
|
|
28
29
|
vote_sym = vote.to_sym
|
|
29
30
|
return { success: false, error: :invalid_vote } unless VOTE_VALUES.include?(vote_sym)
|
|
30
31
|
|
|
31
|
-
votes_mutex.synchronize do
|
|
32
|
+
snapshot = votes_mutex.synchronize do
|
|
32
33
|
votes_store[proposal_id] ||= []
|
|
33
34
|
votes_store[proposal_id] << { vote: vote_sym, agent_id: agent_id.to_s, rationale: rationale,
|
|
34
35
|
cast_at: Time.now.utc }
|
|
36
|
+
votes_store.transform_values(&:dup)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Helpers::ProposalPersistence.new.save_votes(snapshot)
|
|
40
|
+
|
|
41
|
+
tally = tally_votes(proposal_id: proposal_id)
|
|
42
|
+
if tally[:verdict] != :pending
|
|
43
|
+
log.info "[governance] quorum reached for #{proposal_id}: #{tally[:verdict]}"
|
|
44
|
+
if defined?(Legion::Events)
|
|
45
|
+
Legion::Events.emit('governance.quorum_reached',
|
|
46
|
+
proposal_id: proposal_id, verdict: tally[:verdict])
|
|
47
|
+
end
|
|
48
|
+
return { success: true, proposal_id: proposal_id, vote: vote_sym,
|
|
49
|
+
agent_id: agent_id.to_s, verdict: tally[:verdict], quorum_reached: true }
|
|
35
50
|
end
|
|
36
51
|
|
|
37
52
|
{ success: true, proposal_id: proposal_id, vote: vote_sym, agent_id: agent_id.to_s }
|
|
38
53
|
end
|
|
39
54
|
|
|
55
|
+
def governance_resolved(proposal_id:, base_path: nil, **)
|
|
56
|
+
tally = tally_votes(proposal_id: proposal_id)
|
|
57
|
+
return { action: :tally_failed } unless tally[:success]
|
|
58
|
+
|
|
59
|
+
case tally[:verdict]
|
|
60
|
+
when :approved
|
|
61
|
+
log.info "[governance] build triggered for #{proposal_id}"
|
|
62
|
+
build_result = Runners::Builder.build_extension(proposal_id: proposal_id, base_path: base_path)
|
|
63
|
+
{ action: :build_triggered, build: build_result, tally: tally }
|
|
64
|
+
when :rejected
|
|
65
|
+
log.info "[governance] proposal rejected: #{proposal_id}"
|
|
66
|
+
proposal = Runners::Proposer.get_proposal_object(proposal_id)
|
|
67
|
+
if proposal
|
|
68
|
+
proposal.transition!(:rejected)
|
|
69
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
70
|
+
end
|
|
71
|
+
{ action: :rejected, tally: tally }
|
|
72
|
+
else
|
|
73
|
+
{ action: :pending, tally: tally }
|
|
74
|
+
end
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
log.error "[governance] governance_resolved failed for #{proposal_id}: #{e.message}"
|
|
77
|
+
{ action: :error, error: e.message }
|
|
78
|
+
end
|
|
79
|
+
|
|
40
80
|
def tally_votes(proposal_id:, **)
|
|
41
81
|
ballots = votes_mutex.synchronize { (votes_store[proposal_id] || []).dup }
|
|
42
82
|
|
|
@@ -61,6 +101,7 @@ module Legion
|
|
|
61
101
|
return { success: false, error: :not_found } unless proposal
|
|
62
102
|
|
|
63
103
|
proposal.transition!(:approved)
|
|
104
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
64
105
|
{ success: true, proposal_id: proposal_id, status: :approved }
|
|
65
106
|
rescue ArgumentError => e
|
|
66
107
|
{ success: false, error: e.message }
|
|
@@ -71,6 +112,7 @@ module Legion
|
|
|
71
112
|
return { success: false, error: :not_found } unless proposal
|
|
72
113
|
|
|
73
114
|
proposal.transition!(:rejected)
|
|
115
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
74
116
|
{ success: true, proposal_id: proposal_id, status: :rejected, reason: reason }
|
|
75
117
|
rescue ArgumentError => e
|
|
76
118
|
{ success: false, error: e.message }
|
|
@@ -109,7 +151,11 @@ module Legion
|
|
|
109
151
|
private
|
|
110
152
|
|
|
111
153
|
def votes_store
|
|
112
|
-
@votes_store ||=
|
|
154
|
+
@votes_store ||= begin
|
|
155
|
+
persistence = Helpers::ProposalPersistence.new
|
|
156
|
+
cached = persistence.load_votes
|
|
157
|
+
cached.empty? ? {} : cached.transform_keys(&:to_s)
|
|
158
|
+
end
|
|
113
159
|
end
|
|
114
160
|
|
|
115
161
|
def votes_mutex
|
|
@@ -76,8 +76,104 @@ module Legion
|
|
|
76
76
|
model_coverage: profile[:model_coverage]&.map { |m| { model: m[:model], coverage: m[:coverage] } } }
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
def post_build_pipeline(proposal_id:, base_path: nil, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
80
|
+
proposal = Runners::Proposer.get_proposal_object(proposal_id)
|
|
81
|
+
return { success: false, skipped: true, reason: 'proposal not found' } unless proposal
|
|
82
|
+
return { success: false, skipped: true, reason: "not in :passing state (is #{proposal.status})" } unless proposal.status == :passing
|
|
83
|
+
|
|
84
|
+
result = { proposal_id: proposal_id }
|
|
85
|
+
|
|
86
|
+
log.info "[orchestrator] wiring proposal #{proposal_id}"
|
|
87
|
+
wire_result = wire_proposal(proposal)
|
|
88
|
+
result[:wire] = wire_result
|
|
89
|
+
|
|
90
|
+
gaia_wire_skip = wire_result[:reason] == :gaia_not_available
|
|
91
|
+
wire_failed = !gaia_wire_skip && wire_result[:success] == false
|
|
92
|
+
|
|
93
|
+
if wire_failed
|
|
94
|
+
log.warn "[orchestrator] wiring failed for #{proposal_id}: #{wire_result[:reason]}"
|
|
95
|
+
result[:activated] = false
|
|
96
|
+
result[:success] = false
|
|
97
|
+
result[:reason] = :wire_failed
|
|
98
|
+
return result
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if gaia_wire_skip
|
|
102
|
+
log.info "[orchestrator] wiring skipped for #{proposal_id} (GAIA unavailable)"
|
|
103
|
+
else
|
|
104
|
+
proposal.transition!(:wired)
|
|
105
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
log.info "[orchestrator] testing proposal #{proposal_id}"
|
|
109
|
+
test_result = test_proposal(proposal)
|
|
110
|
+
result[:integration_test] = test_result
|
|
111
|
+
|
|
112
|
+
if test_result[:reason] == :gaia_not_available
|
|
113
|
+
log.info "[orchestrator] integration test skipped for #{proposal_id} (GAIA unavailable)"
|
|
114
|
+
result[:success] = false
|
|
115
|
+
result[:activated] = false
|
|
116
|
+
result[:reason] = :gaia_not_available
|
|
117
|
+
return result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if test_result[:success] == false
|
|
121
|
+
proposal.transition!(:degraded)
|
|
122
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
123
|
+
result[:activated] = false
|
|
124
|
+
result[:success] = false
|
|
125
|
+
log.info "[orchestrator] proposal #{proposal_id} degraded after integration test"
|
|
126
|
+
else
|
|
127
|
+
proposal.transition!(:active)
|
|
128
|
+
Runners::Proposer.persist_proposal(proposal)
|
|
129
|
+
result[:activated] = true
|
|
130
|
+
result[:success] = true
|
|
131
|
+
log.info "[orchestrator] proposal #{proposal_id} activated"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
result
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
log.error "[orchestrator] post_build_pipeline failed for #{proposal_id}: #{e.message}"
|
|
137
|
+
{ success: false, error: e.message }
|
|
138
|
+
end
|
|
139
|
+
|
|
79
140
|
private
|
|
80
141
|
|
|
142
|
+
def wire_proposal(proposal)
|
|
143
|
+
phase = Helpers::PhaseAllocator.allocate_phase(
|
|
144
|
+
category: proposal.category,
|
|
145
|
+
runner_methods: proposal.runner_methods
|
|
146
|
+
)
|
|
147
|
+
log.info "[orchestrator] wiring #{proposal.name} into tick phase #{phase[:phase]}"
|
|
148
|
+
Runners::Wirer.wire_extension(
|
|
149
|
+
extension_name: proposal.name,
|
|
150
|
+
ext_module: proposal.module_name,
|
|
151
|
+
runner_module: "#{proposal.module_name}::Runners",
|
|
152
|
+
fn: proposal.runner_methods&.first&.dig(:name) || 'status',
|
|
153
|
+
phase: phase[:phase]
|
|
154
|
+
)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
log.error "[orchestrator] wire_proposal failed for #{proposal.name}: #{e.message}"
|
|
157
|
+
{ success: false, reason: e.message }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def test_proposal(proposal)
|
|
161
|
+
log.info "[orchestrator] running integration test for #{proposal.name}"
|
|
162
|
+
phase = Helpers::PhaseAllocator.allocate_phase(
|
|
163
|
+
category: proposal.category,
|
|
164
|
+
runner_methods: proposal.runner_methods
|
|
165
|
+
)
|
|
166
|
+
Runners::IntegrationTester.test_extension_in_tick(
|
|
167
|
+
ext_module: proposal.module_name,
|
|
168
|
+
runner_module: "#{proposal.module_name}::Runners",
|
|
169
|
+
fn: proposal.runner_methods&.first&.dig(:name) || 'status',
|
|
170
|
+
phase: phase[:phase]
|
|
171
|
+
)
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
log.error "[orchestrator] test_proposal failed for #{proposal.name}: #{e.message}"
|
|
174
|
+
{ success: false, reason: e.message }
|
|
175
|
+
end
|
|
176
|
+
|
|
81
177
|
def propose_from_priorities(priorities, max)
|
|
82
178
|
priorities.first(max).filter_map do |priority_name|
|
|
83
179
|
name = "lex-#{priority_name.to_s.tr('_', '-')}"
|
|
@@ -126,6 +222,13 @@ module Legion
|
|
|
126
222
|
succeeded: builds.count { |b| b[:success] },
|
|
127
223
|
failed: builds.count { |b| !b[:success] },
|
|
128
224
|
held: force ? 0 : held_count }
|
|
225
|
+
builds.each do |build|
|
|
226
|
+
next unless build[:success]
|
|
227
|
+
|
|
228
|
+
proposal_id = build[:proposal]&.dig(:id) || build[:proposal_id]
|
|
229
|
+
post_result = post_build_pipeline(proposal_id: proposal_id, base_path: base_path)
|
|
230
|
+
trace[:steps] << { step: :post_build, proposal_id: proposal_id, result: post_result }
|
|
231
|
+
end
|
|
129
232
|
nil
|
|
130
233
|
end
|
|
131
234
|
end
|