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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8f21cd00a0fb432c27fd057addf7294b2d84425c7927d6298bab0c87496d868
4
- data.tar.gz: c0a512a778e049b13d5442ab3f72bf513ce5104bc0be3632891eda965b1fa668
3
+ metadata.gz: d9b428be29ef5df316acb3e4b9d6a01b826168046e1133e50c293e1ecc1a1186
4
+ data.tar.gz: 7a38b04f5d93dea41f294cb0011a9f74511cd24bfe9687af792edc1940dcfd03
5
5
  SHA512:
6
- metadata.gz: f772d447b870acf41484209df88a82352e4b27b00a7f30335dfb0b5fee1e26c6cc7b06d6194aaf46d2d7506e55cb7e5d264aa9b005872db00c632f3caea1a4bf
7
- data.tar.gz: d4aee3639b1cec1a35015c9a432cb7041715bd3f3ad0571534a9d441ce11e79481a6361e72295076fb06605cc2afd357805e385ff04bf28503302c7f003bcbd5
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 = Mutex.new
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
- @proposals.delete(oldest.id) if oldest
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
- chat = Legion::LLM.chat(caller: { extension: 'lex-mind-growth', operation: 'build' }, intent: { capability: :reasoning }) # rubocop:disable Legion/HelperMigration/DirectLlm
251
- chat.with_instructions(implementation_instructions)
252
- response = chat.ask(file_implementation_prompt(stub_content, proposal))
253
- code = extract_ruby_code(response.content)
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
- caller: {
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
- ).ask(improvement_prompt(name, fitness, weaknesses))
138
+ )
138
139
  # rubocop:enable Legion/HelperMigration/DirectLlm
139
- parse_llm_suggestions(response.content)
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