lex-swarm 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1b72b8c33768eb24380924cb9f33a342dd5e0e07fa1daaf151089de1d38e141e
4
+ data.tar.gz: 790c2c25675b41bb049410a15f21585fc361a00963d771af73752b612c2d15e9
5
+ SHA512:
6
+ metadata.gz: 9ca0b32d4b9c8af87086c777c71822c32228b195e0e1c7cb825c89c9a883dd97cee6e8815e228ca4d4a8a07ce1d31813eca91e09191405f06b45c9ed0c347453
7
+ data.tar.gz: 6765b85f142bebdb96f537860d4984e56c762551d87da9b6fd3ab0a3b028c6f6fc3236e46b50bf445a020bf1d570c6fae3791af408c6cdc77d6ea77f68382c64
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
data/lex-swarm.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/swarm/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-swarm'
7
+ spec.version = Legion::Extensions::Swarm::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Swarm'
12
+ spec.description = 'Swarm orchestration and charter system for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-swarm'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-swarm'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-swarm'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-swarm'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-swarm/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-swarm.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Swarm
8
+ module Actor
9
+ class StaleCheck < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Swarm::Runners::Swarm
12
+ end
13
+
14
+ def runner_function
15
+ 'timeout_stale_swarms'
16
+ end
17
+
18
+ def time
19
+ 3600
20
+ end
21
+
22
+ def run_now?
23
+ false
24
+ end
25
+
26
+ def use_runner?
27
+ false
28
+ end
29
+
30
+ def check_subtask?
31
+ false
32
+ end
33
+
34
+ def generate_task?
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm/helpers/charter'
4
+ require 'legion/extensions/swarm/helpers/swarm_store'
5
+ require 'legion/extensions/swarm/runners/swarm'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Swarm
10
+ class Client
11
+ include Runners::Swarm
12
+
13
+ def initialize(**)
14
+ @swarm_store = Helpers::SwarmStore.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :swarm_store
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Swarm
8
+ module Helpers
9
+ module Charter
10
+ ROLES = %i[finder fixer validator reviewer coordinator].freeze
11
+ STATUSES = %i[forming active completing disbanded failed].freeze
12
+ SWARM_STALE_TIMEOUT = 86_400 # 24 hours
13
+
14
+ module_function
15
+
16
+ def new_charter(name:, objective:, roles: [], max_agents: 10, timeout: 3600)
17
+ {
18
+ charter_id: SecureRandom.uuid,
19
+ name: name,
20
+ objective: objective,
21
+ roles: roles.empty? ? ROLES : roles,
22
+ max_agents: max_agents,
23
+ timeout: timeout,
24
+ status: :forming,
25
+ agents: [],
26
+ created_at: Time.now.utc,
27
+ completed_at: nil
28
+ }
29
+ end
30
+
31
+ def valid_role?(role)
32
+ ROLES.include?(role)
33
+ end
34
+
35
+ def valid_status?(status)
36
+ STATUSES.include?(status)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ module Helpers
7
+ class SwarmStore
8
+ attr_reader :charters
9
+
10
+ def initialize
11
+ @charters = {}
12
+ end
13
+
14
+ def create(charter)
15
+ @charters[charter[:charter_id]] = charter
16
+ charter[:charter_id]
17
+ end
18
+
19
+ def get(charter_id)
20
+ @charters[charter_id]
21
+ end
22
+
23
+ def join(charter_id, agent_id:, role:)
24
+ charter = @charters[charter_id]
25
+ return :not_found unless charter
26
+ return :full if charter[:agents].size >= charter[:max_agents]
27
+ return :already_joined if charter[:agents].any? { |a| a[:agent_id] == agent_id }
28
+
29
+ charter[:agents] << { agent_id: agent_id, role: role, joined_at: Time.now.utc }
30
+ charter[:status] = :active if charter[:status] == :forming
31
+ :joined
32
+ end
33
+
34
+ def leave(charter_id, agent_id:)
35
+ charter = @charters[charter_id]
36
+ return :not_found unless charter
37
+
38
+ removed = charter[:agents].reject! { |a| a[:agent_id] == agent_id }
39
+ removed ? :left : :not_member
40
+ end
41
+
42
+ def complete(charter_id, outcome:)
43
+ charter = @charters[charter_id]
44
+ return nil unless charter
45
+
46
+ charter[:status] = outcome == :success ? :completing : :failed
47
+ charter[:completed_at] = Time.now.utc
48
+ charter[:outcome] = outcome
49
+ charter
50
+ end
51
+
52
+ def active_charters
53
+ @charters.values.select { |c| c[:status] == :active }
54
+ end
55
+
56
+ def count
57
+ @charters.size
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ module Runners
7
+ module Swarm
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_swarm(name:, objective:, roles: [], max_agents: 10, timeout: 3600, **) # rubocop:disable Metrics/ParameterLists
12
+ charter = Helpers::Charter.new_charter(name: name, objective: objective,
13
+ roles: roles, max_agents: max_agents, timeout: timeout)
14
+ id = swarm_store.create(charter)
15
+ Legion::Logging.info "[swarm] created: id=#{id[0..7]} name=#{name} max=#{max_agents} roles=#{roles.join(',')}"
16
+ { charter_id: id, name: name, status: :forming }
17
+ end
18
+
19
+ def join_swarm(charter_id:, agent_id:, role:, **)
20
+ return { error: :invalid_role, valid: Helpers::Charter::ROLES } unless Helpers::Charter.valid_role?(role)
21
+
22
+ result = swarm_store.join(charter_id, agent_id: agent_id, role: role)
23
+ Legion::Logging.debug "[swarm] join: charter=#{charter_id[0..7]} agent=#{agent_id} role=#{role} result=#{result}"
24
+ Legion::Logging.info "[swarm] agent joined: charter=#{charter_id[0..7]} agent=#{agent_id} role=#{role}" if result == :joined
25
+ {
26
+ joined: { joined: true, charter_id: charter_id },
27
+ full: { error: :swarm_full },
28
+ not_found: { error: :not_found },
29
+ already_joined: { error: :already_joined }
30
+ }[result]
31
+ end
32
+
33
+ def leave_swarm(charter_id:, agent_id:, **)
34
+ result = swarm_store.leave(charter_id, agent_id: agent_id)
35
+ Legion::Logging.debug "[swarm] leave: charter=#{charter_id[0..7]} agent=#{agent_id} result=#{result}"
36
+ Legion::Logging.info "[swarm] agent left: charter=#{charter_id[0..7]} agent=#{agent_id}" if result == :left
37
+ {
38
+ left: { left: true },
39
+ not_found: { error: :not_found },
40
+ not_member: { error: :not_member }
41
+ }[result]
42
+ end
43
+
44
+ def complete_swarm(charter_id:, outcome:, **)
45
+ result = swarm_store.complete(charter_id, outcome: outcome)
46
+ if result
47
+ Legion::Logging.info "[swarm] completed: charter=#{charter_id[0..7]} outcome=#{outcome}"
48
+ else
49
+ Legion::Logging.debug "[swarm] complete failed: charter=#{charter_id[0..7]} not found"
50
+ end
51
+ result ? { completed: true, outcome: outcome } : { error: :not_found }
52
+ end
53
+
54
+ def get_swarm(charter_id:, **)
55
+ charter = swarm_store.get(charter_id)
56
+ Legion::Logging.debug "[swarm] get: charter=#{charter_id[0..7]} found=#{!charter.nil?}"
57
+ charter ? { found: true, charter: charter } : { found: false }
58
+ end
59
+
60
+ def active_swarms(**)
61
+ charters = swarm_store.active_charters
62
+ Legion::Logging.debug "[swarm] active: count=#{charters.size}"
63
+ { charters: charters, count: charters.size }
64
+ end
65
+
66
+ def swarm_status(**)
67
+ total = swarm_store.count
68
+ Legion::Logging.debug "[swarm] status: total=#{total}"
69
+ { total: total }
70
+ end
71
+
72
+ def timeout_stale_swarms(**)
73
+ now = Time.now.utc
74
+ timeout = Helpers::Charter::SWARM_STALE_TIMEOUT
75
+ disbanded = []
76
+ swarm_store.charters.each_value do |charter|
77
+ next unless %i[forming active].include?(charter[:status])
78
+ next unless now - charter[:created_at] > timeout
79
+
80
+ charter[:status] = :disbanded
81
+ disbanded << charter[:charter_id]
82
+ end
83
+ Legion::Logging.debug "[swarm] stale check: checked=#{swarm_store.charters.size} disbanded=#{disbanded.size}"
84
+ { checked: swarm_store.charters.size, disbanded: disbanded.size, disbanded_ids: disbanded }
85
+ end
86
+
87
+ private
88
+
89
+ def swarm_store
90
+ @swarm_store ||= Helpers::SwarmStore.new
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm/version'
4
+ require 'legion/extensions/swarm/helpers/charter'
5
+ require 'legion/extensions/swarm/helpers/swarm_store'
6
+ require 'legion/extensions/swarm/runners/swarm'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Swarm
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Actors
6
+ class Every # rubocop:disable Lint/EmptyClass
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
13
+
14
+ require_relative '../../../../../lib/legion/extensions/swarm/actors/stale_check'
15
+
16
+ RSpec.describe Legion::Extensions::Swarm::Actor::StaleCheck do
17
+ subject(:actor) { described_class.new }
18
+
19
+ describe '#runner_class' do
20
+ it { expect(actor.runner_class).to eq Legion::Extensions::Swarm::Runners::Swarm }
21
+ end
22
+
23
+ describe '#runner_function' do
24
+ it { expect(actor.runner_function).to eq 'timeout_stale_swarms' }
25
+ end
26
+
27
+ describe '#time' do
28
+ it { expect(actor.time).to eq 3600 }
29
+ end
30
+
31
+ describe '#run_now?' do
32
+ it { expect(actor.run_now?).to be false }
33
+ end
34
+
35
+ describe '#use_runner?' do
36
+ it { expect(actor.use_runner?).to be false }
37
+ end
38
+
39
+ describe '#check_subtask?' do
40
+ it { expect(actor.check_subtask?).to be false }
41
+ end
42
+
43
+ describe '#generate_task?' do
44
+ it { expect(actor.generate_task?).to be false }
45
+ end
46
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm/client'
4
+
5
+ RSpec.describe Legion::Extensions::Swarm::Client do
6
+ it 'responds to swarm runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:create_swarm)
9
+ expect(client).to respond_to(:join_swarm)
10
+ expect(client).to respond_to(:leave_swarm)
11
+ expect(client).to respond_to(:complete_swarm)
12
+ expect(client).to respond_to(:get_swarm)
13
+ expect(client).to respond_to(:active_swarms)
14
+ expect(client).to respond_to(:swarm_status)
15
+ end
16
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Swarm::Helpers::Charter do
4
+ describe 'constants' do
5
+ it 'defines ROLES as the five swarm roles' do
6
+ expect(described_class::ROLES).to eq(%i[finder fixer validator reviewer coordinator])
7
+ end
8
+
9
+ it 'defines STATUSES as the five lifecycle statuses' do
10
+ expect(described_class::STATUSES).to eq(%i[forming active completing disbanded failed])
11
+ end
12
+ end
13
+
14
+ describe '.new_charter' do
15
+ let(:charter) { described_class.new_charter(name: 'test-swarm', objective: 'fix all bugs') }
16
+
17
+ it 'returns a hash with a UUID charter_id' do
18
+ expect(charter[:charter_id]).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
19
+ end
20
+
21
+ it 'stores the provided name' do
22
+ expect(charter[:name]).to eq('test-swarm')
23
+ end
24
+
25
+ it 'stores the provided objective' do
26
+ expect(charter[:objective]).to eq('fix all bugs')
27
+ end
28
+
29
+ it 'defaults status to :forming' do
30
+ expect(charter[:status]).to eq(:forming)
31
+ end
32
+
33
+ it 'starts with an empty agents array' do
34
+ expect(charter[:agents]).to eq([])
35
+ end
36
+
37
+ it 'sets completed_at to nil' do
38
+ expect(charter[:completed_at]).to be_nil
39
+ end
40
+
41
+ it 'records created_at as a UTC Time' do
42
+ before = Time.now.utc
43
+ c = described_class.new_charter(name: 'n', objective: 'o')
44
+ after = Time.now.utc
45
+ expect(c[:created_at]).to be_between(before, after)
46
+ end
47
+
48
+ it 'defaults to all ROLES when roles is empty' do
49
+ expect(charter[:roles]).to eq(described_class::ROLES)
50
+ end
51
+
52
+ it 'uses the provided roles array when non-empty' do
53
+ c = described_class.new_charter(name: 'n', objective: 'o', roles: %i[finder fixer])
54
+ expect(c[:roles]).to eq(%i[finder fixer])
55
+ end
56
+
57
+ it 'defaults max_agents to 10' do
58
+ expect(charter[:max_agents]).to eq(10)
59
+ end
60
+
61
+ it 'accepts a custom max_agents value' do
62
+ c = described_class.new_charter(name: 'n', objective: 'o', max_agents: 5)
63
+ expect(c[:max_agents]).to eq(5)
64
+ end
65
+
66
+ it 'defaults timeout to 3600' do
67
+ expect(charter[:timeout]).to eq(3600)
68
+ end
69
+
70
+ it 'accepts a custom timeout value' do
71
+ c = described_class.new_charter(name: 'n', objective: 'o', timeout: 7200)
72
+ expect(c[:timeout]).to eq(7200)
73
+ end
74
+
75
+ it 'generates unique charter_ids for each call' do
76
+ c1 = described_class.new_charter(name: 'a', objective: 'b')
77
+ c2 = described_class.new_charter(name: 'a', objective: 'b')
78
+ expect(c1[:charter_id]).not_to eq(c2[:charter_id])
79
+ end
80
+ end
81
+
82
+ describe '.valid_role?' do
83
+ it 'returns true for all defined ROLES' do
84
+ described_class::ROLES.each do |role|
85
+ expect(described_class.valid_role?(role)).to be true
86
+ end
87
+ end
88
+
89
+ it 'returns true for :finder' do
90
+ expect(described_class.valid_role?(:finder)).to be true
91
+ end
92
+
93
+ it 'returns true for :coordinator' do
94
+ expect(described_class.valid_role?(:coordinator)).to be true
95
+ end
96
+
97
+ it 'returns false for an unknown role' do
98
+ expect(described_class.valid_role?(:operator)).to be false
99
+ end
100
+
101
+ it 'returns false for nil' do
102
+ expect(described_class.valid_role?(nil)).to be false
103
+ end
104
+
105
+ it 'returns false for a string version of a valid role' do
106
+ expect(described_class.valid_role?('finder')).to be false
107
+ end
108
+ end
109
+
110
+ describe '.valid_status?' do
111
+ it 'returns true for all defined STATUSES' do
112
+ described_class::STATUSES.each do |status|
113
+ expect(described_class.valid_status?(status)).to be true
114
+ end
115
+ end
116
+
117
+ it 'returns true for :forming' do
118
+ expect(described_class.valid_status?(:forming)).to be true
119
+ end
120
+
121
+ it 'returns true for :disbanded' do
122
+ expect(described_class.valid_status?(:disbanded)).to be true
123
+ end
124
+
125
+ it 'returns false for an unknown status' do
126
+ expect(described_class.valid_status?(:pending)).to be false
127
+ end
128
+
129
+ it 'returns false for nil' do
130
+ expect(described_class.valid_status?(nil)).to be false
131
+ end
132
+
133
+ it 'returns false for a string version of a valid status' do
134
+ expect(described_class.valid_status?('active')).to be false
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Swarm::Helpers::SwarmStore do
4
+ let(:store) { described_class.new }
5
+ let(:charter_helper) { Legion::Extensions::Swarm::Helpers::Charter }
6
+
7
+ let(:charter) { charter_helper.new_charter(name: 'test-swarm', objective: 'fix bugs') }
8
+
9
+ describe '#initialize' do
10
+ it 'starts with an empty charters hash' do
11
+ expect(store.charters).to eq({})
12
+ end
13
+ end
14
+
15
+ describe '#create' do
16
+ it 'stores the charter by its charter_id' do
17
+ id = store.create(charter)
18
+ expect(store.charters[id]).to eq(charter)
19
+ end
20
+
21
+ it 'returns the charter_id' do
22
+ id = store.create(charter)
23
+ expect(id).to eq(charter[:charter_id])
24
+ end
25
+
26
+ it 'stores multiple charters independently' do
27
+ c1 = charter_helper.new_charter(name: 'swarm-a', objective: 'a')
28
+ c2 = charter_helper.new_charter(name: 'swarm-b', objective: 'b')
29
+ store.create(c1)
30
+ store.create(c2)
31
+ expect(store.count).to eq(2)
32
+ end
33
+ end
34
+
35
+ describe '#get' do
36
+ it 'returns the charter for a known id' do
37
+ store.create(charter)
38
+ expect(store.get(charter[:charter_id])).to eq(charter)
39
+ end
40
+
41
+ it 'returns nil for an unknown id' do
42
+ expect(store.get('nonexistent-id')).to be_nil
43
+ end
44
+ end
45
+
46
+ describe '#join' do
47
+ before { store.create(charter) }
48
+
49
+ it 'returns :joined when agent successfully joins' do
50
+ result = store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
51
+ expect(result).to eq(:joined)
52
+ end
53
+
54
+ it 'adds the agent to the charter agents list' do
55
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
56
+ agents = store.get(charter[:charter_id])[:agents]
57
+ expect(agents.size).to eq(1)
58
+ expect(agents.first[:agent_id]).to eq('agent-1')
59
+ end
60
+
61
+ it 'stores the role on the joined agent record' do
62
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
63
+ expect(store.get(charter[:charter_id])[:agents].first[:role]).to eq(:finder)
64
+ end
65
+
66
+ it 'records joined_at as a UTC Time' do
67
+ before = Time.now.utc
68
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
69
+ after = Time.now.utc
70
+ ts = store.get(charter[:charter_id])[:agents].first[:joined_at]
71
+ expect(ts).to be_between(before, after)
72
+ end
73
+
74
+ it 'transitions status from :forming to :active on first join' do
75
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
76
+ expect(store.get(charter[:charter_id])[:status]).to eq(:active)
77
+ end
78
+
79
+ it 'keeps status :active when a second agent joins' do
80
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
81
+ store.join(charter[:charter_id], agent_id: 'agent-2', role: :fixer)
82
+ expect(store.get(charter[:charter_id])[:status]).to eq(:active)
83
+ end
84
+
85
+ it 'returns :not_found for unknown charter_id' do
86
+ result = store.join('unknown-id', agent_id: 'agent-1', role: :finder)
87
+ expect(result).to eq(:not_found)
88
+ end
89
+
90
+ it 'returns :already_joined when the agent is already a member' do
91
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
92
+ result = store.join(charter[:charter_id], agent_id: 'agent-1', role: :fixer)
93
+ expect(result).to eq(:already_joined)
94
+ end
95
+
96
+ it 'returns :full when max_agents is reached' do
97
+ small_charter = charter_helper.new_charter(name: 'tiny', objective: 'min', max_agents: 1)
98
+ store.create(small_charter)
99
+ store.join(small_charter[:charter_id], agent_id: 'a1', role: :finder)
100
+ result = store.join(small_charter[:charter_id], agent_id: 'a2', role: :fixer)
101
+ expect(result).to eq(:full)
102
+ end
103
+
104
+ it 'does not modify the charter when returning :full' do
105
+ small_charter = charter_helper.new_charter(name: 'tiny', objective: 'min', max_agents: 1)
106
+ store.create(small_charter)
107
+ store.join(small_charter[:charter_id], agent_id: 'a1', role: :finder)
108
+ store.join(small_charter[:charter_id], agent_id: 'a2', role: :fixer)
109
+ expect(store.get(small_charter[:charter_id])[:agents].size).to eq(1)
110
+ end
111
+ end
112
+
113
+ describe '#leave' do
114
+ before do
115
+ store.create(charter)
116
+ store.join(charter[:charter_id], agent_id: 'agent-1', role: :finder)
117
+ end
118
+
119
+ it 'returns :left when agent successfully leaves' do
120
+ result = store.leave(charter[:charter_id], agent_id: 'agent-1')
121
+ expect(result).to eq(:left)
122
+ end
123
+
124
+ it 'removes the agent from the agents list' do
125
+ store.leave(charter[:charter_id], agent_id: 'agent-1')
126
+ expect(store.get(charter[:charter_id])[:agents]).to be_empty
127
+ end
128
+
129
+ it 'returns :not_member for an agent not in the charter' do
130
+ result = store.leave(charter[:charter_id], agent_id: 'agent-99')
131
+ expect(result).to eq(:not_member)
132
+ end
133
+
134
+ it 'returns :not_found for unknown charter_id' do
135
+ result = store.leave('unknown-id', agent_id: 'agent-1')
136
+ expect(result).to eq(:not_found)
137
+ end
138
+
139
+ it 'leaves other members unaffected' do
140
+ store.join(charter[:charter_id], agent_id: 'agent-2', role: :fixer)
141
+ store.leave(charter[:charter_id], agent_id: 'agent-1')
142
+ agents = store.get(charter[:charter_id])[:agents]
143
+ expect(agents.map { |a| a[:agent_id] }).to contain_exactly('agent-2')
144
+ end
145
+ end
146
+
147
+ describe '#complete' do
148
+ before { store.create(charter) }
149
+
150
+ it 'sets status to :completing on success outcome' do
151
+ store.complete(charter[:charter_id], outcome: :success)
152
+ expect(store.get(charter[:charter_id])[:status]).to eq(:completing)
153
+ end
154
+
155
+ it 'sets status to :failed on non-success outcome' do
156
+ store.complete(charter[:charter_id], outcome: :failure)
157
+ expect(store.get(charter[:charter_id])[:status]).to eq(:failed)
158
+ end
159
+
160
+ it 'sets status to :failed for an arbitrary non-success outcome' do
161
+ store.complete(charter[:charter_id], outcome: :timeout)
162
+ expect(store.get(charter[:charter_id])[:status]).to eq(:failed)
163
+ end
164
+
165
+ it 'records completed_at as a UTC Time' do
166
+ before = Time.now.utc
167
+ store.complete(charter[:charter_id], outcome: :success)
168
+ after = Time.now.utc
169
+ ts = store.get(charter[:charter_id])[:completed_at]
170
+ expect(ts).to be_between(before, after)
171
+ end
172
+
173
+ it 'stores the outcome on the charter' do
174
+ store.complete(charter[:charter_id], outcome: :success)
175
+ expect(store.get(charter[:charter_id])[:outcome]).to eq(:success)
176
+ end
177
+
178
+ it 'returns the updated charter hash' do
179
+ result = store.complete(charter[:charter_id], outcome: :success)
180
+ expect(result[:charter_id]).to eq(charter[:charter_id])
181
+ end
182
+
183
+ it 'returns nil for unknown charter_id' do
184
+ expect(store.complete('unknown-id', outcome: :success)).to be_nil
185
+ end
186
+ end
187
+
188
+ describe '#active_charters' do
189
+ it 'returns an empty array when no charters exist' do
190
+ expect(store.active_charters).to eq([])
191
+ end
192
+
193
+ it 'excludes charters with :forming status' do
194
+ store.create(charter)
195
+ expect(store.active_charters).to eq([])
196
+ end
197
+
198
+ it 'returns charters with :active status' do
199
+ store.create(charter)
200
+ store.join(charter[:charter_id], agent_id: 'a1', role: :finder)
201
+ expect(store.active_charters.size).to eq(1)
202
+ end
203
+
204
+ it 'excludes charters with :completing status' do
205
+ store.create(charter)
206
+ store.join(charter[:charter_id], agent_id: 'a1', role: :finder)
207
+ store.complete(charter[:charter_id], outcome: :success)
208
+ expect(store.active_charters).to eq([])
209
+ end
210
+
211
+ it 'excludes charters with :failed status' do
212
+ store.create(charter)
213
+ store.join(charter[:charter_id], agent_id: 'a1', role: :finder)
214
+ store.complete(charter[:charter_id], outcome: :error)
215
+ expect(store.active_charters).to eq([])
216
+ end
217
+
218
+ it 'returns only the active subset among multiple charters' do
219
+ c1 = charter_helper.new_charter(name: 'active', objective: 'a')
220
+ c2 = charter_helper.new_charter(name: 'forming', objective: 'b')
221
+ store.create(c1)
222
+ store.create(c2)
223
+ store.join(c1[:charter_id], agent_id: 'a1', role: :finder)
224
+ expect(store.active_charters.size).to eq(1)
225
+ expect(store.active_charters.first[:charter_id]).to eq(c1[:charter_id])
226
+ end
227
+ end
228
+
229
+ describe '#count' do
230
+ it 'returns 0 for an empty store' do
231
+ expect(store.count).to eq(0)
232
+ end
233
+
234
+ it 'returns the number of charters stored' do
235
+ store.create(charter)
236
+ expect(store.count).to eq(1)
237
+ end
238
+
239
+ it 'counts all charters regardless of status' do
240
+ c1 = charter_helper.new_charter(name: 'a', objective: 'a')
241
+ c2 = charter_helper.new_charter(name: 'b', objective: 'b')
242
+ store.create(c1)
243
+ store.create(c2)
244
+ store.join(c1[:charter_id], agent_id: 'a1', role: :finder)
245
+ store.complete(c1[:charter_id], outcome: :success)
246
+ expect(store.count).to eq(2)
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm/client'
4
+
5
+ RSpec.describe Legion::Extensions::Swarm::Runners::Swarm do
6
+ let(:client) { Legion::Extensions::Swarm::Client.new }
7
+
8
+ describe '#create_swarm' do
9
+ it 'creates a swarm charter' do
10
+ result = client.create_swarm(name: 'test-swarm', objective: 'fix bugs')
11
+ expect(result[:charter_id]).to match(/\A[0-9a-f-]{36}\z/)
12
+ expect(result[:status]).to eq(:forming)
13
+ end
14
+ end
15
+
16
+ describe '#join_swarm' do
17
+ it 'adds agent to swarm' do
18
+ swarm = client.create_swarm(name: 'test', objective: 'test')
19
+ result = client.join_swarm(charter_id: swarm[:charter_id], agent_id: 'a1', role: :finder)
20
+ expect(result[:joined]).to be true
21
+ end
22
+
23
+ it 'rejects invalid role' do
24
+ swarm = client.create_swarm(name: 'test', objective: 'test')
25
+ result = client.join_swarm(charter_id: swarm[:charter_id], agent_id: 'a1', role: :invalid)
26
+ expect(result[:error]).to eq(:invalid_role)
27
+ end
28
+
29
+ it 'prevents duplicate joins' do
30
+ swarm = client.create_swarm(name: 'test', objective: 'test')
31
+ client.join_swarm(charter_id: swarm[:charter_id], agent_id: 'a1', role: :finder)
32
+ result = client.join_swarm(charter_id: swarm[:charter_id], agent_id: 'a1', role: :fixer)
33
+ expect(result[:error]).to eq(:already_joined)
34
+ end
35
+ end
36
+
37
+ describe '#complete_swarm' do
38
+ it 'completes a swarm' do
39
+ swarm = client.create_swarm(name: 'test', objective: 'test')
40
+ result = client.complete_swarm(charter_id: swarm[:charter_id], outcome: :success)
41
+ expect(result[:completed]).to be true
42
+ end
43
+ end
44
+
45
+ describe '#active_swarms' do
46
+ it 'lists active swarms' do
47
+ swarm = client.create_swarm(name: 'test', objective: 'test')
48
+ client.join_swarm(charter_id: swarm[:charter_id], agent_id: 'a1', role: :finder)
49
+ result = client.active_swarms
50
+ expect(result[:count]).to eq(1)
51
+ end
52
+ end
53
+
54
+ describe '#timeout_stale_swarms' do
55
+ it 'returns zero disbanded when store is empty' do
56
+ result = client.timeout_stale_swarms
57
+ expect(result[:disbanded]).to eq(0)
58
+ expect(result[:disbanded_ids]).to eq([])
59
+ end
60
+
61
+ it 'does not disband a freshly created swarm' do
62
+ client.create_swarm(name: 'fresh', objective: 'test')
63
+ result = client.timeout_stale_swarms
64
+ expect(result[:disbanded]).to eq(0)
65
+ end
66
+
67
+ it 'disbands a stale forming swarm' do
68
+ swarm = client.create_swarm(name: 'stale', objective: 'test')
69
+ # backdate created_at past the timeout threshold
70
+ store = client.send(:swarm_store)
71
+ store.charters[swarm[:charter_id]][:created_at] = Time.now.utc - 90_000
72
+ result = client.timeout_stale_swarms
73
+ expect(result[:disbanded]).to eq(1)
74
+ expect(result[:disbanded_ids]).to include(swarm[:charter_id])
75
+ end
76
+
77
+ it 'reports correct checked count' do
78
+ client.create_swarm(name: 's1', objective: 'test')
79
+ client.create_swarm(name: 's2', objective: 'test')
80
+ result = client.timeout_stale_swarms
81
+ expect(result[:checked]).to eq(2)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/swarm'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-swarm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Swarm orchestration and charter system for brain-modeled agentic AI
27
+ email:
28
+ - matthewdiverson@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - Gemfile
34
+ - lex-swarm.gemspec
35
+ - lib/legion/extensions/swarm.rb
36
+ - lib/legion/extensions/swarm/actors/stale_check.rb
37
+ - lib/legion/extensions/swarm/client.rb
38
+ - lib/legion/extensions/swarm/helpers/charter.rb
39
+ - lib/legion/extensions/swarm/helpers/swarm_store.rb
40
+ - lib/legion/extensions/swarm/runners/swarm.rb
41
+ - lib/legion/extensions/swarm/version.rb
42
+ - spec/legion/extensions/swarm/actors/stale_check_spec.rb
43
+ - spec/legion/extensions/swarm/client_spec.rb
44
+ - spec/legion/extensions/swarm/helpers/charter_spec.rb
45
+ - spec/legion/extensions/swarm/helpers/swarm_store_spec.rb
46
+ - spec/legion/extensions/swarm/runners/swarm_spec.rb
47
+ - spec/spec_helper.rb
48
+ homepage: https://github.com/LegionIO/lex-swarm
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ homepage_uri: https://github.com/LegionIO/lex-swarm
53
+ source_code_uri: https://github.com/LegionIO/lex-swarm
54
+ documentation_uri: https://github.com/LegionIO/lex-swarm
55
+ changelog_uri: https://github.com/LegionIO/lex-swarm
56
+ bug_tracker_uri: https://github.com/LegionIO/lex-swarm/issues
57
+ rubygems_mfa_required: 'true'
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '3.4'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.6.9
73
+ specification_version: 4
74
+ summary: LEX Swarm
75
+ test_files: []