legion-gaia 0.9.51 → 0.9.52

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: 48ad6f8421723d7d895e4b9d47d17470b4185a9b8ffb9457181a636d770ba021
4
- data.tar.gz: 6dd1ad8c144273fd690c5647c9e92992558975a62b0417e6751adb26f77da6f8
3
+ metadata.gz: 8390ab05b469136c10d656240dd01aed52856c2eb6ad853b566c1bf6712cc351
4
+ data.tar.gz: '08fa057814b2eb00550a6afb0db16074871a0974a026aa2c8b2caa23768921f4'
5
5
  SHA512:
6
- metadata.gz: a7d12973d7aa366c1bd1853deeae9af6518c4c6db352f379d2b04b50a17187afd161e1eb6f68b36d5e1754bd00a2c0bfc07f5ba696b6f1e6e2bb9d1e0ab34a6b
7
- data.tar.gz: 36e7142df2a327aa193992611d3bfc76350c0ca77465cca388c235aab65a11a3be666e9aab782530fcbbcc736383ef368d71cccd9fad242d5774240fa67ceebe
6
+ metadata.gz: 0c2a1e84bf1839d3b50db07766243a15cd8bca7c56c6f14bbe68a7c4e9130a414fa745a06d0696901d319b784435943e5965b8397607c19ac9cbaa69bc534424
7
+ data.tar.gz: 9be977c4a11659433a0e6e7cbe11be7f5aee4692541b46b5be6f5c2856d38f9b1f1991b1653332847b8a57a7a5787b426e720d8b6940c136cb5c0d12b1f6e49d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.52] - 2026-05-07
6
+
7
+ ### Fixed
8
+ - Close the 2026-05-06 GAIA gap-analysis findings across advisory generation, Bot Framework JWT issuer validation, router outbound delivery, offline queuing, proactive partner channels, notification gating, Teams delivery, and shared-state synchronization.
9
+ - Normalize advisory routing hints, support string-keyed tool predictions, and return a stable empty advisory hash when advisory generation fails.
10
+ - Replace high-severity `log.unknown` message tracing with debug logging to avoid production PII log flooding.
11
+ - Harden PR review follow-up behavior for outbound queue acknowledgements, delayed notification TTL preservation, atomic bond channel metadata updates, and adapter delivery-signature caching.
12
+
5
13
  ## [0.9.51] - 2026-04-27
6
14
 
7
15
  ### Fixed
@@ -13,7 +13,7 @@ module Legion
13
13
  # Returns Hash with optional keys: system_prompt, routing_hint,
14
14
  # context_window, tool_hint, suppress, valence
15
15
  def advise(caller:, conversation_id: nil, messages: nil)
16
- log.unknown "advise(caller: #{caller}, conversation_id: #{conversation_id}, messages: #{messages}) "
16
+ log.debug "advise(caller: #{caller}, conversation_id: #{conversation_id}, messages: #{messages}) "
17
17
  return nil unless Gaia.started?
18
18
 
19
19
  advisory = {}
@@ -27,17 +27,17 @@ module Legion
27
27
  result
28
28
  rescue StandardError => e
29
29
  handle_exception(e, level: :warn, operation: 'gaia.advisory.advise', conversation_id: conversation_id)
30
- nil
30
+ {}
31
31
  end
32
32
 
33
33
  def merge_tick_data!(advisory, tick_result)
34
- log.unknown "merge_tick_data!(#{advisory.inspect}, #{tick_result})"
34
+ log.debug "merge_tick_data!(#{advisory.inspect}, #{tick_result})"
35
35
  return unless tick_result && tick_result[:results]
36
36
 
37
37
  results = tick_result[:results]
38
38
  apply_tool_hints!(advisory, results)
39
39
  apply_suppress!(advisory, results)
40
- advisory[:routing_hint] = results.dig(:post_tick_reflection, :routing_preference)
40
+ advisory[:routing_hint] = normalize_routing_hint(results.dig(:post_tick_reflection, :routing_preference))
41
41
  advisory[:context_window] = results.dig(:memory_retrieval, :cross_conversation)
42
42
  end
43
43
 
@@ -45,7 +45,11 @@ module Legion
45
45
  predictions = results.dig(:prediction_engine, :predictions)
46
46
  return unless predictions
47
47
 
48
- tools = predictions.select { |p| p[:confidence] >= 0.6 }.map { |p| p[:tool] }
48
+ tools = predictions.filter_map do |prediction|
49
+ confidence = value_for(prediction, :confidence).to_f
50
+ tool = value_for(prediction, :tool)
51
+ tool if confidence >= 0.6 && tool
52
+ end
49
53
  advisory[:tool_hint] = tools.empty? ? nil : tools
50
54
  end
51
55
 
@@ -62,11 +66,36 @@ module Legion
62
66
  return unless identity
63
67
 
64
68
  learned = AuditObserver.instance.learned_data_for(identity)
65
- advisory[:routing_hint] ||= learned[:routing_preference] if learned[:routing_preference]
69
+ advisory[:routing_hint] ||= normalize_routing_hint(learned[:routing_preference]) if learned[:routing_preference]
66
70
  advisory[:tool_hint] ||= learned[:tool_predictions].keys.first(5) if learned[:tool_predictions]&.any?
67
71
  end
68
72
 
69
- private_class_method :merge_tick_data!, :apply_tool_hints!, :apply_suppress!, :merge_observer_data!
73
+ def normalize_routing_hint(value)
74
+ return nil if value.nil?
75
+
76
+ if value.is_a?(Hash)
77
+ provider = value_for(value, :provider)
78
+ model = value_for(value, :model)
79
+ return nil if provider.to_s.empty? && model.to_s.empty?
80
+
81
+ return { provider: provider&.to_s, model: model&.to_s }
82
+ end
83
+
84
+ { provider: value.to_s, model: nil }
85
+ end
86
+
87
+ def value_for(hash, key)
88
+ return nil unless hash.respond_to?(:key?)
89
+
90
+ string_key = key.to_s
91
+ return hash[key] if hash.key?(key)
92
+ return hash[string_key] if hash.key?(string_key)
93
+
94
+ nil
95
+ end
96
+
97
+ private_class_method :merge_tick_data!, :apply_tool_hints!, :apply_suppress!, :merge_observer_data!,
98
+ :normalize_routing_hint, :value_for
70
99
  end
71
100
  end
72
101
  end
@@ -12,7 +12,6 @@ module Legion
12
12
  def initialize
13
13
  @user_prefs = {}
14
14
  @tool_patterns = {}
15
- @quality_log = []
16
15
  @mutex = Mutex.new
17
16
  end
18
17
 
@@ -22,7 +21,6 @@ module Legion
22
21
  @mutex.synchronize do
23
22
  record_routing_preference(event)
24
23
  record_tool_patterns(event)
25
- record_quality(event)
26
24
  end
27
25
  identity = extract_caller_identity(event)
28
26
  log.debug(
@@ -46,8 +44,7 @@ module Legion
46
44
  prefs = @user_prefs[identity] || {}
47
45
  {
48
46
  routing_preference: prefs[:routing],
49
- tool_predictions: top_tools_for_patterns,
50
- quality_signals: recent_quality
47
+ tool_predictions: top_tools_for_patterns
51
48
  }
52
49
  end
53
50
  end
@@ -56,7 +53,6 @@ module Legion
56
53
  @mutex.synchronize do
57
54
  @user_prefs.clear
58
55
  @tool_patterns.clear
59
- @quality_log.clear
60
56
  end
61
57
  end
62
58
 
@@ -95,22 +91,9 @@ module Legion
95
91
  end
96
92
  end
97
93
 
98
- def record_quality(event)
99
- @quality_log << {
100
- provider: event.dig(:routing, :provider),
101
- tokens: event[:tokens],
102
- timestamp: event[:timestamp]
103
- }
104
- @quality_log.shift if @quality_log.length > 100
105
- end
106
-
107
94
  def top_tools_for_patterns
108
95
  @tool_patterns.sort_by { |_, v| -v[:count] }.first(10).to_h
109
96
  end
110
-
111
- def recent_quality
112
- @quality_log.last(10)
113
- end
114
97
  end
115
98
  end
116
99
  end
@@ -9,20 +9,37 @@ module Legion
9
9
  extend Legion::Logging::Helper
10
10
 
11
11
  @bonds = Concurrent::Hash.new
12
+ @mutex = Mutex.new
12
13
 
13
14
  module_function
14
15
 
15
- def register(identity, bond: nil, role: nil, priority: :normal, channel_identity: nil)
16
+ def register(identity, bond: nil, role: nil, priority: :normal, channel_identity: nil,
17
+ preferred_channel: nil, last_channel: nil)
16
18
  effective_bond = (bond || role || :unknown).to_sym
17
- @bonds[identity.to_s] = {
19
+ @mutex.synchronize do
20
+ @bonds[identity.to_s] = build_entry(
21
+ identity,
22
+ bond: effective_bond,
23
+ priority: priority,
24
+ channel_identity: channel_identity,
25
+ preferred_channel: preferred_channel,
26
+ last_channel: last_channel
27
+ )
28
+ end
29
+ log.info("BondRegistry registered identity=#{identity} bond=#{effective_bond} priority=#{priority}")
30
+ end
31
+
32
+ def build_entry(identity, bond:, priority:, channel_identity:, preferred_channel:, last_channel:)
33
+ {
18
34
  identity: identity.to_s,
19
- bond: effective_bond,
20
- role: effective_bond,
35
+ bond: bond,
36
+ role: bond,
21
37
  priority: priority.to_sym,
22
38
  since: Time.now.utc,
23
- channel_identity: channel_identity&.to_s
39
+ channel_identity: channel_identity&.to_s,
40
+ preferred_channel: preferred_channel&.to_sym,
41
+ last_channel: last_channel&.to_sym
24
42
  }
25
- log.info("BondRegistry registered identity=#{identity} bond=#{effective_bond} priority=#{priority}")
26
43
  end
27
44
 
28
45
  def bond(identity)
@@ -53,6 +70,21 @@ module Legion
53
70
  @bonds.values
54
71
  end
55
72
 
73
+ def record_channel(identity, channel_id:, channel_identity: nil)
74
+ @mutex.synchronize do
75
+ entry = @bonds[identity.to_s]
76
+ return nil unless entry
77
+
78
+ channel = channel_id&.to_sym
79
+ updated = entry.merge(
80
+ last_channel: channel || entry[:last_channel],
81
+ preferred_channel: entry[:preferred_channel] || channel,
82
+ channel_identity: entry[:channel_identity] || channel_identity&.to_s
83
+ )
84
+ @bonds[identity.to_s] = updated
85
+ end
86
+ end
87
+
56
88
  # Returns the single best partner bond entry using deterministic selection:
57
89
  # 1. Prefer entries that have an explicit channel_identity stored (§9.6 guarantee)
58
90
  # 2. Then prefer entries with priority: :primary
@@ -81,8 +113,14 @@ module Legion
81
113
 
82
114
  identities = extract_identity_keys(content)
83
115
  priority = content.match?(/primary/i) ? :primary : :normal
84
-
85
- identities.each { |id| register(id, bond: :partner, priority: priority) }
116
+ channel_identity = extract_channel_identity(content)
117
+ preferred_channel = extract_channel(content, 'preferred')
118
+ last_channel = extract_channel(content, 'last')
119
+
120
+ identities.each do |id|
121
+ register(id, bond: :partner, priority: priority, channel_identity: channel_identity,
122
+ preferred_channel: preferred_channel, last_channel: last_channel)
123
+ end
86
124
  end
87
125
  log.info("BondRegistry hydrated entries=#{result[:results].size}")
88
126
  rescue StandardError => e
@@ -90,7 +128,7 @@ module Legion
90
128
  end
91
129
 
92
130
  def reset!
93
- @bonds = Concurrent::Hash.new
131
+ @mutex.synchronize { @bonds = Concurrent::Hash.new }
94
132
  log.debug('BondRegistry reset')
95
133
  end
96
134
 
@@ -100,7 +138,25 @@ module Legion
100
138
 
101
139
  match[1].split(/[,\s]+/).map(&:strip).reject(&:empty?)
102
140
  end
103
- private_class_method :extract_identity_keys
141
+
142
+ def extract_channel_identity(content)
143
+ match = content.match(/channel[_\s-]*identity[:\s]+([^\n]+)/i)
144
+ first_match_value(match)
145
+ end
146
+
147
+ def extract_channel(content, kind)
148
+ match = content.match(/#{kind}[_\s-]*channel[:\s]+([^\n]+)/i)
149
+ first_match_value(match)
150
+ end
151
+
152
+ def first_match_value(match)
153
+ return nil unless match
154
+
155
+ match[1].split(/[,\s]+/).first&.strip
156
+ end
157
+
158
+ private_class_method :build_entry, :extract_identity_keys, :extract_channel_identity, :extract_channel,
159
+ :first_match_value
104
160
  end
105
161
  end
106
162
  end
@@ -63,7 +63,7 @@ module Legion
63
63
  protected
64
64
 
65
65
  def build_intent_metadata(content)
66
- require_relative 'intent_classifier' unless defined?(IntentClassifier)
66
+ require_relative 'intent_classifier' unless defined?(Legion::Gaia::IntentClassifier)
67
67
  classification = IntentClassifier.classify_with_engagement(content)
68
68
  {
69
69
  interaction_intent: classification[:intent],
@@ -5,12 +5,14 @@ module Legion
5
5
  class ChannelRegistry
6
6
  def initialize
7
7
  @adapters = {}
8
+ @deliver_signature_cache = {}
8
9
  @mutex = Mutex.new
9
10
  end
10
11
 
11
12
  def register(adapter)
12
13
  @mutex.synchronize do
13
14
  @adapters[adapter.channel_id] = adapter
15
+ @deliver_signature_cache.delete(adapter.class)
14
16
  end
15
17
  end
16
18
 
@@ -46,7 +48,8 @@ module Legion
46
48
  end
47
49
 
48
50
  rendered = adapter.translate_outbound(output_frame)
49
- normalize_delivery_result(adapter.deliver(rendered), channel_id: output_frame.channel_id)
51
+ normalize_delivery_result(deliver_to_adapter(adapter, rendered, output_frame),
52
+ channel_id: output_frame.channel_id)
50
53
  end
51
54
 
52
55
  def start_all
@@ -59,6 +62,34 @@ module Legion
59
62
 
60
63
  private
61
64
 
65
+ def deliver_to_adapter(adapter, rendered, output_frame)
66
+ conversation_id = output_frame.metadata[:conversation_id]
67
+ if conversation_id && deliver_accepts_conversation_id?(adapter)
68
+ adapter.deliver(rendered, conversation_id: conversation_id)
69
+ else
70
+ adapter.deliver(rendered)
71
+ end
72
+ end
73
+
74
+ def deliver_accepts_conversation_id?(adapter)
75
+ adapter_class = adapter.class
76
+ @mutex.synchronize do
77
+ return @deliver_signature_cache[adapter_class] if @deliver_signature_cache.key?(adapter_class)
78
+ end
79
+
80
+ accepts_conversation_id = deliver_parameters(adapter).any? do |type, name|
81
+ %i[key keyreq].include?(type) && name == :conversation_id
82
+ end
83
+ @mutex.synchronize { @deliver_signature_cache[adapter_class] = accepts_conversation_id }
84
+ accepts_conversation_id
85
+ end
86
+
87
+ def deliver_parameters(adapter)
88
+ adapter.class.instance_method(:deliver).parameters
89
+ rescue StandardError
90
+ adapter.method(:deliver).parameters
91
+ end
92
+
62
93
  def normalize_delivery_result(result, channel_id:)
63
94
  return { delivered: false, reason: :adapter_returned_false, channel_id: channel_id } if result == false
64
95
  return { delivered: true, channel_id: channel_id } unless result.is_a?(Hash)
@@ -15,6 +15,7 @@ module Legion
15
15
  def initialize
16
16
  super(channel_id: :cli, capabilities: CAPABILITIES)
17
17
  @output_buffer = []
18
+ @mutex = Mutex.new
18
19
  end
19
20
 
20
21
  def translate_inbound(raw_input)
@@ -35,22 +36,24 @@ module Legion
35
36
  end
36
37
 
37
38
  def deliver(rendered_content)
38
- @output_buffer << rendered_content
39
+ @mutex.synchronize { @output_buffer << rendered_content }
39
40
  rendered_content
40
41
  end
41
42
 
42
43
  def drain_output
43
- output = @output_buffer.dup
44
- @output_buffer.clear
45
- output
44
+ @mutex.synchronize do
45
+ output = @output_buffer.dup
46
+ @output_buffer.clear
47
+ output
48
+ end
46
49
  end
47
50
 
48
51
  def last_output
49
- @output_buffer.last
52
+ @mutex.synchronize { @output_buffer.last }
50
53
  end
51
54
 
52
55
  def output_buffer_size
53
- @output_buffer.size
56
+ @mutex.synchronize { @output_buffer.size }
54
57
  end
55
58
  end
56
59
  end
@@ -102,15 +102,17 @@ module Legion
102
102
  end
103
103
 
104
104
  def jwks_keys
105
- now = Time.now.to_i
106
- cache = @jwks_cache
107
- return cache[:keys] if cache && cache[:expires_at] > now
108
-
109
- metadata = fetch_json(OPENID_METADATA_URL)
110
- jwks_uri = metadata['jwks_uri']
111
- keys = fetch_json(jwks_uri).fetch('keys', [])
112
- @jwks_cache = { keys: keys, expires_at: now + JWKS_CACHE_TTL }
113
- keys
105
+ jwks_mutex.synchronize do
106
+ now = Time.now.to_i
107
+ cache = @jwks_cache
108
+ return cache[:keys] if cache && cache[:expires_at] > now
109
+
110
+ metadata = fetch_json(OPENID_METADATA_URL)
111
+ jwks_uri = metadata['jwks_uri']
112
+ keys = fetch_json(jwks_uri).fetch('keys', [])
113
+ @jwks_cache = { keys: keys, expires_at: now + JWKS_CACHE_TTL }
114
+ keys
115
+ end
114
116
  end
115
117
 
116
118
  def fetch_json(url)
@@ -146,12 +148,23 @@ module Legion
146
148
  issuer = payload['iss']
147
149
  valid_issuers = [BOT_FRAMEWORK_ISSUER]
148
150
  valid_issuers << EMULATOR_ISSUER if allow_emulator
149
- valid_issuers.any? { |i| issuer&.start_with?(i) || issuer == i }
151
+ valid_issuers.any? { |valid_issuer| issuer_matches?(issuer, valid_issuer) }
150
152
  end
151
153
 
152
154
  def check_issuer(payload, _allow_emulator)
153
155
  { valid: false, error: :invalid_issuer, issuer: payload['iss'] }
154
156
  end
157
+
158
+ def issuer_matches?(issuer, valid_issuer)
159
+ return false if issuer.to_s.empty?
160
+
161
+ prefix = valid_issuer.end_with?('/') ? valid_issuer : "#{valid_issuer}/"
162
+ issuer == valid_issuer || issuer.start_with?(prefix)
163
+ end
164
+
165
+ def jwks_mutex
166
+ @jwks_mutex ||= Mutex.new
167
+ end
155
168
  end
156
169
  end
157
170
  end
@@ -91,7 +91,8 @@ module Legion
91
91
  end
92
92
 
93
93
  def error_response(type, detail)
94
- { status: 401, type: type, detail: detail }
94
+ status = %i[missing_auth auth_failed].include?(type) ? 401 : 400
95
+ { status: status, type: type, detail: detail }
95
96
  end
96
97
  end
97
98
  end
@@ -17,7 +17,7 @@ module Legion
17
17
  module_function
18
18
 
19
19
  def classify(content)
20
- log.unknown "classify(#{content})"
20
+ log.debug "classify(#{content})"
21
21
  text = content.to_s.strip
22
22
  return :casual if text.empty?
23
23
 
@@ -31,12 +31,12 @@ module Legion
31
31
  end
32
32
 
33
33
  def direct_engage?(content)
34
- log.unknown "direct_engage?(#{content})"
34
+ log.debug "direct_engage?(#{content})"
35
35
  content.to_s.match?(DIRECT_ADDRESS_PATTERN)
36
36
  end
37
37
 
38
38
  def classify_with_engagement(content)
39
- log.unknown "classify_with_engagement(#{content})"
39
+ log.debug "classify_with_engagement(#{content})"
40
40
  { intent: classify(content), direct_engage: direct_engage?(content) }
41
41
  end
42
42
  end
@@ -25,14 +25,14 @@ module Legion
25
25
  signals << arousal_signal if @arousal
26
26
  signals << idle_signal if @idle_seconds
27
27
 
28
- return 1.0 if signals.empty?
28
+ return 0.0 if signals.empty?
29
29
 
30
30
  signals.sum / signals.size
31
31
  end
32
32
 
33
33
  def should_deliver?(priority: :normal)
34
34
  base = PRIORITY_BASE_THRESHOLD[priority] || PRIORITY_BASE_THRESHOLD[:normal]
35
- effective_threshold = base + ((1.0 - notification_score) * THRESHOLD_MODIFIER)
35
+ effective_threshold = base + (notification_score * THRESHOLD_MODIFIER)
36
36
  priority_value(priority) >= effective_threshold
37
37
  end
38
38
 
@@ -22,6 +22,15 @@ module Legion
22
22
  end
23
23
  end
24
24
 
25
+ def requeue(entry)
26
+ @mutex.synchronize do
27
+ evicted = nil
28
+ evicted = @entries.shift if @entries.size >= @max_size
29
+ @entries << entry.merge(retry_count: entry[:retry_count].to_i + 1)
30
+ evicted
31
+ end
32
+ end
33
+
25
34
  def size
26
35
  @mutex.synchronize { @entries.size }
27
36
  end
@@ -39,6 +39,10 @@ module Legion
39
39
  Australia/Sydney Australia/Melbourne Pacific/Auckland
40
40
  ].freeze
41
41
 
42
+ SOUTHERN_DST_ZONES = %w[
43
+ Australia/Sydney Australia/Melbourne Pacific/Auckland
44
+ ].freeze
45
+
42
46
  def initialize(schedule: [])
43
47
  @schedule = normalize_schedule(schedule)
44
48
  end
@@ -88,22 +92,22 @@ module Legion
88
92
  def localize(time, timezone)
89
93
  return time unless timezone
90
94
 
91
- offset = tz_offset(timezone)
95
+ offset = tz_offset(timezone, time)
92
96
  offset ? time.getlocal(offset) : time
93
97
  end
94
98
 
95
- def tz_offset(timezone)
99
+ def tz_offset(timezone, time = Time.now)
96
100
  return nil unless timezone
97
- return tzinfo_offset(timezone) if defined?(TZInfo)
101
+ return tzinfo_offset(timezone, time) if defined?(TZInfo)
98
102
 
99
103
  base = STANDARD_OFFSETS[timezone]
100
104
  return nil unless base
101
105
 
102
- dst_active? && DST_ZONES.include?(timezone) ? dst_shift(base) : base
106
+ dst_active?(timezone, time) && DST_ZONES.include?(timezone) ? dst_shift(base) : base
103
107
  end
104
108
 
105
- def tzinfo_offset(timezone)
106
- hours = TZInfo::Timezone.get(timezone).current_period.utc_total_offset / 3600.0
109
+ def tzinfo_offset(timezone, time)
110
+ hours = TZInfo::Timezone.get(timezone).period_for_utc(time.utc).utc_total_offset / 3600.0
107
111
  sign = hours.negative? ? '-' : '+'
108
112
  abs_h = hours.abs.to_i
109
113
  abs_m = ((hours.abs % 1) * 60).round
@@ -124,9 +128,11 @@ module Legion
124
128
  format('%<sign>s%<h>02d:%<m>02d', sign: new_sign, h: abs_total / 60, m: abs_total % 60)
125
129
  end
126
130
 
127
- def dst_active?
128
- m = Time.now.utc.month
129
- m.between?(3, 11)
131
+ def dst_active?(timezone, time)
132
+ month = time.utc.month
133
+ return month >= 10 || month <= 4 if SOUTHERN_DST_ZONES.include?(timezone)
134
+
135
+ month.between?(3, 11)
130
136
  end
131
137
  end
132
138
  end
@@ -54,14 +54,22 @@ module Legion
54
54
  end
55
55
 
56
56
  def process_delayed
57
- expired = @delay_queue.drain_expired
58
- deliverable = expired.map { |e| e[:frame] }
57
+ candidates = @delay_queue.drain_expired
59
58
 
60
59
  unless @schedule_evaluator.quiet?
61
60
  flushed = @delay_queue.flush
62
- deliverable.concat(flushed.map { |e| e[:frame] })
61
+ candidates.concat(flushed)
63
62
  end
64
63
 
64
+ deliverable = []
65
+ candidates.each do |entry|
66
+ frame = entry[:frame]
67
+ if evaluate(frame) == :deliver
68
+ deliverable << frame
69
+ else
70
+ @delay_queue.requeue(entry)
71
+ end
72
+ end
65
73
  deliverable
66
74
  end
67
75
 
@@ -21,37 +21,41 @@ module Legion
21
21
  end
22
22
 
23
23
  def agent_online?(worker_id)
24
- presence = presence_store[worker_id]
24
+ presence = mutex.synchronize { presence_store[worker_id] }
25
25
  return false unless presence
26
26
 
27
27
  (Time.now - presence[:last_seen]) < offline_threshold
28
28
  end
29
29
 
30
30
  def record_presence(worker_id)
31
- presence_store[worker_id] = { last_seen: Time.now }
31
+ mutex.synchronize { presence_store[worker_id] = { last_seen: Time.now } }
32
32
  log.debug("OfflineHandler recorded presence worker_id=#{worker_id}")
33
33
  end
34
34
 
35
35
  def pending_count(worker_id)
36
- pending_store[worker_id]&.size || 0
36
+ mutex.synchronize { pending_store[worker_id]&.size || 0 }
37
37
  end
38
38
 
39
39
  def drain_pending(worker_id)
40
- drained = pending_store.delete(worker_id) || []
40
+ drained = mutex.synchronize { pending_store.delete(worker_id) || [] }
41
41
  log.info("OfflineHandler drained pending worker_id=#{worker_id} count=#{drained.size}") if drained.any?
42
42
  drained
43
43
  end
44
44
 
45
45
  def reset!
46
- @presence_store = {}
47
- @pending_store = {}
46
+ mutex.synchronize do
47
+ @presence_store = {}
48
+ @pending_store = {}
49
+ end
48
50
  end
49
51
 
50
52
  private
51
53
 
52
54
  def queue_message(frame, worker_id)
53
- pending_store[worker_id] ||= []
54
- pending_store[worker_id] << { frame: frame, queued_at: Time.now }
55
+ mutex.synchronize do
56
+ pending_store[worker_id] ||= []
57
+ pending_store[worker_id] << { frame: frame, queued_at: Time.now }
58
+ end
55
59
  end
56
60
 
57
61
  def notify_sender(frame)
@@ -92,6 +96,10 @@ module Legion
92
96
  def pending_store
93
97
  @pending_store ||= {}
94
98
  end
99
+
100
+ def mutex
101
+ @mutex ||= Mutex.new
102
+ end
95
103
  end
96
104
  end
97
105
  end
@@ -201,18 +201,21 @@ module Legion
201
201
  }.freeze
202
202
 
203
203
  @previous_reflection = {}
204
+ @previous_reflection_mutex = Mutex.new
204
205
 
205
206
  module_function
206
207
 
207
208
  def previous_reflection
208
- @previous_reflection || {}
209
+ @previous_reflection_mutex.synchronize { @previous_reflection || {} }
209
210
  end
210
211
 
211
212
  def capture_tick_results(results)
212
213
  return unless results.is_a?(Hash)
213
214
 
214
215
  refl = results[:post_tick_reflection]
215
- @previous_reflection = refl if refl.is_a?(Hash) && refl[:status] != :skipped
216
+ @previous_reflection_mutex.synchronize do
217
+ @previous_reflection = refl if refl.is_a?(Hash) && refl[:status] != :skipped
218
+ end
216
219
  end
217
220
 
218
221
  def build_cognitive_state(prior_results)
@@ -23,43 +23,57 @@ module Legion
23
23
  @dispatch_log = []
24
24
  @pending_buffer = []
25
25
  @last_ignored_at = nil
26
+ @mutex = Mutex.new
26
27
  end
27
28
 
28
29
  def can_dispatch?
29
- prune_old_dispatches!
30
- return false if @dispatch_log.size >= @max_per_day
31
- return false if @dispatch_log.any? && (Time.now.utc - @dispatch_log.last[:at]) < @min_interval
32
- return false if @last_ignored_at && (Time.now.utc - @last_ignored_at) < @ignore_cooldown
30
+ @mutex.synchronize do
31
+ prune_old_dispatches!
32
+ return false if @dispatch_log.size >= @max_per_day
33
+ return false if @dispatch_log.any? && (Time.now.utc - @dispatch_log.last[:at]) < @min_interval
34
+ return false if @last_ignored_at && (Time.now.utc - @last_ignored_at) < @ignore_cooldown
35
+ end
33
36
 
34
37
  true
35
38
  end
36
39
 
37
40
  def record_dispatch!
38
- prune_old_dispatches!
39
- @dispatch_log << { at: Time.now.utc }
40
- log.info("ProactiveDispatcher recorded dispatch count=#{@dispatch_log.size}")
41
+ count = @mutex.synchronize do
42
+ prune_old_dispatches!
43
+ @dispatch_log << { at: Time.now.utc }
44
+ @dispatch_log.size
45
+ end
46
+ log.info("ProactiveDispatcher recorded dispatch count=#{count}")
41
47
  end
42
48
 
43
49
  def record_ignored!
44
- @last_ignored_at = Time.now.utc
50
+ @mutex.synchronize { @last_ignored_at = Time.now.utc }
45
51
  log.info('ProactiveDispatcher recorded ignored interaction')
46
52
  end
47
53
 
48
54
  def dispatches_today
49
- prune_old_dispatches!
50
- @dispatch_log.size
55
+ @mutex.synchronize do
56
+ prune_old_dispatches!
57
+ @dispatch_log.size
58
+ end
51
59
  end
52
60
 
53
61
  def queue_intent(intent)
54
- @pending_buffer << intent
55
- @pending_buffer.shift while @pending_buffer.size > MAX_PENDING
62
+ pending = @mutex.synchronize do
63
+ @pending_buffer << intent
64
+ @pending_buffer.shift while @pending_buffer.size > MAX_PENDING
65
+ @pending_buffer.size
66
+ end
56
67
  log.info("ProactiveDispatcher queued intent reason=#{intent.dig(:trigger,
57
- :reason)} pending=#{@pending_buffer.size}")
68
+ :reason)} pending=#{pending}")
58
69
  end
59
70
 
60
71
  def drain_pending
61
- drained = @pending_buffer.dup
62
- @pending_buffer.clear
72
+ drained = @mutex.synchronize do
73
+ copy = @pending_buffer.dup
74
+ @pending_buffer.clear
75
+ copy
76
+ end
63
77
  log.info("ProactiveDispatcher drained pending count=#{drained.size}") if drained.any?
64
78
  drained
65
79
  end
@@ -11,8 +11,8 @@ module Legion
11
11
  attr_reader :worker_routing, :channel_registry, :started
12
12
 
13
13
  def initialize(channel_registry:, worker_routing: nil, allowed_worker_ids: [])
14
- log.unknown "initialize(channel_registry: #{channel_registry}, " \
15
- "worker_routing: #{worker_routing}, allowed_worker_ids: #{allowed_worker_ids}"
14
+ log.debug "initialize(channel_registry: #{channel_registry}, " \
15
+ "worker_routing: #{worker_routing}, allowed_worker_ids: #{allowed_worker_ids}"
16
16
  @channel_registry = channel_registry
17
17
  @worker_routing = worker_routing || WorkerRouting.new(allowed_worker_ids: allowed_worker_ids)
18
18
  @started = false
@@ -20,10 +20,12 @@ module Legion
20
20
 
21
21
  def start
22
22
  @started = true
23
+ subscribe_outbound if transport_available?
23
24
  log.info("RouterBridge started workers=#{@worker_routing.size}")
24
25
  end
25
26
 
26
27
  def stop
28
+ @outbound_consumer&.cancel if @outbound_consumer.respond_to?(:cancel)
27
29
  @started = false
28
30
  log.info('RouterBridge stopped')
29
31
  end
@@ -33,7 +35,7 @@ module Legion
33
35
  end
34
36
 
35
37
  def route_inbound(input_frame)
36
- log.unknown "route_inbound(input_frame: #{input_frame})"
38
+ log.debug "route_inbound(input_frame: #{input_frame})"
37
39
  return { routed: false, reason: :not_started } unless started?
38
40
 
39
41
  identity = extract_identity(input_frame)
@@ -47,7 +49,7 @@ module Legion
47
49
  end
48
50
 
49
51
  def route_outbound(payload)
50
- log.unknown "route_outbound(payload: #{payload})"
52
+ log.debug "route_outbound(payload: #{payload})"
51
53
  return { delivered: false, reason: :not_started } unless started?
52
54
 
53
55
  frame = reconstruct_output_frame(payload)
@@ -82,6 +84,31 @@ module Legion
82
84
 
83
85
  private
84
86
 
87
+ def subscribe_outbound
88
+ queue = Transport::Queues::Outbound.new
89
+ @outbound_consumer = queue.subscribe(manual_ack: true, block: false) do |delivery_info, _metadata, payload|
90
+ message = decode_payload(payload)
91
+ unless message
92
+ queue.reject(delivery_info.delivery_tag, requeue: false)
93
+ next
94
+ end
95
+
96
+ delivery_result = route_outbound(message)
97
+ if delivery_result.is_a?(Hash) && delivery_result[:delivered] == true && !delivery_result[:error]
98
+ queue.acknowledge(delivery_info.delivery_tag)
99
+ else
100
+ log.warn(
101
+ 'RouterBridge outbound delivery not acknowledged ' \
102
+ "reason=#{delivery_result[:reason] || delivery_result[:error] || :unknown}"
103
+ )
104
+ queue.reject(delivery_info.delivery_tag, requeue: true)
105
+ end
106
+ rescue StandardError => e
107
+ handle_exception(e, level: :error, operation: 'gaia.router.router_bridge.subscribe_outbound')
108
+ queue.reject(delivery_info.delivery_tag, requeue: false)
109
+ end
110
+ end
111
+
85
112
  def extract_identity(input_frame)
86
113
  input_frame.auth_context[:principal_id] ||
87
114
  input_frame.auth_context[:aad_object_id] ||
@@ -108,15 +135,25 @@ module Legion
108
135
 
109
136
  def offline_response(input_frame)
110
137
  identity = extract_identity(input_frame)
138
+ offline_result = OfflineHandler.handle_offline_delivery(input_frame, worker_id: identity || :unassigned)
111
139
  log.warn("RouterBridge could not route inbound frame_id=#{input_frame.id} identity=#{identity}")
112
140
  {
113
141
  routed: false,
114
142
  reason: :worker_not_found,
115
143
  identity: identity,
116
- frame_id: input_frame.id
144
+ frame_id: input_frame.id,
145
+ queued: offline_result[:queued]
117
146
  }
118
147
  end
119
148
 
149
+ def decode_payload(raw)
150
+ parsed = Legion::JSON.load(raw)
151
+ parsed.is_a?(Hash) ? parsed : nil
152
+ rescue StandardError => e
153
+ handle_exception(e, level: :warn, operation: 'gaia.router.router_bridge.decode_payload')
154
+ nil
155
+ end
156
+
120
157
  def reconstruct_output_frame(payload)
121
158
  OutputFrame.new(
122
159
  id: payload[:id],
@@ -9,7 +9,7 @@ module Legion
9
9
  attr_reader :allowed_worker_ids
10
10
 
11
11
  def initialize(allowed_worker_ids: [])
12
- log.unknown "initialize(allowed_worker_ids: #{allowed_worker_ids})"
12
+ log.debug "initialize(allowed_worker_ids: #{allowed_worker_ids})"
13
13
  @routes = {}
14
14
  @allowed_worker_ids = allowed_worker_ids
15
15
  @mutex = Mutex.new
@@ -10,7 +10,7 @@
10
10
  module Legion
11
11
  module Gaia
12
12
  module Routes
13
- def self.registered(app)
13
+ def self.registered(app) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
14
14
  app.helpers do # rubocop:disable Metrics/BlockLength
15
15
  unless method_defined?(:gaia_available?)
16
16
  define_method(:gaia_available?) do
@@ -47,9 +47,9 @@ module Legion
47
47
  end
48
48
 
49
49
  unless method_defined?(:json_error)
50
- define_method(:json_error) do |code, message, status_code: 400|
50
+ define_method(:json_error) do |code, message, status_code: nil|
51
51
  content_type :json
52
- status status_code
52
+ status status_code if status_code
53
53
  Legion::JSON.dump({ error: { code: code, message: message } })
54
54
  end
55
55
  end
@@ -76,7 +76,7 @@ module Legion
76
76
 
77
77
  def self.register_ticks_route(app)
78
78
  app.get '/api/gaia/ticks' do
79
- halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available?
79
+ halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
80
80
 
81
81
  max_limit =
82
82
  if defined?(Legion::Gaia::TickHistory) && Legion::Gaia::TickHistory.const_defined?(:MAX_ENTRIES)
@@ -93,7 +93,7 @@ module Legion
93
93
 
94
94
  def self.register_channels_route(app)
95
95
  app.get '/api/gaia/channels' do
96
- halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available?
96
+ halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
97
97
 
98
98
  registry = Legion::Gaia.channel_registry
99
99
  return json_response({ channels: [] }) unless registry
@@ -109,7 +109,7 @@ module Legion
109
109
 
110
110
  def self.register_buffer_route(app)
111
111
  app.get '/api/gaia/buffer' do
112
- halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available?
112
+ halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
113
113
 
114
114
  buffer = Legion::Gaia.sensory_buffer
115
115
  json_response({
@@ -122,7 +122,7 @@ module Legion
122
122
 
123
123
  def self.register_sessions_route(app)
124
124
  app.get '/api/gaia/sessions' do
125
- halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available?
125
+ halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
126
126
 
127
127
  store = Legion::Gaia.session_store
128
128
  json_response({
@@ -176,14 +176,14 @@ module Legion
176
176
 
177
177
  def self.register_ingest_route(app)
178
178
  app.post '/api/gaia/ingest' do
179
- halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available?
179
+ halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
180
180
 
181
181
  body = Legion::JSON.load(request.body.read)
182
182
  content = body[:content] || body['content']
183
183
  identity = body[:identity] || body['identity'] || 'unknown'
184
184
  channel = (body[:channel_id] || body['channel_id'] || 'cli').to_sym
185
185
 
186
- halt 400, json_error('missing_content', 'content is required', status_code: 400) if content.to_s.empty?
186
+ halt 400, json_error('missing_content', 'content is required') if content.to_s.empty?
187
187
 
188
188
  frame = Legion::Gaia::InputFrame.new(
189
189
  content: content,
@@ -110,7 +110,7 @@ module Legion
110
110
  # during the migration window.
111
111
  @identity_index[normalized_identity] = old_session_id
112
112
  @canonical_to_uuid[canonical_key] = normalized_identity
113
- (@session_identity_index[old_session_id] ||= []) << normalized_identity
113
+ (@session_identity_index[old_session_id] ||= []).push(canonical_key, normalized_identity).uniq!
114
114
  old_session_id
115
115
  end
116
116
 
@@ -17,12 +17,12 @@ module Legion
17
17
  def record(tick_result)
18
18
  return unless tick_result.is_a?(Hash) && tick_result[:results].is_a?(Hash)
19
19
 
20
- timestamp = Time.now.utc.iso8601
20
+ timestamp = Time.now.utc.iso8601(3).freeze
21
21
  new_events = tick_result[:results].filter_map do |phase_name, phase_data|
22
22
  next unless phase_data.is_a?(Hash)
23
23
 
24
24
  {
25
- timestamp: timestamp.freeze,
25
+ timestamp: timestamp,
26
26
  phase: phase_name.to_s.freeze,
27
27
  duration_ms: phase_data[:elapsed_ms] || phase_data[:duration_ms] || 0.0,
28
28
  status: phase_data[:status] || :completed
@@ -12,13 +12,15 @@ module Legion
12
12
  module_function
13
13
 
14
14
  def register_tracker(name, tracker:, tags:)
15
- @trackers ||= {}
16
- @trackers[name] = { tracker: tracker, tags: tags }
15
+ mutex.synchronize do
16
+ @trackers ||= {}
17
+ @trackers[name] = { tracker: tracker, tags: tags }
18
+ end
17
19
  log.info("TrackerPersistence registered tracker=#{name} tags=#{Array(tags).join(',')}")
18
20
  end
19
21
 
20
22
  def registered_trackers
21
- @trackers || {}
23
+ mutex.synchronize { (@trackers || {}).dup }
22
24
  end
23
25
 
24
26
  def flush_dirty(store: nil)
@@ -26,7 +28,8 @@ module Legion
26
28
 
27
29
  failed = false
28
30
  flushed = 0
29
- registered_trackers.each_value do |entry|
31
+ entries = registered_trackers.values
32
+ entries.each do |entry|
30
33
  tracker = entry[:tracker]
31
34
  next unless tracker.dirty?
32
35
 
@@ -36,7 +39,7 @@ module Legion
36
39
  failed = true
37
40
  end
38
41
  end
39
- @last_flush_at = Time.now.utc unless failed
42
+ mutex.synchronize { @last_flush_at = Time.now.utc } unless failed
40
43
  if failed
41
44
  log.warn("TrackerPersistence flush_dirty completed with errors flushed=#{flushed}")
42
45
  else
@@ -50,11 +53,12 @@ module Legion
50
53
  return unless store
51
54
 
52
55
  failed = false
53
- registered_trackers.each_value do |entry|
56
+ entries = registered_trackers.values
57
+ entries.each do |entry|
54
58
  failed ||= !flush_tracker(entry[:tracker], store: store)
55
59
  end
56
- @last_flush_at = Time.now.utc unless failed
57
- log.info("TrackerPersistence flushed all trackers count=#{registered_trackers.size}")
60
+ mutex.synchronize { @last_flush_at = Time.now.utc } unless failed
61
+ log.info("TrackerPersistence flushed all trackers count=#{entries.size}")
58
62
  rescue StandardError => e
59
63
  handle_exception(e, level: :warn, operation: 'gaia.tracker_persistence.flush_all')
60
64
  end
@@ -71,18 +75,22 @@ module Legion
71
75
  end
72
76
 
73
77
  def last_flush_at
74
- @last_flush_at
78
+ mutex.synchronize { @last_flush_at }
75
79
  end
76
80
 
77
81
  def should_flush?
78
- return true if @last_flush_at.nil?
82
+ mutex.synchronize do
83
+ return true if @last_flush_at.nil?
79
84
 
80
- (Time.now.utc - @last_flush_at) >= FLUSH_INTERVAL
85
+ (Time.now.utc - @last_flush_at) >= FLUSH_INTERVAL
86
+ end
81
87
  end
82
88
 
83
89
  def reset!
84
- @trackers = {}
85
- @last_flush_at = nil
90
+ mutex.synchronize do
91
+ @trackers = {}
92
+ @last_flush_at = nil
93
+ end
86
94
  end
87
95
 
88
96
  def flush_tracker(tracker, store:)
@@ -118,6 +126,11 @@ module Legion
118
126
  true
119
127
  end
120
128
  private_class_method :upsert_succeeded?
129
+
130
+ def mutex
131
+ @mutex ||= Mutex.new
132
+ end
133
+ private_class_method :mutex
121
134
  end
122
135
  end
123
136
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Gaia
5
- VERSION = '0.9.51'
5
+ VERSION = '0.9.52'
6
6
  end
7
7
  end
@@ -51,16 +51,18 @@ module Legion
51
51
  self
52
52
  end
53
53
 
54
- # Non-raising variant — returns true on success, false on guard/checkpoint failure.
55
- # Unknown-state and invalid-transition errors still propagate.
54
+ # Non-raising variant — returns true on success, false on transition failure.
55
+ # Pass strict: true to propagate unknown-state and invalid-transition errors.
56
56
  #
57
57
  # @param to_state [Symbol]
58
58
  # @param ctx [Hash]
59
59
  # @return [Boolean]
60
- def transition(to_state, **ctx)
60
+ def transition(to_state, strict: false, **ctx)
61
61
  transition!(to_state, **ctx)
62
62
  true
63
- rescue GuardRejected, CheckpointBlocked => e
63
+ rescue GuardRejected, CheckpointBlocked, UnknownState, InvalidTransition => e
64
+ raise if strict && (e.is_a?(UnknownState) || e.is_a?(InvalidTransition))
65
+
64
66
  handle_exception(e, level: :debug, operation: 'gaia.workflow.instance.transition',
65
67
  workflow: definition.name, to_state: to_state)
66
68
  false
data/lib/legion/gaia.rb CHANGED
@@ -206,6 +206,10 @@ module Legion
206
206
  session = @session_store&.find_or_create(identity: identity || :anonymous)
207
207
  @session_store&.touch(session.id, channel_id: input_frame.channel_id) if session
208
208
 
209
+ if identity
210
+ BondRegistry.record_channel(identity.to_s, channel_id: input_frame.channel_id,
211
+ channel_identity: channel_identity(input_frame))
212
+ end
209
213
  observe_interlocutor(input_frame, identity) if identity && identity != :anonymous
210
214
 
211
215
  log.info(
@@ -387,8 +391,6 @@ module Legion
387
391
  end
388
392
 
389
393
  def boot_agent_bridge
390
- return unless settings&.dig(:router, :mode)
391
-
392
394
  worker_id = settings&.dig(:router, :worker_id)
393
395
  return unless worker_id
394
396
 
@@ -499,6 +501,11 @@ module Legion
499
501
  ctx[:user_id]
500
502
  end
501
503
 
504
+ def channel_identity(input_frame)
505
+ ctx = input_frame.auth_context || {}
506
+ ctx[:channel_identity] || ctx[:user_id] || ctx[:identity]
507
+ end
508
+
502
509
  def observe_interlocutor(input_frame, identity)
503
510
  role = BondRegistry.bond(identity.to_s)
504
511
 
@@ -544,7 +551,7 @@ module Legion
544
551
  },
545
552
  domain_tags: ['partner_interaction', observation[:channel].to_s],
546
553
  origin: :direct_experience,
547
- emotional_valence: @last_valences&.dig(0, :urgency).to_s,
554
+ emotional_valence: @last_valences&.first&.inspect,
548
555
  emotional_intensity: 0.5,
549
556
  confidence: 0.8
550
557
  )
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-gaia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.51
4
+ version: 0.9.52
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity