lex-tick 0.1.17 → 0.1.18
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/client.rb +2 -0
- data/lib/legion/extensions/tick/helpers/constants.rb +27 -2
- data/lib/legion/extensions/tick/helpers/state.rb +13 -1
- data/lib/legion/extensions/tick/runners/orchestrator.rb +66 -24
- data/lib/legion/extensions/tick/version.rb +1 -1
- data/spec/legion/extensions/tick/client_spec.rb +14 -4
- data/spec/legion/extensions/tick/helpers/constants_spec.rb +0 -35
- data/spec/legion/extensions/tick/runners/orchestrator_spec.rb +193 -45
- 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: 86c832a54da11de90ede212d84cfd65a4fed13510e0d4cf7307e3127723fdaf9
|
|
4
|
+
data.tar.gz: 232d3c7e96e3f9c78d86e3452e2f50429a5c09eee1336643d87b39df5c06a5f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d8319d73e2fed107c41db9066436b169a469aa3315a1b1bbe08dab283544676711f310e82e537b6c6980cc95f3906043b1d85d308abac57ae10d9b3c4a45585
|
|
7
|
+
data.tar.gz: 89678c7b0ed29423b3267387f39666031237e0a5eedcd47e7fa033532c4a57db85640f94f839ea453f0f3a03520acec07393b495c80c8d5ff23be217d674f708
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging'
|
|
3
4
|
require 'legion/extensions/tick/helpers/constants'
|
|
4
5
|
require 'legion/extensions/tick/helpers/state'
|
|
5
6
|
require 'legion/extensions/tick/runners/orchestrator'
|
|
@@ -8,6 +9,7 @@ module Legion
|
|
|
8
9
|
module Extensions
|
|
9
10
|
module Tick
|
|
10
11
|
class Client
|
|
12
|
+
include Legion::Logging::Helper
|
|
11
13
|
include Runners::Orchestrator
|
|
12
14
|
|
|
13
15
|
def initialize(mode: :dormant, **)
|
|
@@ -50,6 +50,13 @@ module Legion
|
|
|
50
50
|
full_active: PHASES
|
|
51
51
|
}.freeze
|
|
52
52
|
|
|
53
|
+
# Only bounded, consumer-facing work belongs in the optional tick schedule.
|
|
54
|
+
OPTIONAL_PHASES = {
|
|
55
|
+
global_workspace: { mode: :full_active, after: :gut_instinct, requires: %i[sensory_processing].freeze }.freeze,
|
|
56
|
+
metacognition: { mode: :full_active, after: :post_tick_reflection, requires: %i[post_tick_reflection].freeze }.freeze
|
|
57
|
+
}.freeze
|
|
58
|
+
INCOMPLETE_PHASE_STATUSES = %i[error failed failure timeout no_handler skipped deferred unavailable no_result].freeze
|
|
59
|
+
|
|
53
60
|
# Timing constants (in seconds)
|
|
54
61
|
ACTIVE_TIMEOUT = 300 # seconds without high-salience signal before demotion
|
|
55
62
|
SENTINEL_TIMEOUT = 3600 # seconds without any signal before demotion to dormant
|
|
@@ -90,8 +97,26 @@ module Legion
|
|
|
90
97
|
|
|
91
98
|
module_function
|
|
92
99
|
|
|
93
|
-
def phases_for_mode(mode)
|
|
94
|
-
|
|
100
|
+
def phases_for_mode(mode, enabled_optional_phases: [])
|
|
101
|
+
validate_optional_phases(enabled_optional_phases)
|
|
102
|
+
phases = MODE_PHASES.fetch(mode, PHASES)
|
|
103
|
+
return phases if enabled_optional_phases.empty?
|
|
104
|
+
|
|
105
|
+
phases.each_with_object([]) do |phase, scheduled|
|
|
106
|
+
scheduled << phase
|
|
107
|
+
OPTIONAL_PHASES.each do |optional, contract|
|
|
108
|
+
next unless enabled_optional_phases.include?(optional) && contract[:mode] == mode && contract[:after] == phase
|
|
109
|
+
|
|
110
|
+
scheduled << optional
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_optional_phases(phases)
|
|
116
|
+
raise ArgumentError, 'enabled_optional_phases must be an Array of phase symbols' unless phases.is_a?(Array)
|
|
117
|
+
|
|
118
|
+
unsupported = phases.reject { |phase| OPTIONAL_PHASES.key?(phase) }
|
|
119
|
+
raise ArgumentError, "unsupported optional phases: #{unsupported.inspect}" unless unsupported.empty?
|
|
95
120
|
end
|
|
96
121
|
|
|
97
122
|
def tick_budget(mode)
|
|
@@ -6,7 +6,7 @@ module Legion
|
|
|
6
6
|
module Helpers
|
|
7
7
|
class State
|
|
8
8
|
attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at, :last_dream_completed_at,
|
|
9
|
-
:phase_results, :current_phase, :mode_history
|
|
9
|
+
:last_dream_deferred_at, :phase_results, :current_phase, :mode_history
|
|
10
10
|
|
|
11
11
|
def initialize(mode: :dormant)
|
|
12
12
|
@mode = mode
|
|
@@ -14,6 +14,7 @@ module Legion
|
|
|
14
14
|
@last_signal_at = nil
|
|
15
15
|
@last_high_salience_at = nil
|
|
16
16
|
@last_dream_completed_at = nil
|
|
17
|
+
@last_dream_deferred_at = nil
|
|
17
18
|
@phase_results = {}
|
|
18
19
|
@current_phase = nil
|
|
19
20
|
@mode_history = [{ mode: mode, at: Time.now.utc }]
|
|
@@ -49,6 +50,10 @@ module Legion
|
|
|
49
50
|
@last_dream_completed_at = Time.now.utc
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
def record_dream_deferred
|
|
54
|
+
@last_dream_deferred_at = Time.now.utc
|
|
55
|
+
end
|
|
56
|
+
|
|
52
57
|
def seconds_since_signal
|
|
53
58
|
return 0.0 unless @last_signal_at
|
|
54
59
|
|
|
@@ -67,6 +72,12 @@ module Legion
|
|
|
67
72
|
Time.now.utc - @last_dream_completed_at
|
|
68
73
|
end
|
|
69
74
|
|
|
75
|
+
def seconds_since_dream_deferred
|
|
76
|
+
return Float::INFINITY unless @last_dream_deferred_at
|
|
77
|
+
|
|
78
|
+
Time.now.utc - @last_dream_deferred_at
|
|
79
|
+
end
|
|
80
|
+
|
|
70
81
|
def to_h
|
|
71
82
|
{
|
|
72
83
|
mode: @mode,
|
|
@@ -75,6 +86,7 @@ module Legion
|
|
|
75
86
|
last_signal_at: @last_signal_at,
|
|
76
87
|
last_high_salience_at: @last_high_salience_at,
|
|
77
88
|
last_dream_completed_at: @last_dream_completed_at,
|
|
89
|
+
last_dream_deferred_at: @last_dream_deferred_at,
|
|
78
90
|
phases_completed: @phase_results.keys
|
|
79
91
|
}
|
|
80
92
|
end
|
|
@@ -12,16 +12,19 @@ module Legion
|
|
|
12
12
|
false
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def execute_tick(signals: [], phase_handlers: {}, **context)
|
|
15
|
+
def execute_tick(signals: [], phase_handlers: {}, enabled_optional_phases: [], **context)
|
|
16
|
+
Helpers::Constants.validate_optional_phases(enabled_optional_phases)
|
|
16
17
|
if defined?(Legion::Telemetry::OpenInference)
|
|
17
18
|
state = tick_state
|
|
18
19
|
Legion::Telemetry::OpenInference.agent_span(
|
|
19
20
|
name: "tick-#{state.tick_count + 1}", mode: state.mode,
|
|
20
|
-
phase_count: Helpers::Constants.phases_for_mode(state.mode).size,
|
|
21
|
+
phase_count: Helpers::Constants.phases_for_mode(state.mode, enabled_optional_phases: enabled_optional_phases).size,
|
|
21
22
|
budget_ms: (Helpers::Constants.tick_budget(state.mode) * 1000).round
|
|
22
|
-
)
|
|
23
|
+
) do |_span|
|
|
24
|
+
execute_tick_impl(signals: signals, phase_handlers: phase_handlers, enabled_optional_phases: enabled_optional_phases, **context)
|
|
25
|
+
end
|
|
23
26
|
else
|
|
24
|
-
execute_tick_impl(signals: signals, phase_handlers: phase_handlers, **context)
|
|
27
|
+
execute_tick_impl(signals: signals, phase_handlers: phase_handlers, enabled_optional_phases: enabled_optional_phases, **context)
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
|
|
@@ -42,7 +45,9 @@ module Legion
|
|
|
42
45
|
|
|
43
46
|
new_mode = case state.mode
|
|
44
47
|
when :dormant
|
|
45
|
-
if
|
|
48
|
+
if has_human || max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD
|
|
49
|
+
:full_active
|
|
50
|
+
elsif signals.any?
|
|
46
51
|
:sentinel
|
|
47
52
|
elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD && dream_backoff_elapsed?(state)
|
|
48
53
|
:dormant_active
|
|
@@ -51,7 +56,7 @@ module Legion
|
|
|
51
56
|
end
|
|
52
57
|
when :dormant_active
|
|
53
58
|
if max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD || has_human
|
|
54
|
-
:
|
|
59
|
+
:full_active
|
|
55
60
|
elsif dream_complete
|
|
56
61
|
state.record_dream_completed
|
|
57
62
|
:dormant
|
|
@@ -107,7 +112,7 @@ module Legion
|
|
|
107
112
|
|
|
108
113
|
private
|
|
109
114
|
|
|
110
|
-
def execute_tick_impl(signals:, phase_handlers:, **context)
|
|
115
|
+
def execute_tick_impl(signals:, phase_handlers:, enabled_optional_phases:, **context)
|
|
111
116
|
state = tick_state
|
|
112
117
|
state.increment_tick
|
|
113
118
|
|
|
@@ -126,9 +131,9 @@ module Legion
|
|
|
126
131
|
evaluate_mode_transition(signals: signals)
|
|
127
132
|
end
|
|
128
133
|
|
|
129
|
-
phases = Helpers::Constants.phases_for_mode(state.mode)
|
|
134
|
+
phases = Helpers::Constants.phases_for_mode(state.mode, enabled_optional_phases: enabled_optional_phases)
|
|
130
135
|
budget = Helpers::Constants.tick_budget(state.mode)
|
|
131
|
-
start_time =
|
|
136
|
+
start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
132
137
|
ctx = {
|
|
133
138
|
budget: budget,
|
|
134
139
|
start_time: start_time,
|
|
@@ -139,18 +144,26 @@ module Legion
|
|
|
139
144
|
results = run_phases(phases, state, ctx)
|
|
140
145
|
finish_dream_cycle(state, phases, results)
|
|
141
146
|
|
|
142
|
-
total_elapsed =
|
|
147
|
+
total_elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
|
|
143
148
|
skipped = phases - results.keys
|
|
144
149
|
log_tick_complete(state, results, phases, total_elapsed, skipped)
|
|
145
150
|
|
|
146
151
|
{
|
|
147
|
-
tick_number:
|
|
148
|
-
mode:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
tick_number: state.tick_count,
|
|
153
|
+
mode: state.mode,
|
|
154
|
+
phases_scheduled: phases,
|
|
155
|
+
phases_executed: results.keys,
|
|
156
|
+
phases_skipped: skipped,
|
|
157
|
+
results: results,
|
|
158
|
+
elapsed: total_elapsed
|
|
153
159
|
}
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
log.error "[tick] execution failed: #{e.class}: #{e.message}"
|
|
162
|
+
if state.mode == :dormant_active
|
|
163
|
+
state.record_dream_deferred
|
|
164
|
+
state.transition_to(:dormant)
|
|
165
|
+
end
|
|
166
|
+
raise
|
|
154
167
|
end
|
|
155
168
|
|
|
156
169
|
def run_phases(phases, state, ctx)
|
|
@@ -159,7 +172,7 @@ module Legion
|
|
|
159
172
|
start_time = ctx[:start_time]
|
|
160
173
|
log.debug "[tick] ##{state.tick_count} running #{phases.size} phases with #{budget}s budget"
|
|
161
174
|
phases.each do |phase|
|
|
162
|
-
elapsed =
|
|
175
|
+
elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
|
|
163
176
|
if elapsed >= budget
|
|
164
177
|
log.debug "[tick] ##{state.tick_count} budget exhausted at #{elapsed.round(3)}s, skipping remaining phases"
|
|
165
178
|
break
|
|
@@ -173,14 +186,35 @@ module Legion
|
|
|
173
186
|
end
|
|
174
187
|
|
|
175
188
|
def run_single_phase(phase, handler, state, signals, results, **context)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
unavailable = optional_phase_unavailable(phase, handler, results)
|
|
190
|
+
|
|
191
|
+
phase_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
192
|
+
result = unavailable || if handler
|
|
193
|
+
handler.call(state: state, signals: signals, prior_results: results, **context)
|
|
194
|
+
else
|
|
195
|
+
{ status: :no_handler }
|
|
196
|
+
end
|
|
197
|
+
phase_elapsed = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - phase_start) * 1000).round(1)
|
|
179
198
|
status = result.is_a?(Hash) ? (result[:status] || :ok) : :ok
|
|
180
199
|
log.debug "[tick] ##{state.tick_count} phase=#{phase} status=#{status} (#{phase_elapsed}ms)"
|
|
181
200
|
result
|
|
182
201
|
end
|
|
183
202
|
|
|
203
|
+
def optional_phase_unavailable(phase, handler, results)
|
|
204
|
+
contract = Helpers::Constants::OPTIONAL_PHASES[phase]
|
|
205
|
+
return unless contract
|
|
206
|
+
return { status: :unavailable, reason: :missing_handler } unless handler
|
|
207
|
+
|
|
208
|
+
missing = contract[:requires].select do |dependency|
|
|
209
|
+
result = results[dependency]
|
|
210
|
+
!result.is_a?(Hash) || result.empty? || result[:success] == false ||
|
|
211
|
+
Helpers::Constants::INCOMPLETE_PHASE_STATUSES.include?(result[:status])
|
|
212
|
+
end
|
|
213
|
+
return if missing.empty?
|
|
214
|
+
|
|
215
|
+
{ status: :deferred, reason: :missing_prerequisites, phases: missing }
|
|
216
|
+
end
|
|
217
|
+
|
|
184
218
|
def log_tick_complete(state, results, phases, total_elapsed, skipped)
|
|
185
219
|
skipped_suffix = skipped.empty? ? '' : " skipped=#{skipped}"
|
|
186
220
|
log.debug "[tick] ##{state.tick_count} complete | mode=#{state.mode} " \
|
|
@@ -194,19 +228,26 @@ module Legion
|
|
|
194
228
|
|
|
195
229
|
skipped = phases - results.keys
|
|
196
230
|
failed = failed_dream_phases(results)
|
|
197
|
-
state.record_dream_completed
|
|
198
231
|
state.transition_to(:dormant)
|
|
199
232
|
if skipped.empty? && failed.empty?
|
|
233
|
+
state.record_dream_completed
|
|
200
234
|
log.info '[tick] dream cycle complete; backing off to dormant'
|
|
201
235
|
else
|
|
236
|
+
state.record_dream_deferred
|
|
202
237
|
log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
|
|
203
238
|
end
|
|
204
239
|
end
|
|
205
240
|
|
|
206
241
|
def failed_dream_phases(results)
|
|
207
242
|
results.each_with_object([]) do |(phase, result), failed|
|
|
208
|
-
|
|
209
|
-
|
|
243
|
+
unless result.is_a?(Hash) && !result.empty?
|
|
244
|
+
failed << phase
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
status = result[:status]
|
|
249
|
+
incomplete = Helpers::Constants::INCOMPLETE_PHASE_STATUSES.include?(status)
|
|
250
|
+
failed << phase if incomplete || result[:success] == false
|
|
210
251
|
end
|
|
211
252
|
end
|
|
212
253
|
|
|
@@ -221,7 +262,8 @@ module Legion
|
|
|
221
262
|
end
|
|
222
263
|
|
|
223
264
|
def dream_backoff_elapsed?(state)
|
|
224
|
-
state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
|
|
265
|
+
state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL &&
|
|
266
|
+
state.seconds_since_dream_deferred >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
|
|
225
267
|
end
|
|
226
268
|
|
|
227
269
|
def tick_state
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'open3'
|
|
3
4
|
require 'legion/extensions/tick/client'
|
|
4
5
|
|
|
5
6
|
RSpec.describe Legion::Extensions::Tick::Client do
|
|
@@ -16,9 +17,18 @@ RSpec.describe Legion::Extensions::Tick::Client do
|
|
|
16
17
|
expect(client.tick_status[:mode]).to eq(:sentinel)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
it '
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
it 'executes standalone without the framework or its test helper supplying logging' do
|
|
21
|
+
script = <<~RUBY
|
|
22
|
+
require 'legion/extensions/tick/client'
|
|
23
|
+
result = Legion::Extensions::Tick::Client.new.execute_tick(
|
|
24
|
+
phase_handlers: { memory_consolidation: ->(**) { { consolidated: true } } }
|
|
25
|
+
)
|
|
26
|
+
abort 'consolidation did not execute' unless result.dig(:results, :memory_consolidation, :consolidated)
|
|
27
|
+
puts 'standalone consolidation completed'
|
|
28
|
+
RUBY
|
|
29
|
+
output, status = Open3.capture2e(RbConfig.ruby, '-Ilib', '-e', script)
|
|
30
|
+
|
|
31
|
+
expect(status.success?).to be(true), output
|
|
32
|
+
expect(output).to include('standalone consolidation completed')
|
|
23
33
|
end
|
|
24
34
|
end
|
|
@@ -12,11 +12,6 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
|
|
|
12
12
|
expect(phases.size).to eq(5)
|
|
13
13
|
expect(phases).to include(:sensory_processing, :memory_retrieval)
|
|
14
14
|
end
|
|
15
|
-
|
|
16
|
-
it 'returns all 16 phases for full_active' do
|
|
17
|
-
phases = described_class.phases_for_mode(:full_active)
|
|
18
|
-
expect(phases.size).to eq(16)
|
|
19
|
-
end
|
|
20
15
|
end
|
|
21
16
|
|
|
22
17
|
describe '.tick_budget' do
|
|
@@ -33,10 +28,6 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
|
|
|
33
28
|
end
|
|
34
29
|
end
|
|
35
30
|
|
|
36
|
-
it 'defines exactly 16 phases' do
|
|
37
|
-
expect(described_class::PHASES.size).to eq(16)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
31
|
it 'defines phase budgets for all active phases' do
|
|
41
32
|
described_class::PHASES.each do |phase|
|
|
42
33
|
expect(described_class::PHASE_BUDGETS).to have_key(phase)
|
|
@@ -47,17 +38,9 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
|
|
|
47
38
|
it 'includes dormant_active' do
|
|
48
39
|
expect(described_class::MODES).to include(:dormant_active)
|
|
49
40
|
end
|
|
50
|
-
|
|
51
|
-
it 'has exactly 4 modes' do
|
|
52
|
-
expect(described_class::MODES.size).to eq(4)
|
|
53
|
-
end
|
|
54
41
|
end
|
|
55
42
|
|
|
56
43
|
describe 'DREAM_PHASES' do
|
|
57
|
-
it 'defines 10 dream phases' do
|
|
58
|
-
expect(described_class::DREAM_PHASES.size).to eq(10)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
44
|
it 'includes partner_reflection' do
|
|
62
45
|
expect(described_class::DREAM_PHASES).to include(:partner_reflection)
|
|
63
46
|
end
|
|
@@ -91,22 +74,4 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
|
|
|
91
74
|
expect(described_class.tick_budget(:dormant_active)).to eq(described_class::DREAM_TICK_BUDGET)
|
|
92
75
|
end
|
|
93
76
|
end
|
|
94
|
-
|
|
95
|
-
describe 'dream thresholds' do
|
|
96
|
-
it 'defines DREAM_IDLE_THRESHOLD as 1800' do
|
|
97
|
-
expect(described_class::DREAM_IDLE_THRESHOLD).to eq(1800)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
it 'defines SENTINEL_TO_DREAM_THRESHOLD as 600' do
|
|
101
|
-
expect(described_class::SENTINEL_TO_DREAM_THRESHOLD).to eq(600)
|
|
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
|
|
111
|
-
end
|
|
112
77
|
end
|
|
@@ -12,6 +12,10 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
describe '#execute_tick' do
|
|
15
|
+
let(:dream_handlers) do
|
|
16
|
+
Legion::Extensions::Tick::Helpers::Constants::DREAM_PHASES.to_h { |phase| [phase, ->(**) { { status: :ok } }] }
|
|
17
|
+
end
|
|
18
|
+
|
|
15
19
|
before do
|
|
16
20
|
allow(client.send(:tick_state)).to receive(:seconds_since_signal).and_return(60.0)
|
|
17
21
|
end
|
|
@@ -28,35 +32,6 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
28
32
|
expect(r2[:tick_number]).to eq(r1[:tick_number] + 1)
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
it 'uses phase handlers when provided' do
|
|
32
|
-
handler_called = false
|
|
33
|
-
handlers = {
|
|
34
|
-
memory_consolidation: lambda { |**|
|
|
35
|
-
handler_called = true
|
|
36
|
-
{ status: :ok }
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
client.execute_tick(phase_handlers: handlers)
|
|
40
|
-
expect(handler_called).to be true
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
it 'passes extra execution context through to phase handlers' do
|
|
44
|
-
seen = nil
|
|
45
|
-
handlers = {
|
|
46
|
-
memory_consolidation: lambda { |partner_observations: nil, **|
|
|
47
|
-
seen = partner_observations
|
|
48
|
-
{ status: :ok }
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
client.execute_tick(
|
|
53
|
-
phase_handlers: handlers,
|
|
54
|
-
partner_observations: [{ identity: 'partner-1' }]
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
expect(seen).to eq([{ identity: 'partner-1' }])
|
|
58
|
-
end
|
|
59
|
-
|
|
60
35
|
it 'reports no_handler for unhandled phases' do
|
|
61
36
|
result = client.execute_tick
|
|
62
37
|
expect(result[:results][:memory_consolidation][:status]).to eq(:no_handler)
|
|
@@ -68,10 +43,46 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
68
43
|
expect(result[:mode]).to eq(:sentinel)
|
|
69
44
|
end
|
|
70
45
|
|
|
46
|
+
it 'runs action selection for the first human signal without a second signal' do
|
|
47
|
+
result = client.execute_tick(
|
|
48
|
+
signals: [{ source_type: :human_direct, salience: 0.1 }],
|
|
49
|
+
phase_handlers: { action_selection: ->(**) { { status: :selected } } }
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(result[:mode]).to eq(:full_active)
|
|
53
|
+
expect(result[:results][:action_selection]).to eq(status: :selected)
|
|
54
|
+
expect(client.execute_tick[:mode]).to eq(:full_active)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'promotes a dormant agent immediately for a high-salience non-human signal' do
|
|
58
|
+
result = client.execute_tick(signals: [{ source_type: :sensor, salience: 0.9 }])
|
|
59
|
+
|
|
60
|
+
expect(result[:mode]).to eq(:full_active)
|
|
61
|
+
expect(result[:phases_executed]).to include(:action_selection)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'interrupts a dream with full processing for a low-salience human signal' do
|
|
65
|
+
dreaming_client = Legion::Extensions::Tick::Client.new(mode: :dormant_active)
|
|
66
|
+
result = dreaming_client.execute_tick(signals: [{ source_type: :human_direct, salience: 0.1 }])
|
|
67
|
+
|
|
68
|
+
expect(result[:mode]).to eq(:full_active)
|
|
69
|
+
expect(result[:phases_executed]).to include(:action_selection)
|
|
70
|
+
expect(result[:phases_executed]).not_to include(:dream_narration)
|
|
71
|
+
expect(dreaming_client.tick_status[:last_dream_completed_at]).to be_nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'honors a forced mode for one tick before allowing human promotion' do
|
|
75
|
+
client.set_mode(mode: :sentinel)
|
|
76
|
+
signals = [{ source_type: :human_direct, salience: 0.1 }]
|
|
77
|
+
|
|
78
|
+
expect(client.execute_tick(signals: signals)[:phases_executed]).not_to include(:action_selection)
|
|
79
|
+
expect(client.execute_tick(signals: signals)[:phases_executed]).to include(:action_selection)
|
|
80
|
+
end
|
|
81
|
+
|
|
71
82
|
it 'completes one dormant_active dream cycle and backs off to dormant' do
|
|
72
83
|
client.set_mode(mode: :dormant_active)
|
|
73
84
|
|
|
74
|
-
result = client.execute_tick
|
|
85
|
+
result = client.execute_tick(phase_handlers: dream_handlers)
|
|
75
86
|
|
|
76
87
|
expect(result[:phases_executed]).to eq(Legion::Extensions::Tick::Helpers::Constants::DREAM_PHASES)
|
|
77
88
|
expect(result[:mode]).to eq(:dormant)
|
|
@@ -83,7 +94,7 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
83
94
|
allow(state).to receive(:seconds_since_signal).and_return(1801.0)
|
|
84
95
|
|
|
85
96
|
client.set_mode(mode: :dormant_active)
|
|
86
|
-
client.execute_tick
|
|
97
|
+
client.execute_tick(phase_handlers: dream_handlers)
|
|
87
98
|
result = client.execute_tick
|
|
88
99
|
|
|
89
100
|
expect(result[:mode]).to eq(:dormant)
|
|
@@ -94,14 +105,13 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
94
105
|
client.set_mode(mode: :dormant_active)
|
|
95
106
|
|
|
96
107
|
result = client.execute_tick(
|
|
97
|
-
phase_handlers: {
|
|
98
|
-
dream_narration: ->(**) { { status: :error } }
|
|
99
|
-
}
|
|
108
|
+
phase_handlers: dream_handlers.merge(dream_narration: ->(**) { { status: :error } })
|
|
100
109
|
)
|
|
101
110
|
|
|
102
111
|
expect(result[:mode]).to eq(:dormant)
|
|
103
112
|
expect(result[:results][:dream_narration][:status]).to eq(:error)
|
|
104
|
-
expect(client.tick_status[:last_dream_completed_at]).
|
|
113
|
+
expect(client.tick_status[:last_dream_completed_at]).to be_nil
|
|
114
|
+
expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
|
|
105
115
|
end
|
|
106
116
|
|
|
107
117
|
it 'backs off to dormant when the dream tick budget is exhausted before all phases run' do
|
|
@@ -115,12 +125,156 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
115
125
|
expect(result[:mode]).to eq(:dormant)
|
|
116
126
|
expect(result[:phases_executed]).to be_empty
|
|
117
127
|
expect(result[:phases_skipped]).to eq(constants::DREAM_PHASES)
|
|
118
|
-
expect(client.tick_status[:last_dream_completed_at]).
|
|
128
|
+
expect(client.tick_status[:last_dream_completed_at]).to be_nil
|
|
129
|
+
expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'backs off an unhandled dream without claiming completion' do
|
|
133
|
+
state = client.send(:tick_state)
|
|
134
|
+
allow(state).to receive(:seconds_since_signal).and_return(1801.0)
|
|
135
|
+
client.set_mode(mode: :dormant_active)
|
|
136
|
+
|
|
137
|
+
client.execute_tick
|
|
138
|
+
expect(client.tick_status[:last_dream_completed_at]).to be_nil
|
|
139
|
+
expect(client.execute_tick[:phases_executed]).to eq([:memory_consolidation])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'does not claim completion when a handler reports unsuccessful work without a status' do
|
|
143
|
+
client.set_mode(mode: :dormant_active)
|
|
144
|
+
client.execute_tick(phase_handlers: dream_handlers.merge(dream_narration: ->(**) { { success: false } }))
|
|
145
|
+
|
|
146
|
+
expect(client.tick_status[:last_dream_completed_at]).to be_nil
|
|
147
|
+
expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'does not immediately replay earlier dream effects when a later handler raises' do
|
|
151
|
+
allow(client.send(:tick_state)).to receive(:seconds_since_signal).and_return(1801.0)
|
|
152
|
+
effects = []
|
|
153
|
+
handlers = dream_handlers.merge(
|
|
154
|
+
memory_audit: ->(**) { effects << :audit },
|
|
155
|
+
association_walk: ->(**) { raise 'association failure' }
|
|
156
|
+
)
|
|
157
|
+
client.set_mode(mode: :dormant_active)
|
|
158
|
+
|
|
159
|
+
expect { client.execute_tick(phase_handlers: handlers) }.to raise_error('association failure')
|
|
160
|
+
expect(client.tick_status[:last_dream_completed_at]).to be_nil
|
|
161
|
+
client.execute_tick(phase_handlers: handlers)
|
|
162
|
+
expect(effects).to eq([:audit])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'keeps budget accounting independent of backwards wall-clock adjustments' do
|
|
166
|
+
allow(Time).to receive(:now).and_return(Time.utc(2026, 10, 29))
|
|
167
|
+
result = client.execute_tick(phase_handlers: {
|
|
168
|
+
memory_consolidation: lambda { |**|
|
|
169
|
+
allow(Time).to receive(:now).and_return(Time.utc(2026, 10, 28))
|
|
170
|
+
{ status: :ok }
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(result[:elapsed]).to be >= 0.0
|
|
175
|
+
expect(result[:elapsed]).to be < 1.0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'does not count an empty dream result as a completed cycle' do
|
|
179
|
+
client.set_mode(mode: :dormant_active)
|
|
180
|
+
client.execute_tick(phase_handlers: dream_handlers.merge(memory_audit: ->(**) {}))
|
|
181
|
+
|
|
182
|
+
expect(client.tick_status[:last_dream_completed_at]).to be_nil
|
|
183
|
+
expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe 'optional phase scheduling' do
|
|
188
|
+
let(:signals) { [{ source_type: :human_direct, salience: 0.1 }] }
|
|
189
|
+
|
|
190
|
+
it 'does not activate optional work merely because a handler is available' do
|
|
191
|
+
effects = []
|
|
192
|
+
client.execute_tick(signals: signals, phase_handlers: { metacognition: ->(**) { effects << :snapshot } })
|
|
193
|
+
|
|
194
|
+
expect(effects).to be_empty
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it 'makes workspace selection available to the subsequent action decision' do
|
|
198
|
+
handlers = {
|
|
199
|
+
sensory_processing: ->(**) { { candidates: [{ content: 'urgent request', salience: 0.9 }] } },
|
|
200
|
+
global_workspace: lambda { |prior_results:, **|
|
|
201
|
+
{ selected: prior_results[:sensory_processing][:candidates].max_by { |candidate| candidate[:salience] } }
|
|
202
|
+
},
|
|
203
|
+
action_selection: ->(prior_results:, **) { { decision: prior_results.dig(:global_workspace, :selected, :content) } }
|
|
204
|
+
}
|
|
205
|
+
result = client.execute_tick(signals: signals, phase_handlers: handlers, enabled_optional_phases: [:global_workspace])
|
|
206
|
+
|
|
207
|
+
expect(result.dig(:results, :action_selection, :decision)).to eq('urgent request')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'runs metacognition once after reflection even if the opt-in is repeated' do
|
|
211
|
+
observations = []
|
|
212
|
+
handlers = {
|
|
213
|
+
post_tick_reflection: ->(**) { { cognitive_health: 0.8 } },
|
|
214
|
+
metacognition: lambda { |prior_results:, **|
|
|
215
|
+
observations << prior_results.dig(:post_tick_reflection, :cognitive_health)
|
|
216
|
+
{ status: :completed }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
client.execute_tick(signals: signals, phase_handlers: handlers, enabled_optional_phases: %i[metacognition metacognition])
|
|
220
|
+
|
|
221
|
+
expect(observations).to eq([0.8])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'does not run full-active optional work during a forced sentinel tick' do
|
|
225
|
+
effects = []
|
|
226
|
+
client.set_mode(mode: :sentinel)
|
|
227
|
+
result = client.execute_tick(signals: signals, enabled_optional_phases: [:metacognition],
|
|
228
|
+
phase_handlers: { metacognition: ->(**) { effects << :snapshot } })
|
|
229
|
+
|
|
230
|
+
expect(effects).to be_empty
|
|
231
|
+
expect(result[:phases_scheduled]).not_to include(:metacognition)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'reports an unavailable optional capability when its handler is absent' do
|
|
235
|
+
result = client.execute_tick(signals: signals, enabled_optional_phases: [:metacognition])
|
|
236
|
+
|
|
237
|
+
expect(result.dig(:results, :metacognition)).to include(status: :unavailable, reason: :missing_handler)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'defers optional work rather than consuming a failed prerequisite' do
|
|
241
|
+
effects = []
|
|
242
|
+
result = client.execute_tick(
|
|
243
|
+
signals: signals, enabled_optional_phases: [:metacognition],
|
|
244
|
+
phase_handlers: {
|
|
245
|
+
post_tick_reflection: ->(**) { { success: false } },
|
|
246
|
+
metacognition: ->(**) { effects << :snapshot }
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
expect(effects).to be_empty
|
|
251
|
+
expect(result.dig(:results, :metacognition)).to include(status: :deferred, phases: [:post_tick_reflection])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it 'rejects unknown phase names before advancing tick state' do
|
|
255
|
+
expect { client.execute_tick(enabled_optional_phases: [:dream_cycle]) }.to raise_error(ArgumentError)
|
|
256
|
+
expect(client.tick_status[:tick_count]).to eq(0)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it 'rejects a scalar opt-in instead of silently ignoring it' do
|
|
260
|
+
expect { client.execute_tick(enabled_optional_phases: :metacognition) }.to raise_error(ArgumentError)
|
|
261
|
+
expect(client.tick_status[:tick_count]).to eq(0)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'reports budget-skipped optional work without invoking it' do
|
|
265
|
+
allow(Legion::Extensions::Tick::Helpers::Constants).to receive(:tick_budget).and_return(0.0)
|
|
266
|
+
effects = []
|
|
267
|
+
result = client.execute_tick(signals: signals, enabled_optional_phases: [:metacognition],
|
|
268
|
+
phase_handlers: { metacognition: ->(**) { effects << :snapshot } })
|
|
269
|
+
|
|
270
|
+
expect(effects).to be_empty
|
|
271
|
+
expect(result[:phases_skipped]).to include(:metacognition)
|
|
272
|
+
expect(result[:results]).not_to have_key(:metacognition)
|
|
119
273
|
end
|
|
120
274
|
end
|
|
121
275
|
|
|
122
276
|
describe '#evaluate_mode_transition' do
|
|
123
|
-
it 'promotes dormant to sentinel on
|
|
277
|
+
it 'promotes dormant to sentinel on a low-salience non-human signal' do
|
|
124
278
|
result = client.evaluate_mode_transition(signals: [{ salience: 0.1 }])
|
|
125
279
|
expect(result[:transitioned]).to be true
|
|
126
280
|
expect(result[:new_mode]).to eq(:sentinel)
|
|
@@ -176,11 +330,11 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
176
330
|
expect(result[:new_mode]).to eq(:sentinel)
|
|
177
331
|
end
|
|
178
332
|
|
|
179
|
-
it 'transitions dormant_active ->
|
|
333
|
+
it 'transitions dormant_active -> full_active on high-salience signal' do
|
|
180
334
|
client.set_mode(mode: :dormant_active)
|
|
181
335
|
result = client.evaluate_mode_transition(signals: [{ salience: 0.9 }])
|
|
182
336
|
expect(result[:transitioned]).to be true
|
|
183
|
-
expect(result[:new_mode]).to eq(:
|
|
337
|
+
expect(result[:new_mode]).to eq(:full_active)
|
|
184
338
|
end
|
|
185
339
|
|
|
186
340
|
it 'transitions dormant_active -> dormant when dream_complete: true with no signals' do
|
|
@@ -255,12 +409,6 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
|
|
|
255
409
|
expect(result[:mode]).to eq(:full_active)
|
|
256
410
|
end
|
|
257
411
|
|
|
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
|
-
|
|
264
412
|
it 'rejects invalid mode' do
|
|
265
413
|
result = client.set_mode(mode: :invalid)
|
|
266
414
|
expect(result[:error]).to eq(:invalid_mode)
|