lex-mind-growth 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -0
- 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 +5 -0
- 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/post_build_pipeline_spec.rb +106 -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: 3fcaa84f5b0e455b0ac70019228ebe5bffdbd1c69228aafcad9bf92d7f91bb52
|
|
4
|
+
data.tar.gz: 4ea8b0c108d6bf0a957cd9709147e4cfaa3c78e0b297afc85e642331f9153e4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 911dc8e13b4dfe4cb4ee0cd5dd7cd27fc2d93a529aca350cc4d02d4f3f27b9548ac8881945bb57d150ffa8cc8e169e8f62b3590e2bbab4188dbfc0046aa69eb3
|
|
7
|
+
data.tar.gz: d4f849194085b3b398dd41e3499b686c87b323ab4950a392eaca71133aeb943fcfe68557829a5cb6be92428bf781c90ab53d49cf877f26abe5b699562c25d07e
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -61,6 +61,7 @@ module Legion
|
|
|
61
61
|
|
|
62
62
|
eval_scores = scores || score_with_llm(proposal) || default_scores
|
|
63
63
|
proposal.evaluate!(eval_scores)
|
|
64
|
+
proposal_store.update(proposal)
|
|
64
65
|
log.info "[mind_growth:proposer] evaluated #{proposal.name}: #{proposal.status}"
|
|
65
66
|
{ success: true, proposal: proposal.to_h, approved: proposal.status == :approved,
|
|
66
67
|
auto_approved: proposal.auto_approvable? }
|
|
@@ -81,6 +82,10 @@ module Legion
|
|
|
81
82
|
proposal_store.get(id)
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
def persist_proposal(proposal)
|
|
86
|
+
proposal_store.update(proposal)
|
|
87
|
+
end
|
|
88
|
+
|
|
84
89
|
private
|
|
85
90
|
|
|
86
91
|
def proposal_store
|
|
@@ -5,6 +5,7 @@ require 'securerandom'
|
|
|
5
5
|
require 'legion/extensions/mind_growth/version'
|
|
6
6
|
require 'legion/extensions/mind_growth/helpers/constants'
|
|
7
7
|
require 'legion/extensions/mind_growth/helpers/concept_proposal'
|
|
8
|
+
require 'legion/extensions/mind_growth/helpers/proposal_persistence'
|
|
8
9
|
require 'legion/extensions/mind_growth/helpers/proposal_store'
|
|
9
10
|
require 'legion/extensions/mind_growth/helpers/cognitive_models'
|
|
10
11
|
require 'legion/extensions/mind_growth/helpers/build_pipeline'
|
|
@@ -19,6 +20,8 @@ require 'legion/extensions/mind_growth/runners/wirer'
|
|
|
19
20
|
require 'legion/extensions/mind_growth/runners/integration_tester'
|
|
20
21
|
require 'legion/extensions/mind_growth/runners/retrospective'
|
|
21
22
|
require 'legion/extensions/mind_growth/runners/governance'
|
|
23
|
+
require 'legion/extensions/mind_growth/hooks/governance_listener'
|
|
24
|
+
Legion::Extensions::MindGrowth::Hooks::GovernanceListener.register
|
|
22
25
|
require 'legion/extensions/mind_growth/runners/risk_assessor'
|
|
23
26
|
require 'legion/extensions/mind_growth/helpers/composition_map'
|
|
24
27
|
require 'legion/extensions/mind_growth/runners/monitor'
|
|
@@ -39,6 +42,18 @@ module Legion
|
|
|
39
42
|
def self.remote_invocable?
|
|
40
43
|
false
|
|
41
44
|
end
|
|
45
|
+
|
|
46
|
+
def self.mcp_tools?
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.mcp_tools_deferred?
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.transport_required?
|
|
55
|
+
false
|
|
56
|
+
end
|
|
42
57
|
end
|
|
43
58
|
end
|
|
44
59
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::MindGrowth::Helpers::Constants do
|
|
4
|
+
describe 'PROPOSAL_STATUSES' do
|
|
5
|
+
subject(:statuses) { described_class::PROPOSAL_STATUSES }
|
|
6
|
+
|
|
7
|
+
it 'is frozen' do
|
|
8
|
+
expect(statuses).to be_frozen
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'contains only symbols' do
|
|
12
|
+
expect(statuses).to all(be_a(Symbol))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Full lifecycle:
|
|
16
|
+
# proposed → evaluating → approved → building → testing → passing → wired → active
|
|
17
|
+
# → rejected → build_failed
|
|
18
|
+
# → degraded
|
|
19
|
+
# → pruned
|
|
20
|
+
let(:full_lifecycle_states) do
|
|
21
|
+
%i[proposed evaluating approved rejected building testing passing wired active degraded pruned build_failed]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'includes every state in the full proposal lifecycle' do
|
|
25
|
+
full_lifecycle_states.each do |state|
|
|
26
|
+
expect(statuses).to include(state), "expected PROPOSAL_STATUSES to include :#{state}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'includes the primary happy-path states in order' do
|
|
31
|
+
happy_path = %i[proposed evaluating approved building testing passing wired active]
|
|
32
|
+
expect(statuses).to include(*happy_path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'includes the rejection terminal state' do
|
|
36
|
+
expect(statuses).to include(:rejected)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'includes the build_failed terminal state' do
|
|
40
|
+
expect(statuses).to include(:build_failed)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'includes degraded and pruned operational states' do
|
|
44
|
+
expect(statuses).to include(:degraded, :pruned)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::MindGrowth::Helpers::ProposalPersistence do
|
|
4
|
+
subject(:persistence) { described_class.new(namespace: 'test_proposals') }
|
|
5
|
+
|
|
6
|
+
before do
|
|
7
|
+
stub_const('Legion::Cache', Class.new do
|
|
8
|
+
def self.connected? = true
|
|
9
|
+
def self.get(key) = (@store ||= {})[key]
|
|
10
|
+
def self.set_sync(key, value, **) = ((@store ||= {})[key] = value)
|
|
11
|
+
def self.delete_sync(key) = (@store ||= {}).delete(key)
|
|
12
|
+
def self.flush = (@store = {})
|
|
13
|
+
end)
|
|
14
|
+
Legion::Cache.flush
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#save_proposal / #load_proposal' do
|
|
18
|
+
it 'round-trips a proposal hash' do
|
|
19
|
+
proposal_hash = { id: 'p-001', name: 'lex-test', module_name: 'Test',
|
|
20
|
+
category: :cognition, description: 'test', status: :approved,
|
|
21
|
+
scores: { novelty: 0.8 }, created_at: Time.now.utc.to_s }
|
|
22
|
+
persistence.save_proposal(proposal_hash)
|
|
23
|
+
loaded = persistence.load_proposal('p-001')
|
|
24
|
+
expect(loaded[:name]).to eq('lex-test')
|
|
25
|
+
# JSON round-trip preserves string values; from_h converts to symbols at rehydration
|
|
26
|
+
expect(loaded[:status].to_sym).to eq(:approved)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'returns nil for an unknown id' do
|
|
30
|
+
expect(persistence.load_proposal('no-such-id')).to be_nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'adds the id to the index on save' do
|
|
34
|
+
persistence.save_proposal({ id: 'p-001', name: 'one' })
|
|
35
|
+
all = persistence.load_all_proposals
|
|
36
|
+
# IDs are stored as strings; key may be string or symbol depending on JSON parse
|
|
37
|
+
expect(all.transform_keys(&:to_s)).to have_key('p-001')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#delete_proposal' do
|
|
42
|
+
it 'removes the proposal from cache' do
|
|
43
|
+
persistence.save_proposal({ id: 'p-del', name: 'delete-me' })
|
|
44
|
+
persistence.delete_proposal('p-del')
|
|
45
|
+
expect(persistence.load_proposal('p-del')).to be_nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'removes the id from the index' do
|
|
49
|
+
persistence.save_proposal({ id: 'p-del', name: 'delete-me' })
|
|
50
|
+
persistence.delete_proposal('p-del')
|
|
51
|
+
all_str = persistence.load_all_proposals.transform_keys(&:to_s)
|
|
52
|
+
expect(all_str).not_to have_key('p-del')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '#save_votes / #load_votes' do
|
|
57
|
+
it 'round-trips governance votes' do
|
|
58
|
+
votes = { 'p-001' => [{ vote: :approve, agent_id: 'a1', cast_at: Time.now.utc.to_s }] }
|
|
59
|
+
persistence.save_votes(votes)
|
|
60
|
+
loaded = persistence.load_votes
|
|
61
|
+
# JSON round-trip: top-level keys become symbols, vote values are strings
|
|
62
|
+
ballot = (loaded[:'p-001'] || loaded['p-001']).first
|
|
63
|
+
expect(ballot[:vote].to_sym).to eq(:approve)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns empty hash when no votes are stored' do
|
|
67
|
+
expect(persistence.load_votes).to eq({})
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe '#load_all_proposals' do
|
|
72
|
+
it 'returns all persisted proposals' do
|
|
73
|
+
persistence.save_proposal({ id: 'p-001', name: 'one', status: :approved })
|
|
74
|
+
persistence.save_proposal({ id: 'p-002', name: 'two', status: :proposed })
|
|
75
|
+
all = persistence.load_all_proposals
|
|
76
|
+
expect(all.size).to eq(2)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'returns an empty hash when nothing is stored' do
|
|
80
|
+
expect(persistence.load_all_proposals).to eq({})
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'does not include deleted proposals' do
|
|
84
|
+
persistence.save_proposal({ id: 'p-001', name: 'one' })
|
|
85
|
+
persistence.save_proposal({ id: 'p-002', name: 'two' })
|
|
86
|
+
persistence.delete_proposal('p-001')
|
|
87
|
+
all = persistence.load_all_proposals
|
|
88
|
+
expect(all.size).to eq(1)
|
|
89
|
+
expect(all.transform_keys(&:to_s)).to have_key('p-002')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe 'when cache unavailable' do
|
|
94
|
+
before { allow(Legion::Cache).to receive(:connected?).and_return(false) }
|
|
95
|
+
|
|
96
|
+
it 'degrades gracefully on load_proposal' do
|
|
97
|
+
expect(persistence.load_proposal('p-001')).to be_nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'degrades gracefully on save_proposal' do
|
|
101
|
+
expect(persistence.save_proposal({ id: 'x' })).to be false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'degrades gracefully on delete_proposal' do
|
|
105
|
+
expect(persistence.delete_proposal('x')).to be false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'degrades gracefully on load_votes' do
|
|
109
|
+
expect(persistence.load_votes).to eq({})
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'degrades gracefully on save_votes' do
|
|
113
|
+
expect(persistence.save_votes({ 'p' => [] })).to be false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'degrades gracefully on load_all_proposals' do
|
|
117
|
+
expect(persistence.load_all_proposals).to eq({})
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe 'Governance callback triggers build' do
|
|
4
|
+
let(:governance) { Legion::Extensions::MindGrowth::Runners::Governance }
|
|
5
|
+
let(:proposer) { Legion::Extensions::MindGrowth::Runners::Proposer }
|
|
6
|
+
|
|
7
|
+
before { proposer.instance_variable_set(:@proposal_store, nil) }
|
|
8
|
+
|
|
9
|
+
describe '.governance_resolved' do
|
|
10
|
+
it 'triggers build when quorum approves' do
|
|
11
|
+
proposal = proposer.propose_concept(name: 'lex-test-governance', category: :cognition,
|
|
12
|
+
description: 'test governance callback', enrich: false)
|
|
13
|
+
proposal_id = proposal[:proposal][:id]
|
|
14
|
+
proposer.evaluate_proposal(proposal_id: proposal_id,
|
|
15
|
+
scores: { novelty: 0.7, fit: 0.7, cognitive_value: 0.7,
|
|
16
|
+
implementability: 0.7, composability: 0.7 })
|
|
17
|
+
governance.instance_variable_set(:@votes_store, {})
|
|
18
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :approve, agent_id: 'agent-1')
|
|
19
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :approve, agent_id: 'agent-2')
|
|
20
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :approve, agent_id: 'agent-3')
|
|
21
|
+
|
|
22
|
+
resolved = governance.governance_resolved(proposal_id: proposal_id)
|
|
23
|
+
expect(resolved[:action]).to eq(:build_triggered)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'does not trigger build when quorum rejects' do
|
|
27
|
+
proposal = proposer.propose_concept(name: 'lex-test-rejected', category: :cognition,
|
|
28
|
+
description: 'test rejected', enrich: false)
|
|
29
|
+
proposal_id = proposal[:proposal][:id]
|
|
30
|
+
proposer.evaluate_proposal(proposal_id: proposal_id,
|
|
31
|
+
scores: { novelty: 0.7, fit: 0.7, cognitive_value: 0.7,
|
|
32
|
+
implementability: 0.7, composability: 0.7 })
|
|
33
|
+
governance.instance_variable_set(:@votes_store, {})
|
|
34
|
+
3.times { |i| governance.vote_on_proposal(proposal_id: proposal_id, vote: :reject, agent_id: "a#{i}") }
|
|
35
|
+
resolved = governance.governance_resolved(proposal_id: proposal_id)
|
|
36
|
+
expect(resolved[:action]).to eq(:rejected)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'returns pending when quorum not reached' do
|
|
40
|
+
governance.instance_variable_set(:@votes_store, {})
|
|
41
|
+
governance.vote_on_proposal(proposal_id: 'fake-id', vote: :approve, agent_id: 'agent-1')
|
|
42
|
+
resolved = governance.governance_resolved(proposal_id: 'fake-id')
|
|
43
|
+
expect(resolved[:action]).to eq(:pending)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe 'event listener registration' do
|
|
48
|
+
it 'registers a listener for governance.quorum_reached' do
|
|
49
|
+
events_spy = Class.new do
|
|
50
|
+
attr_reader :registered
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@registered = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def on(event, &block)
|
|
57
|
+
@registered << { event: event, block: block }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def emit(*)
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end.new
|
|
64
|
+
stub_const('Legion::Events', events_spy)
|
|
65
|
+
Legion::Extensions::MindGrowth::Hooks::GovernanceListener.register
|
|
66
|
+
expect(events_spy.registered.map { |r| r[:event] }).to include('governance.quorum_reached')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'Self-Improvement Pipeline Integration' do
|
|
6
|
+
let(:orchestrator) { Legion::Extensions::MindGrowth::Runners::Orchestrator }
|
|
7
|
+
let(:proposer) { Legion::Extensions::MindGrowth::Runners::Proposer }
|
|
8
|
+
let(:governance) { Legion::Extensions::MindGrowth::Runners::Governance }
|
|
9
|
+
|
|
10
|
+
before do
|
|
11
|
+
proposer.instance_variable_set(:@proposal_store, nil)
|
|
12
|
+
governance.instance_variable_set(:@votes_store, nil)
|
|
13
|
+
governance.instance_variable_set(:@votes_mutex, nil)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe 'auto-approve path (force: true bypasses governance)' do
|
|
17
|
+
it 'runs analyze → propose → evaluate → build' do
|
|
18
|
+
result = orchestrator.run_growth_cycle(
|
|
19
|
+
existing_extensions: [],
|
|
20
|
+
max_proposals: 1,
|
|
21
|
+
force: true
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
expect(result[:success]).to be true
|
|
25
|
+
trace = result[:trace]
|
|
26
|
+
|
|
27
|
+
step_names = trace[:steps].map { |s| s[:step] }
|
|
28
|
+
expect(step_names).to include(:analyze)
|
|
29
|
+
expect(step_names).to include(:propose)
|
|
30
|
+
expect(step_names).to include(:evaluate)
|
|
31
|
+
expect(step_names).to include(:build)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'build step attempts at least one build when force: true' do
|
|
35
|
+
result = orchestrator.run_growth_cycle(
|
|
36
|
+
existing_extensions: [],
|
|
37
|
+
max_proposals: 1,
|
|
38
|
+
force: true
|
|
39
|
+
)
|
|
40
|
+
build_step = result[:trace][:steps].find { |s| s[:step] == :build }
|
|
41
|
+
expect(build_step[:attempted]).to be > 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'holds proposals for governance review when force is false (default scores 0.7)' do
|
|
45
|
+
result = orchestrator.run_growth_cycle(
|
|
46
|
+
existing_extensions: [],
|
|
47
|
+
max_proposals: 1,
|
|
48
|
+
force: false
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(result[:success]).to be true
|
|
52
|
+
build_step = result[:trace][:steps].find { |s| s[:step] == :build }
|
|
53
|
+
expect(build_step[:held]).to be > 0
|
|
54
|
+
expect(build_step[:attempted]).to eq(0)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe 'governance path (scores 0.6-0.9)' do
|
|
59
|
+
it 'holds proposal, then resolves to build_triggered on approve quorum' do
|
|
60
|
+
prop = proposer.propose_concept(
|
|
61
|
+
name: 'lex-gov-test', category: :cognition,
|
|
62
|
+
description: 'governance path test', enrich: false
|
|
63
|
+
)
|
|
64
|
+
proposal_id = prop[:proposal][:id]
|
|
65
|
+
|
|
66
|
+
proposer.evaluate_proposal(
|
|
67
|
+
proposal_id: proposal_id,
|
|
68
|
+
scores: { novelty: 0.7, fit: 0.7, cognitive_value: 0.7,
|
|
69
|
+
implementability: 0.7, composability: 0.7 }
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
73
|
+
expect(proposal.status).to eq(:approved)
|
|
74
|
+
expect(proposal.auto_approvable?).to be false
|
|
75
|
+
|
|
76
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :approve, agent_id: 'a1')
|
|
77
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :approve, agent_id: 'a2')
|
|
78
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :approve, agent_id: 'a3')
|
|
79
|
+
|
|
80
|
+
resolved = governance.governance_resolved(proposal_id: proposal_id)
|
|
81
|
+
expect(resolved[:action]).to eq(:build_triggered)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe 'post-build pipeline' do
|
|
86
|
+
it 'transitions from passing through wire to activation' do
|
|
87
|
+
prop = proposer.propose_concept(
|
|
88
|
+
name: 'lex-postbuild-test', category: :safety,
|
|
89
|
+
description: 'post-build test', enrich: false
|
|
90
|
+
)
|
|
91
|
+
proposal_id = prop[:proposal][:id]
|
|
92
|
+
|
|
93
|
+
proposer.evaluate_proposal(proposal_id: proposal_id,
|
|
94
|
+
scores: { novelty: 0.95, fit: 0.95, cognitive_value: 0.95,
|
|
95
|
+
implementability: 0.95, composability: 0.95 })
|
|
96
|
+
|
|
97
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
98
|
+
proposal.transition!(:building)
|
|
99
|
+
proposal.transition!(:passing)
|
|
100
|
+
|
|
101
|
+
allow(Legion::Extensions::MindGrowth::Runners::Wirer)
|
|
102
|
+
.to receive(:wire_extension)
|
|
103
|
+
.and_return({ success: true, extension: proposal.name, phase: :safety })
|
|
104
|
+
allow(Legion::Extensions::MindGrowth::Runners::IntegrationTester)
|
|
105
|
+
.to receive(:test_extension_in_tick)
|
|
106
|
+
.and_return({ success: true, phase: :safety_integration })
|
|
107
|
+
|
|
108
|
+
result = orchestrator.post_build_pipeline(proposal_id: proposal_id)
|
|
109
|
+
expect(result).to have_key(:wire)
|
|
110
|
+
expect(result).to have_key(:integration_test)
|
|
111
|
+
|
|
112
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
113
|
+
expect(proposal.status).to eq(:active)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe 'rejection path' do
|
|
118
|
+
it 'rejects proposal when governance votes against' do
|
|
119
|
+
prop = proposer.propose_concept(
|
|
120
|
+
name: 'lex-reject-test', category: :cognition,
|
|
121
|
+
description: 'rejection test', enrich: false
|
|
122
|
+
)
|
|
123
|
+
proposal_id = prop[:proposal][:id]
|
|
124
|
+
|
|
125
|
+
proposer.evaluate_proposal(proposal_id: proposal_id,
|
|
126
|
+
scores: { novelty: 0.7, fit: 0.7, cognitive_value: 0.7,
|
|
127
|
+
implementability: 0.7, composability: 0.7 })
|
|
128
|
+
|
|
129
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :reject, agent_id: 'a1')
|
|
130
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :reject, agent_id: 'a2')
|
|
131
|
+
governance.vote_on_proposal(proposal_id: proposal_id, vote: :reject, agent_id: 'a3')
|
|
132
|
+
|
|
133
|
+
resolved = governance.governance_resolved(proposal_id: proposal_id)
|
|
134
|
+
expect(resolved[:action]).to eq(:rejected)
|
|
135
|
+
|
|
136
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
137
|
+
expect(proposal.status).to eq(:rejected)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe 'Orchestrator full cycle with wire + test + activate' do
|
|
4
|
+
let(:orchestrator) { Legion::Extensions::MindGrowth::Runners::Orchestrator }
|
|
5
|
+
let(:proposer) { Legion::Extensions::MindGrowth::Runners::Proposer }
|
|
6
|
+
|
|
7
|
+
before { proposer.instance_variable_set(:@proposal_store, nil) }
|
|
8
|
+
|
|
9
|
+
describe '.post_build_pipeline' do
|
|
10
|
+
it 'calls wire_extension after successful build' do
|
|
11
|
+
prop_result = proposer.propose_concept(name: 'lex-test-wire', category: :cognition,
|
|
12
|
+
description: 'test wiring', enrich: false)
|
|
13
|
+
proposal_id = prop_result[:proposal][:id]
|
|
14
|
+
proposer.evaluate_proposal(
|
|
15
|
+
proposal_id: proposal_id,
|
|
16
|
+
scores: { novelty: 0.95, fit: 0.95, cognitive_value: 0.95,
|
|
17
|
+
implementability: 0.95, composability: 0.95 }
|
|
18
|
+
)
|
|
19
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
20
|
+
proposal.transition!(:building)
|
|
21
|
+
proposal.transition!(:passing)
|
|
22
|
+
|
|
23
|
+
result = orchestrator.post_build_pipeline(proposal_id: proposal_id)
|
|
24
|
+
expect(result).to have_key(:wire)
|
|
25
|
+
expect(result).to have_key(:integration_test)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'skips wire for non-passing proposals' do
|
|
29
|
+
prop_result = proposer.propose_concept(name: 'lex-test-skip', category: :cognition,
|
|
30
|
+
description: 'test skip', enrich: false)
|
|
31
|
+
proposal_id = prop_result[:proposal][:id]
|
|
32
|
+
result = orchestrator.post_build_pipeline(proposal_id: proposal_id)
|
|
33
|
+
expect(result[:skipped]).to be true
|
|
34
|
+
expect(result[:reason]).to include('not in :passing')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'returns skipped: true for unknown proposal_id' do
|
|
38
|
+
result = orchestrator.post_build_pipeline(proposal_id: 'nonexistent-id')
|
|
39
|
+
expect(result[:skipped]).to be true
|
|
40
|
+
expect(result[:reason]).to include('proposal not found')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'includes proposal_id in the result' do
|
|
44
|
+
prop_result = proposer.propose_concept(name: 'lex-test-id', category: :cognition,
|
|
45
|
+
description: 'test id tracking', enrich: false)
|
|
46
|
+
proposal_id = prop_result[:proposal][:id]
|
|
47
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
48
|
+
proposal.transition!(:building)
|
|
49
|
+
proposal.transition!(:passing)
|
|
50
|
+
|
|
51
|
+
result = orchestrator.post_build_pipeline(proposal_id: proposal_id)
|
|
52
|
+
expect(result[:proposal_id]).to eq(proposal_id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'transitions to :active when integration test succeeds' do
|
|
56
|
+
prop_result = proposer.propose_concept(name: 'lex-test-active', category: :cognition,
|
|
57
|
+
description: 'test activation', enrich: false)
|
|
58
|
+
proposal_id = prop_result[:proposal][:id]
|
|
59
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
60
|
+
proposal.transition!(:building)
|
|
61
|
+
proposal.transition!(:passing)
|
|
62
|
+
|
|
63
|
+
allow(Legion::Extensions::MindGrowth::Runners::Wirer)
|
|
64
|
+
.to receive(:wire_extension)
|
|
65
|
+
.and_return({ success: true, extension: proposal.name, phase: :working_memory })
|
|
66
|
+
allow(Legion::Extensions::MindGrowth::Runners::IntegrationTester)
|
|
67
|
+
.to receive(:test_extension_in_tick)
|
|
68
|
+
.and_return({ success: true, phase: :working_memory_integration })
|
|
69
|
+
|
|
70
|
+
result = orchestrator.post_build_pipeline(proposal_id: proposal_id)
|
|
71
|
+
expect(result[:activated]).to be true
|
|
72
|
+
expect(proposal.status).to eq(:active)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'transitions to :degraded when integration test fails' do
|
|
76
|
+
prop_result = proposer.propose_concept(name: 'lex-test-degraded', category: :cognition,
|
|
77
|
+
description: 'test degraded path', enrich: false)
|
|
78
|
+
proposal_id = prop_result[:proposal][:id]
|
|
79
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
80
|
+
proposal.transition!(:building)
|
|
81
|
+
proposal.transition!(:passing)
|
|
82
|
+
|
|
83
|
+
allow(Legion::Extensions::MindGrowth::Runners::Wirer)
|
|
84
|
+
.to receive(:wire_extension)
|
|
85
|
+
.and_return({ success: true, extension: proposal.name, phase: :working_memory })
|
|
86
|
+
allow(Legion::Extensions::MindGrowth::Runners::IntegrationTester)
|
|
87
|
+
.to receive(:test_extension_in_tick)
|
|
88
|
+
.and_return({ success: false, reason: :runner_not_found })
|
|
89
|
+
|
|
90
|
+
result = orchestrator.post_build_pipeline(proposal_id: proposal_id)
|
|
91
|
+
expect(result[:activated]).to be false
|
|
92
|
+
expect(proposal.status).to eq(:degraded)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'ignores unknown keyword arguments' do
|
|
96
|
+
prop_result = proposer.propose_concept(name: 'lex-test-kwargs', category: :cognition,
|
|
97
|
+
description: 'test kwargs', enrich: false)
|
|
98
|
+
proposal_id = prop_result[:proposal][:id]
|
|
99
|
+
proposal = proposer.get_proposal_object(proposal_id)
|
|
100
|
+
proposal.transition!(:building)
|
|
101
|
+
proposal.transition!(:passing)
|
|
102
|
+
|
|
103
|
+
expect { orchestrator.post_build_pipeline(proposal_id: proposal_id, unknown: :val) }.not_to raise_error
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-mind-growth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -141,7 +141,9 @@ files:
|
|
|
141
141
|
- lib/legion/extensions/mind_growth/helpers/constants.rb
|
|
142
142
|
- lib/legion/extensions/mind_growth/helpers/fitness_evaluator.rb
|
|
143
143
|
- lib/legion/extensions/mind_growth/helpers/phase_allocator.rb
|
|
144
|
+
- lib/legion/extensions/mind_growth/helpers/proposal_persistence.rb
|
|
144
145
|
- lib/legion/extensions/mind_growth/helpers/proposal_store.rb
|
|
146
|
+
- lib/legion/extensions/mind_growth/hooks/governance_listener.rb
|
|
145
147
|
- lib/legion/extensions/mind_growth/runners/analyzer.rb
|
|
146
148
|
- lib/legion/extensions/mind_growth/runners/builder.rb
|
|
147
149
|
- lib/legion/extensions/mind_growth/runners/competitive_evolver.rb
|
|
@@ -167,9 +169,13 @@ files:
|
|
|
167
169
|
- spec/legion/extensions/mind_growth/helpers/cognitive_models_spec.rb
|
|
168
170
|
- spec/legion/extensions/mind_growth/helpers/composition_map_spec.rb
|
|
169
171
|
- spec/legion/extensions/mind_growth/helpers/concept_proposal_spec.rb
|
|
172
|
+
- spec/legion/extensions/mind_growth/helpers/constants_spec.rb
|
|
170
173
|
- spec/legion/extensions/mind_growth/helpers/fitness_evaluator_spec.rb
|
|
171
174
|
- spec/legion/extensions/mind_growth/helpers/phase_allocator_spec.rb
|
|
175
|
+
- spec/legion/extensions/mind_growth/helpers/proposal_persistence_spec.rb
|
|
172
176
|
- spec/legion/extensions/mind_growth/helpers/proposal_store_spec.rb
|
|
177
|
+
- spec/legion/extensions/mind_growth/hooks/governance_callback_spec.rb
|
|
178
|
+
- spec/legion/extensions/mind_growth/integration_spec.rb
|
|
173
179
|
- spec/legion/extensions/mind_growth/runners/analyzer_spec.rb
|
|
174
180
|
- spec/legion/extensions/mind_growth/runners/builder_spec.rb
|
|
175
181
|
- spec/legion/extensions/mind_growth/runners/competitive_evolver_spec.rb
|
|
@@ -182,6 +188,7 @@ files:
|
|
|
182
188
|
- spec/legion/extensions/mind_growth/runners/integration_tester_spec.rb
|
|
183
189
|
- spec/legion/extensions/mind_growth/runners/monitor_spec.rb
|
|
184
190
|
- spec/legion/extensions/mind_growth/runners/orchestrator_spec.rb
|
|
191
|
+
- spec/legion/extensions/mind_growth/runners/post_build_pipeline_spec.rb
|
|
185
192
|
- spec/legion/extensions/mind_growth/runners/proposer_spec.rb
|
|
186
193
|
- spec/legion/extensions/mind_growth/runners/retrospective_spec.rb
|
|
187
194
|
- spec/legion/extensions/mind_growth/runners/risk_assessor_spec.rb
|