lex-agentic-memory 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4e20449f626874ab90b96c4452b4b35e4eae22e61df8fb6c9e5a51e4b0b8cd3
4
- data.tar.gz: eff572db0f462ada591dcfc6e6efa2d8d3092cc43b1be6f907ddcfe9003b0cd9
3
+ metadata.gz: f403d34cba75eaf3f800a8852c8d6e16d2f56924b2454b7c444a0cee82a00f04
4
+ data.tar.gz: f946558e6cf3103bc32bf44d9accb9a4c3a13d91342e7a06a5f6b5ec98c0c856
5
5
  SHA512:
6
- metadata.gz: e0e21635a796f9d4c9fbb2503446657d727bb01de9bb82a5b6323270611ff13c72a18bb0c520f2fb0ab3a987d5a869ca92ebf7e1e718dce0d20cd0b3712f1722
7
- data.tar.gz: 0ea2e0a708e2cb8f853bc17595935a596de7e98c03955c9ce97582a7edcdc5546a22e645f9e929b05a08a43e9b52a4e4a1a45329129b1d797cea58d42f833b76
6
+ metadata.gz: e49ff76e259d4237f19a7998f8e973438ebcfceb44322990a6213f35bd07481adfea5f8cd037c351217af569931f2344ceb04b5603bc53cadd5c27d02cec4cbe
7
+ data.tar.gz: 28a6d1d21d23f0ba91d034b441ee7e858fc51d1d1662e9445b58ac215e09dd271871f90941121d04566e259d971cf6d615c56412ccf2cd9f08e5658e1dd20f0f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.21] - 2026-04-01
4
+
5
+ ### Changed
6
+ - `ErrorTracer.record_trace` now dispatches `Trace.shared_store` writes/flushes in a background `Thread.new` — error/fatal logging hooks no longer block the calling Puma thread, regardless of store backend (Postgres, CacheStore, or in-memory)
7
+ - Debounce check remains synchronous; only the store write and flush go async
8
+
3
9
  ## [0.1.20] - 2026-03-31
4
10
 
5
11
  ### Added
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
+
7
+ gem 'rubocop-legion'
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency 'msgpack', '~> 1.7'
35
35
 
36
36
  spec.add_development_dependency 'rspec', '~> 3.13'
37
- spec.add_development_dependency 'rubocop', '~> 1.60'
38
- spec.add_development_dependency 'rubocop-legion', '~> 0.1'
39
- spec.add_development_dependency 'rubocop-rspec', '~> 2.26'
37
+ spec.add_development_dependency 'rubocop'
38
+ spec.add_development_dependency 'rubocop-legion'
39
+ spec.add_development_dependency 'rubocop-rspec'
40
40
  end
@@ -17,8 +17,12 @@ module Legion
17
17
  def setup
18
18
  return if @active
19
19
 
20
- @recent = {}
21
- @runner = Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
20
+ @recent = {}
21
+ @recent_mutex = ::Mutex.new
22
+ @runner = Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
23
+ @write_queue = ::Queue.new
24
+ @worker = ::Thread.new { drain_queue }
25
+ @worker.name = 'legion-error-tracer'
22
26
  wrap_logging_methods
23
27
  @active = true
24
28
  Legion::Logging.info '[memory] ErrorTracer active — errors/fatals will become episodic traces'
@@ -30,6 +34,19 @@ module Legion
30
34
 
31
35
  private
32
36
 
37
+ def drain_queue
38
+ loop do
39
+ payload = @write_queue.pop
40
+ break if payload == :stop
41
+
42
+ @runner.store_trace(**payload)
43
+ store = @runner.send(:default_store)
44
+ store.flush if store.respond_to?(:flush)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+ end
49
+
33
50
  def wrap_logging_methods
34
51
  original_error = Legion::Logging.method(:error)
35
52
  original_fatal = Legion::Logging.method(:fatal)
@@ -50,23 +67,21 @@ module Legion
50
67
  def record_trace(message, level)
51
68
  return unless message.is_a?(String) && !message.empty?
52
69
 
53
- # Debounce: skip if same message within window
54
70
  now = Time.now.utc
55
71
  key = "#{level}:#{message[0..100]}"
56
- return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
57
72
 
58
- @recent[key] = now
73
+ @recent_mutex.synchronize do
74
+ return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
59
75
 
60
- # Clean old entries periodically
61
- @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
76
+ @recent[key] = now
77
+ @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
78
+ end
62
79
 
63
- # Extract component from [bracket] prefix
64
80
  component = message.match(/\A\[([^\]]+)\]/)&.captures&.first || 'unknown'
65
-
66
81
  valence = level == :fatal ? FATAL_VALENCE : ERROR_VALENCE
67
82
  intensity = level == :fatal ? FATAL_INTENSITY : ERROR_INTENSITY
68
83
 
69
- @runner.store_trace(
84
+ @write_queue.push(
70
85
  type: :episodic,
71
86
  content_payload: message,
72
87
  domain_tags: ['error', component.downcase],
@@ -76,12 +91,7 @@ module Legion
76
91
  unresolved: true,
77
92
  confidence: 0.9
78
93
  )
79
-
80
- # Flush if cache-backed
81
- store = @runner.send(:default_store)
82
- store.flush if store.respond_to?(:flush)
83
- rescue StandardError => _e
84
- # Never let trace creation break the logging pipeline
94
+ rescue StandardError
85
95
  nil
86
96
  end
87
97
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.20'
7
+ VERSION = '0.1.21'
8
8
  end
9
9
  end
10
10
  end
@@ -11,13 +11,24 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
11
11
  # Reset state between examples
12
12
  described_class.instance_variable_set(:@active, nil)
13
13
  described_class.instance_variable_set(:@recent, nil)
14
+ described_class.instance_variable_set(:@recent_mutex, nil)
14
15
  described_class.instance_variable_set(:@runner, nil)
16
+ described_class.instance_variable_set(:@write_queue, nil)
17
+ old_worker = described_class.instance_variable_get(:@worker)
18
+ old_worker&.kill
19
+ described_class.instance_variable_set(:@worker, nil)
15
20
  end
16
21
 
17
22
  after do
18
23
  # Restore logging singleton methods to prevent cross-test side effects
19
24
  Legion::Logging.define_singleton_method(:error, &original_error)
20
25
  Legion::Logging.define_singleton_method(:fatal, &original_fatal)
26
+ # Stop worker thread if still alive
27
+ worker = described_class.instance_variable_get(:@worker)
28
+ if worker&.alive?
29
+ described_class.instance_variable_get(:@write_queue)&.push(:stop)
30
+ worker.join(1)
31
+ end
21
32
  end
22
33
 
23
34
  describe '.setup' do
@@ -31,6 +42,13 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
31
42
  described_class.setup
32
43
  expect(described_class.active?).to be true
33
44
  end
45
+
46
+ it 'starts a single background worker thread' do
47
+ described_class.setup
48
+ worker = described_class.instance_variable_get(:@worker)
49
+ expect(worker).to be_a(Thread)
50
+ expect(worker).to be_alive
51
+ end
34
52
  end
35
53
 
36
54
  describe '.active?' do
@@ -43,4 +61,114 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
43
61
  expect(described_class.active?).to be true
44
62
  end
45
63
  end
64
+
65
+ describe 'record_trace (async dispatch via queue)' do
66
+ let(:store_dbl) { double('store', flush: nil) }
67
+ let(:runner_double) do
68
+ dbl = double('runner')
69
+ allow(dbl).to receive(:store_trace)
70
+ allow(dbl).to receive(:default_store).and_return(store_dbl)
71
+ dbl
72
+ end
73
+ let(:write_queue) { Queue.new }
74
+
75
+ before do
76
+ described_class.instance_variable_set(:@active, true)
77
+ described_class.instance_variable_set(:@recent, {})
78
+ described_class.instance_variable_set(:@recent_mutex, Mutex.new)
79
+ described_class.instance_variable_set(:@runner, runner_double)
80
+ described_class.instance_variable_set(:@write_queue, write_queue)
81
+ end
82
+
83
+ it 'enqueues a payload hash (fire-and-forget dispatch)' do
84
+ described_class.send(:record_trace, 'something broke', :error)
85
+ expect(write_queue.size).to eq(1)
86
+ payload = write_queue.pop
87
+ expect(payload).to be_a(Hash)
88
+ end
89
+
90
+ it 'enqueues a payload with expected keys' do
91
+ described_class.send(:record_trace, '[mycomponent] disk full', :error)
92
+ payload = write_queue.pop
93
+ expect(payload).to include(
94
+ type: :episodic,
95
+ content_payload: '[mycomponent] disk full',
96
+ domain_tags: %w[error mycomponent],
97
+ unresolved: true
98
+ )
99
+ end
100
+
101
+ it 'uses fatal valence and intensity for :fatal level' do
102
+ described_class.send(:record_trace, 'total meltdown', :fatal)
103
+ payload = write_queue.pop
104
+ expect(payload).to include(
105
+ emotional_valence: Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer::FATAL_VALENCE,
106
+ emotional_intensity: Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer::FATAL_INTENSITY
107
+ )
108
+ end
109
+
110
+ it 'does not enqueue (debounced) when same message is within the window' do
111
+ described_class.send(:record_trace, 'repeated error', :error)
112
+ described_class.send(:record_trace, 'repeated error', :error)
113
+ expect(write_queue.size).to eq(1)
114
+ end
115
+
116
+ it 'returns nil for blank messages without raising' do
117
+ expect(described_class.send(:record_trace, '', :error)).to be_nil
118
+ expect(write_queue.size).to eq(0)
119
+ end
120
+
121
+ it 'does not propagate errors from store_trace' do
122
+ worker_queue = Queue.new
123
+ described_class.instance_variable_set(:@write_queue, worker_queue)
124
+ allow(runner_double).to receive(:store_trace).and_raise(StandardError, 'db down')
125
+
126
+ # rubocop:disable ThreadSafety/NewThread
127
+ worker = Thread.new do
128
+ payload = worker_queue.pop
129
+ break if payload == :stop
130
+
131
+ runner_double.store_trace(**payload)
132
+ rescue StandardError
133
+ nil
134
+ end
135
+ # rubocop:enable ThreadSafety/NewThread
136
+
137
+ described_class.send(:record_trace, 'store failure', :error)
138
+ expect { worker.join(2) }.not_to raise_error
139
+ end
140
+
141
+ it 'the background worker calls store_trace with the enqueued payload' do
142
+ dedicated_queue = Queue.new
143
+ described_class.instance_variable_set(:@write_queue, dedicated_queue)
144
+
145
+ # rubocop:disable ThreadSafety/NewThread
146
+ worker = Thread.new do
147
+ loop do
148
+ payload = dedicated_queue.pop
149
+ break if payload == :stop
150
+
151
+ runner_double.store_trace(**payload)
152
+ store = runner_double.send(:default_store)
153
+ store.flush if store.respond_to?(:flush)
154
+ rescue StandardError
155
+ nil
156
+ end
157
+ end
158
+ # rubocop:enable ThreadSafety/NewThread
159
+
160
+ described_class.send(:record_trace, '[mycomponent] disk full', :error)
161
+ dedicated_queue.push(:stop)
162
+ worker.join(2)
163
+
164
+ expect(runner_double).to have_received(:store_trace).with(
165
+ hash_including(
166
+ type: :episodic,
167
+ content_payload: '[mycomponent] disk full',
168
+ domain_tags: %w[error mycomponent],
169
+ unresolved: true
170
+ )
171
+ )
172
+ end
173
+ end
46
174
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-memory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.20
4
+ version: 0.1.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -139,44 +139,44 @@ dependencies:
139
139
  name: rubocop
140
140
  requirement: !ruby/object:Gem::Requirement
141
141
  requirements:
142
- - - "~>"
142
+ - - ">="
143
143
  - !ruby/object:Gem::Version
144
- version: '1.60'
144
+ version: '0'
145
145
  type: :development
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
- - - "~>"
149
+ - - ">="
150
150
  - !ruby/object:Gem::Version
151
- version: '1.60'
151
+ version: '0'
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: rubocop-legion
154
154
  requirement: !ruby/object:Gem::Requirement
155
155
  requirements:
156
- - - "~>"
156
+ - - ">="
157
157
  - !ruby/object:Gem::Version
158
- version: '0.1'
158
+ version: '0'
159
159
  type: :development
160
160
  prerelease: false
161
161
  version_requirements: !ruby/object:Gem::Requirement
162
162
  requirements:
163
- - - "~>"
163
+ - - ">="
164
164
  - !ruby/object:Gem::Version
165
- version: '0.1'
165
+ version: '0'
166
166
  - !ruby/object:Gem::Dependency
167
167
  name: rubocop-rspec
168
168
  requirement: !ruby/object:Gem::Requirement
169
169
  requirements:
170
- - - "~>"
170
+ - - ">="
171
171
  - !ruby/object:Gem::Version
172
- version: '2.26'
172
+ version: '0'
173
173
  type: :development
174
174
  prerelease: false
175
175
  version_requirements: !ruby/object:Gem::Requirement
176
176
  requirements:
177
- - - "~>"
177
+ - - ">="
178
178
  - !ruby/object:Gem::Version
179
- version: '2.26'
179
+ version: '0'
180
180
  description: 'LEX agentic memory domain: episodic, semantic, and working memory'
181
181
  email:
182
182
  - matthewdiverson@gmail.com