activematrix 0.0.7 → 0.0.9

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +96 -28
  3. data/app/models/active_matrix/agent.rb +36 -1
  4. data/app/models/active_matrix/agent_store.rb +29 -0
  5. data/app/models/active_matrix/application_record.rb +8 -0
  6. data/app/models/active_matrix/chat_session.rb +29 -0
  7. data/app/models/active_matrix/knowledge_base.rb +26 -0
  8. data/exe/activematrix +7 -0
  9. data/lib/active_matrix/agent_manager.rb +160 -121
  10. data/lib/active_matrix/agent_registry.rb +25 -21
  11. data/lib/active_matrix/api.rb +8 -2
  12. data/lib/active_matrix/async_query.rb +58 -0
  13. data/lib/active_matrix/bot/base.rb +3 -3
  14. data/lib/active_matrix/bot/builtin_commands.rb +188 -0
  15. data/lib/active_matrix/bot/command_parser.rb +175 -0
  16. data/lib/active_matrix/cli.rb +273 -0
  17. data/lib/active_matrix/client.rb +21 -6
  18. data/lib/active_matrix/client_pool.rb +38 -27
  19. data/lib/active_matrix/daemon/probe_server.rb +118 -0
  20. data/lib/active_matrix/daemon/signal_handler.rb +156 -0
  21. data/lib/active_matrix/daemon/worker.rb +109 -0
  22. data/lib/active_matrix/daemon.rb +236 -0
  23. data/lib/active_matrix/engine.rb +7 -3
  24. data/lib/active_matrix/errors.rb +1 -1
  25. data/lib/active_matrix/event_router.rb +61 -49
  26. data/lib/active_matrix/events.rb +1 -0
  27. data/lib/active_matrix/instrumentation.rb +148 -0
  28. data/lib/active_matrix/memory/agent_memory.rb +7 -21
  29. data/lib/active_matrix/memory/conversation_memory.rb +4 -20
  30. data/lib/active_matrix/memory/global_memory.rb +15 -30
  31. data/lib/active_matrix/message_dispatcher.rb +197 -0
  32. data/lib/active_matrix/metrics.rb +424 -0
  33. data/lib/active_matrix/presence_manager.rb +181 -0
  34. data/lib/active_matrix/telemetry.rb +134 -0
  35. data/lib/active_matrix/version.rb +1 -1
  36. data/lib/active_matrix.rb +12 -2
  37. data/lib/generators/active_matrix/install/install_generator.rb +3 -15
  38. data/lib/generators/active_matrix/install/templates/README +5 -2
  39. data/lib/generators/active_matrix/install/templates/active_matrix.yml +32 -0
  40. metadata +142 -45
  41. data/lib/active_matrix/protocols/cs/message_relationships.rb +0 -318
  42. data/lib/generators/active_matrix/install/templates/create_agent_memories.rb +0 -17
  43. data/lib/generators/active_matrix/install/templates/create_conversation_contexts.rb +0 -21
  44. data/lib/generators/active_matrix/install/templates/create_global_memories.rb +0 -20
  45. data/lib/generators/active_matrix/install/templates/create_matrix_agents.rb +0 -26
@@ -1,26 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'singleton'
4
- require 'concurrent'
4
+ require 'async'
5
+ require 'async/semaphore'
6
+ require 'async/condition'
5
7
 
6
8
  module ActiveMatrix
7
- # Manages a pool of Matrix client connections for efficiency
9
+ # Manages Matrix client connections per homeserver with rate limiting.
10
+ #
11
+ # NOTE: Despite the name, this is not a traditional connection pool.
12
+ # Each agent gets a dedicated long-lived client. The "pool" provides:
13
+ # - Semaphore-based rate limiting on client creation per homeserver
14
+ # - Tracking of active clients for health monitoring
15
+ #
16
+ # Clients are NOT returned to the pool after use - they remain active
17
+ # for the agent's lifetime. The semaphore prevents too many agents
18
+ # from connecting to a single homeserver simultaneously.
19
+ #
8
20
  class ClientPool
9
21
  include Singleton
10
22
  include ActiveMatrix::Logging
11
23
 
12
24
  def initialize
13
- @pools = Concurrent::Hash.new
25
+ @pools = {}
14
26
  @config = ActiveMatrix.config
15
27
  @mutex = Mutex.new
16
28
  end
17
29
 
18
30
  # Get or create a client for a homeserver
19
31
  def get_client(homeserver, **)
20
- @mutex.synchronize do
21
- pool = get_or_create_pool(homeserver)
22
- pool.checkout(**)
23
- end
32
+ pool = @mutex.synchronize { get_or_create_pool(homeserver) }
33
+ pool.checkout(**)
24
34
  end
25
35
 
26
36
  # Return a client to the pool
@@ -78,32 +88,37 @@ module ActiveMatrix
78
88
  @available = []
79
89
  @in_use = {}
80
90
  @mutex = Mutex.new
81
- @condition = ConditionVariable.new
91
+ @semaphore = Async::Semaphore.new(max_size)
82
92
  end
83
93
 
84
94
  def checkout(**)
85
- @mutex.synchronize do
86
- # Try to find an available client
87
- client = find_available_client
95
+ # Acquire semaphore temporarily to rate-limit client creation
96
+ @semaphore.acquire
88
97
 
89
- # Create new client if needed and pool not full
90
- client = create_client(**) if client.nil? && @in_use.size < @max_size
98
+ client = @mutex.synchronize do
99
+ # Try to find an available client
100
+ existing = find_available_client
91
101
 
92
- # Wait for a client if pool is full
93
- while client.nil?
94
- @condition.wait(@mutex, 1)
95
- client = find_available_client
102
+ if existing
103
+ @available.delete(existing)
104
+ existing
105
+ else
106
+ create_client(**)
96
107
  end
108
+ end
97
109
 
98
- # Mark as in use
99
- @available.delete(client)
110
+ # Track as in use
111
+ @mutex.synchronize do
100
112
  @in_use[client.object_id] = {
101
113
  client: client,
102
114
  checked_out_at: Time.current
103
115
  }
104
-
105
- client
106
116
  end
117
+
118
+ client
119
+ ensure
120
+ # Release immediately - semaphore only rate-limits creation, not usage
121
+ @semaphore.release
107
122
  end
108
123
 
109
124
  def checkin(client)
@@ -115,12 +130,8 @@ module ActiveMatrix
115
130
  if client_valid?(client)
116
131
  @available << client
117
132
  else
118
- # Client is no longer valid, don't return to pool
119
133
  logger.debug "Discarding invalid client for #{@homeserver}"
120
134
  end
121
-
122
- # Signal waiting threads
123
- @condition.signal
124
135
  end
125
136
  end
126
137
 
@@ -140,7 +151,7 @@ module ActiveMatrix
140
151
  @mutex.synchronize do
141
152
  # Stop all clients
142
153
  (@available + @in_use.values.map { |e| e[:client] }).each do |client|
143
- client.stop_listener_thread if client.listening?
154
+ client.stop_listener if client.listening?
144
155
  client.logout if client.logged_in?
145
156
  rescue StandardError => e
146
157
  logger.error "Error cleaning up client: #{e.message}"
@@ -158,7 +169,7 @@ module ActiveMatrix
158
169
  @available.select! { |client| client_valid?(client) }
159
170
 
160
171
  # Return first available
161
- @available.first
172
+ @available.shift
162
173
  end
163
174
 
164
175
  def create_client(**)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/http'
5
+ require 'json'
6
+
7
+ module ActiveMatrix
8
+ class Daemon
9
+ # Lightweight HTTP health probe server using Async::HTTP
10
+ #
11
+ # Endpoints:
12
+ # - GET /health - Returns 200 if healthy, 503 if shutting down
13
+ # - GET /status - Returns detailed JSON status
14
+ # - GET /metrics - Prometheus-compatible metrics
15
+ #
16
+ class ProbeServer
17
+ attr_reader :host, :port, :daemon
18
+
19
+ def initialize(host:, port:, daemon:)
20
+ @host = host
21
+ @port = port
22
+ @daemon = daemon
23
+ @thread = nil
24
+ end
25
+
26
+ def start
27
+ @thread = Thread.new do
28
+ Sync do
29
+ endpoint = Async::HTTP::Endpoint.parse("http://#{host}:#{port}")
30
+
31
+ @server = Async::HTTP::Server.for(endpoint) do |request|
32
+ handle_request(request)
33
+ end
34
+
35
+ logger.info "Probe server listening on #{host}:#{port}"
36
+ @server.run
37
+ end
38
+ rescue StandardError => e
39
+ logger.error "Probe server error: #{e.message}"
40
+ end
41
+ end
42
+
43
+ def stop
44
+ @thread&.kill
45
+ @thread&.join(1)
46
+ end
47
+
48
+ private
49
+
50
+ def handle_request(request)
51
+ path = request.path
52
+
53
+ case path
54
+ when '/health'
55
+ health_response
56
+ when '/status'
57
+ status_response
58
+ when '/metrics'
59
+ metrics_response
60
+ else
61
+ not_found_response
62
+ end
63
+ end
64
+
65
+ def health_response
66
+ status = daemon.status
67
+ code = status[:status] == 'ok' ? 200 : 503
68
+
69
+ ::Protocol::HTTP::Response[code, { 'content-type' => 'text/plain' }, [status[:status]]]
70
+ end
71
+
72
+ def status_response
73
+ body = JSON.pretty_generate(daemon.status)
74
+
75
+ ::Protocol::HTTP::Response[200, { 'content-type' => 'application/json' }, [body]]
76
+ end
77
+
78
+ def metrics_response
79
+ status = daemon.status
80
+
81
+ lines = [
82
+ '# HELP activematrix_up Is the daemon running',
83
+ '# TYPE activematrix_up gauge',
84
+ "activematrix_up #{status[:status] == 'ok' ? 1 : 0}",
85
+ '',
86
+ '# HELP activematrix_uptime_seconds Daemon uptime in seconds',
87
+ '# TYPE activematrix_uptime_seconds counter',
88
+ "activematrix_uptime_seconds #{status[:uptime]}",
89
+ '',
90
+ '# HELP activematrix_workers Number of worker processes',
91
+ '# TYPE activematrix_workers gauge',
92
+ "activematrix_workers #{status[:workers]}",
93
+ '',
94
+ '# HELP activematrix_agents_total Total number of agents',
95
+ '# TYPE activematrix_agents_total gauge',
96
+ "activematrix_agents_total #{status.dig(:agents, :total) || 0}",
97
+ '',
98
+ '# HELP activematrix_agents Agent count by state',
99
+ '# TYPE activematrix_agents gauge',
100
+ "activematrix_agents{state=\"online\"} #{status.dig(:agents, :online) || 0}",
101
+ "activematrix_agents{state=\"connecting\"} #{status.dig(:agents, :connecting) || 0}",
102
+ "activematrix_agents{state=\"error\"} #{status.dig(:agents, :error) || 0}",
103
+ "activematrix_agents{state=\"offline\"} #{status.dig(:agents, :offline) || 0}"
104
+ ]
105
+
106
+ ::Protocol::HTTP::Response[200, { 'content-type' => 'text/plain; version=0.0.4' }, [lines.join("\n")]]
107
+ end
108
+
109
+ def not_found_response
110
+ ::Protocol::HTTP::Response[404, { 'content-type' => 'text/plain' }, ['Not Found']]
111
+ end
112
+
113
+ def logger
114
+ ActiveMatrix.logger
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveMatrix
4
+ class Daemon
5
+ # Handles Unix signals for the daemon coordinator
6
+ #
7
+ # Signals:
8
+ # - TERM/INT: Graceful shutdown
9
+ # - HUP: Reload configuration and restart agents
10
+ # - USR1: Log rotation (reopen log files)
11
+ # - USR2: Dump debug information
12
+ #
13
+ class SignalHandler
14
+ SIGNALS = %w[TERM INT HUP USR1 USR2].freeze
15
+
16
+ attr_reader :daemon
17
+
18
+ def initialize(daemon)
19
+ @daemon = daemon
20
+ @self_pipe_reader, @self_pipe_writer = IO.pipe
21
+ @old_handlers = {}
22
+ end
23
+
24
+ def install
25
+ SIGNALS.each do |signal|
26
+ install_handler(signal)
27
+ end
28
+
29
+ # Start signal processing thread
30
+ start_processor
31
+ end
32
+
33
+ def uninstall
34
+ SIGNALS.each do |signal|
35
+ restore_handler(signal)
36
+ end
37
+
38
+ @self_pipe_writer.close
39
+ @self_pipe_reader.close
40
+ end
41
+
42
+ private
43
+
44
+ def install_handler(signal)
45
+ @old_handlers[signal] = Signal.trap(signal) do
46
+ # Write signal to pipe (non-blocking, safe in signal handler)
47
+ @self_pipe_writer.write_nonblock("#{signal}\n")
48
+ rescue Errno::EAGAIN, Errno::EWOULDBLOCK
49
+ # Pipe full, signal will be coalesced
50
+ end
51
+ rescue ArgumentError
52
+ # Signal not supported on this platform
53
+ logger.debug "Signal #{signal} not supported"
54
+ end
55
+
56
+ def restore_handler(signal)
57
+ return unless @old_handlers.key?(signal)
58
+
59
+ Signal.trap(signal, @old_handlers[signal])
60
+ end
61
+
62
+ def start_processor
63
+ Thread.new do
64
+ Thread.current.name = 'activematrix-signal-processor'
65
+
66
+ loop do
67
+ # rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
68
+ ready = IO.select([@self_pipe_reader], nil, nil, 1)
69
+ # rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
70
+ next unless ready
71
+
72
+ signal = @self_pipe_reader.gets&.strip
73
+ next unless signal
74
+
75
+ handle_signal(signal)
76
+ rescue IOError
77
+ break
78
+ end
79
+ end
80
+ end
81
+
82
+ def handle_signal(signal)
83
+ logger.info "Received signal: #{signal}"
84
+
85
+ case signal
86
+ when 'TERM', 'INT'
87
+ handle_shutdown
88
+ when 'HUP'
89
+ handle_reload
90
+ when 'USR1'
91
+ handle_log_rotation
92
+ when 'USR2'
93
+ handle_debug_dump
94
+ end
95
+ end
96
+
97
+ def handle_shutdown
98
+ logger.info 'Initiating graceful shutdown...'
99
+ daemon.shutdown
100
+ end
101
+
102
+ def handle_reload
103
+ logger.info 'Reloading agent configuration...'
104
+ # TODO: Implement reload
105
+ # 1. Query for new/removed agents
106
+ # 2. Send HUP to workers for them to reload
107
+ daemon.worker_pids.each do |pid|
108
+ Process.kill('HUP', pid)
109
+ rescue Errno::ESRCH
110
+ # Worker already dead
111
+ end
112
+ end
113
+
114
+ def handle_log_rotation
115
+ logger.info 'Rotating log files...'
116
+
117
+ # Reopen stdout/stderr if they're files
118
+ if $stdout.respond_to?(:path) && $stdout.path
119
+ $stdout.reopen($stdout.path, 'a')
120
+ $stdout.sync = true
121
+ end
122
+
123
+ if $stderr.respond_to?(:path) && $stderr.path
124
+ $stderr.reopen($stderr.path, 'a')
125
+ $stderr.sync = true
126
+ end
127
+
128
+ # Signal workers to rotate their logs too
129
+ daemon.worker_pids.each do |pid|
130
+ Process.kill('USR1', pid)
131
+ rescue Errno::ESRCH
132
+ # Worker already dead
133
+ end
134
+ end
135
+
136
+ def handle_debug_dump
137
+ logger.info 'Dumping debug information...'
138
+
139
+ # Dump current state
140
+ status = daemon.status
141
+ logger.info "Status: #{status.inspect}"
142
+
143
+ # Dump thread backtraces
144
+ Thread.list.each do |thread|
145
+ logger.info "Thread: #{thread.name || thread.object_id}"
146
+ logger.info thread.backtrace&.join("\n") || '(no backtrace)'
147
+ logger.info '---'
148
+ end
149
+ end
150
+
151
+ def logger
152
+ ActiveMatrix.logger
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveMatrix
4
+ class Daemon
5
+ # Worker process that runs a subset of agents
6
+ #
7
+ # Each worker is a forked child process that:
8
+ # - Initializes its own AgentManager
9
+ # - Runs only assigned agents (by ID)
10
+ # - Handles signals for graceful shutdown
11
+ #
12
+ class Worker
13
+ attr_reader :index, :agent_ids
14
+
15
+ def initialize(index:, agent_ids:)
16
+ @index = index
17
+ @agent_ids = agent_ids
18
+ @running = false
19
+ end
20
+
21
+ def run
22
+ @running = true
23
+
24
+ set_process_name
25
+ install_signal_handlers
26
+ reconnect_database
27
+
28
+ logger.info "Worker #{index} starting with agents: #{agent_ids.join(', ')}"
29
+
30
+ run_agents
31
+ rescue StandardError => e
32
+ logger.error "Worker #{index} crashed: #{e.message}"
33
+ logger.error e.backtrace.join("\n")
34
+ raise
35
+ ensure
36
+ logger.info "Worker #{index} exiting"
37
+ end
38
+
39
+ private
40
+
41
+ def set_process_name
42
+ Process.setproctitle("activematrix[#{index}]: #{agent_ids.size} agents")
43
+ end
44
+
45
+ def install_signal_handlers
46
+ Signal.trap('TERM') do
47
+ @running = false
48
+ AgentManager.instance.stop_all
49
+ end
50
+
51
+ Signal.trap('INT') do
52
+ @running = false
53
+ AgentManager.instance.stop_all
54
+ end
55
+
56
+ Signal.trap('HUP') do
57
+ # Reload - restart agents with new config
58
+ logger.info "Worker #{index} received HUP, reloading..."
59
+ # For now, just log. Full reload would require:
60
+ # 1. Stop all agents
61
+ # 2. Re-query DB for agent list
62
+ # 3. Start new agents
63
+ end
64
+
65
+ Signal.trap('USR1') do
66
+ # Log rotation
67
+ if $stdout.respond_to?(:path) && $stdout.path
68
+ $stdout.reopen($stdout.path, 'a')
69
+ $stdout.sync = true
70
+ end
71
+ if $stderr.respond_to?(:path) && $stderr.path
72
+ $stderr.reopen($stderr.path, 'a')
73
+ $stderr.sync = true
74
+ end
75
+ end
76
+ end
77
+
78
+ def reconnect_database
79
+ # After fork, we need fresh database connections
80
+ ActiveRecord::Base.connection_handler.clear_active_connections!
81
+ ActiveRecord::Base.establish_connection
82
+ end
83
+
84
+ def run_agents
85
+ manager = AgentManager.instance
86
+
87
+ # Install signal handlers for the manager
88
+ manager.install_signal_handlers!
89
+
90
+ # Load only our assigned agents
91
+ agents = ActiveMatrix::Agent.where(id: agent_ids)
92
+
93
+ if agents.empty?
94
+ logger.warn "Worker #{index} has no agents to run"
95
+ return
96
+ end
97
+
98
+ # Start the manager with only our agents
99
+ Sync do
100
+ manager.start_agents(agents)
101
+ end
102
+ end
103
+
104
+ def logger
105
+ ActiveMatrix.logger
106
+ end
107
+ end
108
+ end
109
+ end