lex-tick 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/legion/extensions/tick/helpers/constants.rb +3 -1
- data/lib/legion/extensions/tick/helpers/state.rb +19 -7
- data/lib/legion/extensions/tick/runners/orchestrator.rb +36 -9
- data/lib/legion/extensions/tick/version.rb +1 -1
- data/spec/legion/extensions/tick/helpers/constants_spec.rb +10 -2
- data/spec/legion/extensions/tick/helpers/state_spec.rb +14 -0
- data/spec/legion/extensions/tick/runners/orchestrator_spec.rb +80 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5adf45874a3807dccf25a41669745614b8c1c5051cea86634e15b9e68cec83d8
|
|
4
|
+
data.tar.gz: 313f7a0b71ee43ddb9e752c809419dd5b0dd6e8c7a7797e1abfe2cc7318b3e38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 269f481503c6e5fdc94df7049b3eb19a8909d53434bfd34838b202b10aef4a17bea4083869d8651d000d1bf05bfb3d38d922e8a66fe244f5da109b37000abc66
|
|
7
|
+
data.tar.gz: 13b540ccc95b33b58508be8a133bb4c9823ff42dddfd643bb63babbfb892756acd589731bc6b696236ff72c7fdff0ac4273e5ad2dd60645c465878cf31c34b4b
|
|
@@ -55,7 +55,9 @@ module Legion
|
|
|
55
55
|
SENTINEL_TIMEOUT = 3600 # seconds without any signal before demotion to dormant
|
|
56
56
|
DREAM_IDLE_THRESHOLD = 1800 # seconds dormant with no signal before entering dream cycle
|
|
57
57
|
SENTINEL_TO_DREAM_THRESHOLD = 600 # seconds sentinel with no signal before entering dream cycle
|
|
58
|
+
DREAM_BACKOFF_INTERVAL = 1800 # seconds after a completed dream before another dream cycle
|
|
58
59
|
MAX_TICK_DURATION = 5.0 # hard ceiling for full active tick (seconds)
|
|
60
|
+
DREAM_TICK_BUDGET = 5.0 # hard ceiling for dormant-active dream tick (seconds)
|
|
59
61
|
SENTINEL_TICK_BUDGET = 0.5 # time budget for sentinel tick
|
|
60
62
|
DORMANT_TICK_BUDGET = 0.2 # time budget for dormant tick
|
|
61
63
|
EMERGENCY_PROMOTION_BUDGET = 0.05 # max latency for emergency mode promotion
|
|
@@ -95,7 +97,7 @@ module Legion
|
|
|
95
97
|
def tick_budget(mode)
|
|
96
98
|
case mode
|
|
97
99
|
when :dormant then DORMANT_TICK_BUDGET
|
|
98
|
-
when :dormant_active then
|
|
100
|
+
when :dormant_active then DREAM_TICK_BUDGET
|
|
99
101
|
when :sentinel then SENTINEL_TICK_BUDGET
|
|
100
102
|
else MAX_TICK_DURATION
|
|
101
103
|
end
|
|
@@ -5,7 +5,7 @@ module Legion
|
|
|
5
5
|
module Tick
|
|
6
6
|
module Helpers
|
|
7
7
|
class State
|
|
8
|
-
attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at,
|
|
8
|
+
attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at, :last_dream_completed_at,
|
|
9
9
|
:phase_results, :current_phase, :mode_history
|
|
10
10
|
|
|
11
11
|
def initialize(mode: :dormant)
|
|
@@ -13,6 +13,7 @@ module Legion
|
|
|
13
13
|
@tick_count = 0
|
|
14
14
|
@last_signal_at = nil
|
|
15
15
|
@last_high_salience_at = nil
|
|
16
|
+
@last_dream_completed_at = nil
|
|
16
17
|
@phase_results = {}
|
|
17
18
|
@current_phase = nil
|
|
18
19
|
@mode_history = [{ mode: mode, at: Time.now.utc }]
|
|
@@ -44,6 +45,10 @@ module Legion
|
|
|
44
45
|
@mode_history.shift while @mode_history.size > 50
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
def record_dream_completed
|
|
49
|
+
@last_dream_completed_at = Time.now.utc
|
|
50
|
+
end
|
|
51
|
+
|
|
47
52
|
def seconds_since_signal
|
|
48
53
|
return 0.0 unless @last_signal_at
|
|
49
54
|
|
|
@@ -56,14 +61,21 @@ module Legion
|
|
|
56
61
|
Time.now.utc - @last_high_salience_at
|
|
57
62
|
end
|
|
58
63
|
|
|
64
|
+
def seconds_since_dream_completed
|
|
65
|
+
return Float::INFINITY unless @last_dream_completed_at
|
|
66
|
+
|
|
67
|
+
Time.now.utc - @last_dream_completed_at
|
|
68
|
+
end
|
|
69
|
+
|
|
59
70
|
def to_h
|
|
60
71
|
{
|
|
61
|
-
mode:
|
|
62
|
-
tick_count:
|
|
63
|
-
current_phase:
|
|
64
|
-
last_signal_at:
|
|
65
|
-
last_high_salience_at:
|
|
66
|
-
|
|
72
|
+
mode: @mode,
|
|
73
|
+
tick_count: @tick_count,
|
|
74
|
+
current_phase: @current_phase,
|
|
75
|
+
last_signal_at: @last_signal_at,
|
|
76
|
+
last_high_salience_at: @last_high_salience_at,
|
|
77
|
+
last_dream_completed_at: @last_dream_completed_at,
|
|
78
|
+
phases_completed: @phase_results.keys
|
|
67
79
|
}
|
|
68
80
|
end
|
|
69
81
|
end
|
|
@@ -25,7 +25,7 @@ module Legion
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def evaluate_mode_transition(signals: [], emergency: nil, dream_complete: false, **) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
28
|
+
def evaluate_mode_transition(signals: [], emergency: nil, dream_complete: false, **) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
29
29
|
state = tick_state
|
|
30
30
|
previous_mode = state.mode
|
|
31
31
|
|
|
@@ -44,7 +44,7 @@ module Legion
|
|
|
44
44
|
when :dormant
|
|
45
45
|
if signals.any?
|
|
46
46
|
:sentinel
|
|
47
|
-
elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD
|
|
47
|
+
elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD && dream_backoff_elapsed?(state)
|
|
48
48
|
:dormant_active
|
|
49
49
|
else
|
|
50
50
|
:dormant
|
|
@@ -53,6 +53,7 @@ module Legion
|
|
|
53
53
|
if max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD || has_human
|
|
54
54
|
:sentinel
|
|
55
55
|
elsif dream_complete
|
|
56
|
+
state.record_dream_completed
|
|
56
57
|
:dormant
|
|
57
58
|
else
|
|
58
59
|
:dormant_active
|
|
@@ -60,10 +61,11 @@ module Legion
|
|
|
60
61
|
when :sentinel
|
|
61
62
|
if has_human || max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD
|
|
62
63
|
:full_active
|
|
63
|
-
elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TO_DREAM_THRESHOLD
|
|
64
|
-
:dormant_active
|
|
65
64
|
elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TIMEOUT
|
|
66
65
|
:dormant
|
|
66
|
+
elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TO_DREAM_THRESHOLD &&
|
|
67
|
+
dream_backoff_elapsed?(state)
|
|
68
|
+
:dormant_active
|
|
67
69
|
else
|
|
68
70
|
:sentinel
|
|
69
71
|
end
|
|
@@ -98,9 +100,8 @@ module Legion
|
|
|
98
100
|
|
|
99
101
|
previous = tick_state.mode
|
|
100
102
|
tick_state.transition_to(mode)
|
|
101
|
-
@mode_forced
|
|
102
|
-
|
|
103
|
-
log.info "[tick] mode forced: #{previous} -> #{mode} (protected until #{@force_expires_at})"
|
|
103
|
+
@mode_forced = true
|
|
104
|
+
log.info "[tick] mode forced: #{previous} -> #{mode}"
|
|
104
105
|
{ mode: mode }
|
|
105
106
|
end
|
|
106
107
|
|
|
@@ -120,8 +121,7 @@ module Legion
|
|
|
120
121
|
# @mode_forced is set by set_mode and cleared here so the force lasts exactly one cycle.
|
|
121
122
|
if @mode_forced
|
|
122
123
|
log.debug "[tick] ##{state.tick_count} skipping mode evaluation (mode forced to #{state.mode})"
|
|
123
|
-
@mode_forced
|
|
124
|
-
@force_expires_at = nil
|
|
124
|
+
@mode_forced = false
|
|
125
125
|
else
|
|
126
126
|
evaluate_mode_transition(signals: signals)
|
|
127
127
|
end
|
|
@@ -137,6 +137,7 @@ module Legion
|
|
|
137
137
|
context: context
|
|
138
138
|
}
|
|
139
139
|
results = run_phases(phases, state, ctx)
|
|
140
|
+
finish_dream_cycle(state, phases, results)
|
|
140
141
|
|
|
141
142
|
total_elapsed = Time.now.utc - start_time
|
|
142
143
|
skipped = phases - results.keys
|
|
@@ -187,6 +188,28 @@ module Legion
|
|
|
187
188
|
"elapsed=#{(total_elapsed * 1000).round(1)}ms#{skipped_suffix}"
|
|
188
189
|
end
|
|
189
190
|
|
|
191
|
+
def finish_dream_cycle(state, phases, results)
|
|
192
|
+
return unless state.mode == :dormant_active
|
|
193
|
+
return unless phases == Helpers::Constants::DREAM_PHASES
|
|
194
|
+
|
|
195
|
+
skipped = phases - results.keys
|
|
196
|
+
failed = failed_dream_phases(results)
|
|
197
|
+
state.record_dream_completed
|
|
198
|
+
state.transition_to(:dormant)
|
|
199
|
+
if skipped.empty? && failed.empty?
|
|
200
|
+
log.info '[tick] dream cycle complete; backing off to dormant'
|
|
201
|
+
else
|
|
202
|
+
log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def failed_dream_phases(results)
|
|
207
|
+
results.each_with_object([]) do |(phase, result), failed|
|
|
208
|
+
status = result.is_a?(Hash) ? result[:status] : nil
|
|
209
|
+
failed << phase if %i[error failed failure timeout].include?(status)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
190
213
|
def full_active_cooldown_elapsed(state)
|
|
191
214
|
if state.last_high_salience_at
|
|
192
215
|
state.seconds_since_high_salience
|
|
@@ -197,6 +220,10 @@ module Legion
|
|
|
197
220
|
end
|
|
198
221
|
end
|
|
199
222
|
|
|
223
|
+
def dream_backoff_elapsed?(state)
|
|
224
|
+
state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
|
|
225
|
+
end
|
|
226
|
+
|
|
200
227
|
def tick_state
|
|
201
228
|
@tick_state ||= Helpers::State.new
|
|
202
229
|
end
|
|
@@ -87,8 +87,8 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
describe '.tick_budget' do
|
|
90
|
-
it 'returns
|
|
91
|
-
expect(described_class.tick_budget(:dormant_active)).to eq(
|
|
90
|
+
it 'returns DREAM_TICK_BUDGET for dormant_active' do
|
|
91
|
+
expect(described_class.tick_budget(:dormant_active)).to eq(described_class::DREAM_TICK_BUDGET)
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -100,5 +100,13 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
|
|
|
100
100
|
it 'defines SENTINEL_TO_DREAM_THRESHOLD as 600' do
|
|
101
101
|
expect(described_class::SENTINEL_TO_DREAM_THRESHOLD).to eq(600)
|
|
102
102
|
end
|
|
103
|
+
|
|
104
|
+
it 'defines DREAM_BACKOFF_INTERVAL as 1800' do
|
|
105
|
+
expect(described_class::DREAM_BACKOFF_INTERVAL).to eq(1800)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'defines DREAM_TICK_BUDGET as finite' do
|
|
109
|
+
expect(described_class::DREAM_TICK_BUDGET).to eq(5.0)
|
|
110
|
+
end
|
|
103
111
|
end
|
|
104
112
|
end
|
|
@@ -60,6 +60,19 @@ RSpec.describe Legion::Extensions::Tick::Helpers::State do
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
describe '#record_dream_completed' do
|
|
64
|
+
it 'updates last_dream_completed_at' do
|
|
65
|
+
state.record_dream_completed
|
|
66
|
+
expect(state.last_dream_completed_at).not_to be_nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#seconds_since_dream_completed' do
|
|
71
|
+
it 'treats missing dream completion history as eligible for a dream cycle' do
|
|
72
|
+
expect(state.seconds_since_dream_completed).to eq(Float::INFINITY)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
describe '#transition_to' do
|
|
64
77
|
it 'changes mode' do
|
|
65
78
|
state.transition_to(:sentinel)
|
|
@@ -83,6 +96,7 @@ RSpec.describe Legion::Extensions::Tick::Helpers::State do
|
|
|
83
96
|
h = state.to_h
|
|
84
97
|
expect(h).to have_key(:mode)
|
|
85
98
|
expect(h).to have_key(:tick_count)
|
|
99
|
+
expect(h).to have_key(:last_dream_completed_at)
|
|
86
100
|
expect(h).to have_key(:phases_completed)
|
|
87
101
|
end
|
|
88
102
|
end
|
|
@@ -67,6 +67,56 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
67
67
|
result = client.execute_tick(signals: [{ salience: 0.3 }])
|
|
68
68
|
expect(result[:mode]).to eq(:sentinel)
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
it 'completes one dormant_active dream cycle and backs off to dormant' do
|
|
72
|
+
client.set_mode(mode: :dormant_active)
|
|
73
|
+
|
|
74
|
+
result = client.execute_tick
|
|
75
|
+
|
|
76
|
+
expect(result[:phases_executed]).to eq(Legion::Extensions::Tick::Helpers::Constants::DREAM_PHASES)
|
|
77
|
+
expect(result[:mode]).to eq(:dormant)
|
|
78
|
+
expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'does not immediately restart a completed dream cycle on the next heartbeat' do
|
|
82
|
+
state = client.send(:tick_state)
|
|
83
|
+
allow(state).to receive(:seconds_since_signal).and_return(1801.0)
|
|
84
|
+
|
|
85
|
+
client.set_mode(mode: :dormant_active)
|
|
86
|
+
client.execute_tick
|
|
87
|
+
result = client.execute_tick
|
|
88
|
+
|
|
89
|
+
expect(result[:mode]).to eq(:dormant)
|
|
90
|
+
expect(result[:phases_executed]).to eq([:memory_consolidation])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'backs off to dormant when a dream phase fails' do
|
|
94
|
+
client.set_mode(mode: :dormant_active)
|
|
95
|
+
|
|
96
|
+
result = client.execute_tick(
|
|
97
|
+
phase_handlers: {
|
|
98
|
+
dream_narration: ->(**) { { status: :error } }
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(result[:mode]).to eq(:dormant)
|
|
103
|
+
expect(result[:results][:dream_narration][:status]).to eq(:error)
|
|
104
|
+
expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'backs off to dormant when the dream tick budget is exhausted before all phases run' do
|
|
108
|
+
constants = Legion::Extensions::Tick::Helpers::Constants
|
|
109
|
+
allow(constants).to receive(:tick_budget).and_call_original
|
|
110
|
+
allow(constants).to receive(:tick_budget).with(:dormant_active).and_return(0.0)
|
|
111
|
+
|
|
112
|
+
client.set_mode(mode: :dormant_active)
|
|
113
|
+
result = client.execute_tick
|
|
114
|
+
|
|
115
|
+
expect(result[:mode]).to eq(:dormant)
|
|
116
|
+
expect(result[:phases_executed]).to be_empty
|
|
117
|
+
expect(result[:phases_skipped]).to eq(constants::DREAM_PHASES)
|
|
118
|
+
expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
|
|
119
|
+
end
|
|
70
120
|
end
|
|
71
121
|
|
|
72
122
|
describe '#evaluate_mode_transition' do
|
|
@@ -138,6 +188,7 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
138
188
|
result = client.evaluate_mode_transition(dream_complete: true)
|
|
139
189
|
expect(result[:transitioned]).to be true
|
|
140
190
|
expect(result[:new_mode]).to eq(:dormant)
|
|
191
|
+
expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
|
|
141
192
|
end
|
|
142
193
|
|
|
143
194
|
it 'stays dormant_active when no signals and dream not complete' do
|
|
@@ -146,6 +197,29 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
146
197
|
expect(result[:transitioned]).to be false
|
|
147
198
|
expect(result[:current_mode]).to eq(:dormant_active)
|
|
148
199
|
end
|
|
200
|
+
|
|
201
|
+
it 'keeps dormant backed off after a completed dream cycle' do
|
|
202
|
+
state = client.send(:tick_state)
|
|
203
|
+
state.record_dream_completed
|
|
204
|
+
allow(state).to receive(:seconds_since_signal).and_return(1801.0)
|
|
205
|
+
|
|
206
|
+
result = client.evaluate_mode_transition
|
|
207
|
+
|
|
208
|
+
expect(result[:transitioned]).to be false
|
|
209
|
+
expect(result[:current_mode]).to eq(:dormant)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'allows dormant_active again after dream backoff elapses' do
|
|
213
|
+
state = client.send(:tick_state)
|
|
214
|
+
state.record_dream_completed
|
|
215
|
+
allow(state).to receive(:seconds_since_signal).and_return(1801.0)
|
|
216
|
+
allow(state).to receive(:seconds_since_dream_completed).and_return(1801.0)
|
|
217
|
+
|
|
218
|
+
result = client.evaluate_mode_transition
|
|
219
|
+
|
|
220
|
+
expect(result[:transitioned]).to be true
|
|
221
|
+
expect(result[:new_mode]).to eq(:dormant_active)
|
|
222
|
+
end
|
|
149
223
|
end
|
|
150
224
|
|
|
151
225
|
context 'sentinel -> dormant_active' do
|
|
@@ -181,6 +255,12 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
181
255
|
expect(result[:mode]).to eq(:full_active)
|
|
182
256
|
end
|
|
183
257
|
|
|
258
|
+
it 'does not expose an unused force expiration timestamp' do
|
|
259
|
+
client.set_mode(mode: :full_active)
|
|
260
|
+
|
|
261
|
+
expect(client.instance_variable_defined?(:@force_expires_at)).to be false
|
|
262
|
+
end
|
|
263
|
+
|
|
184
264
|
it 'rejects invalid mode' do
|
|
185
265
|
result = client.set_mode(mode: :invalid)
|
|
186
266
|
expect(result[:error]).to eq(:invalid_mode)
|