lex-tick 0.1.16 → 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 +69 -29
- 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 -39
- 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
|
|
@@ -100,15 +105,14 @@ module Legion
|
|
|
100
105
|
|
|
101
106
|
previous = tick_state.mode
|
|
102
107
|
tick_state.transition_to(mode)
|
|
103
|
-
@mode_forced
|
|
104
|
-
|
|
105
|
-
log.info "[tick] mode forced: #{previous} -> #{mode} (protected until #{@force_expires_at})"
|
|
108
|
+
@mode_forced = true
|
|
109
|
+
log.info "[tick] mode forced: #{previous} -> #{mode}"
|
|
106
110
|
{ mode: mode }
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
private
|
|
110
114
|
|
|
111
|
-
def execute_tick_impl(signals:, phase_handlers:, **context)
|
|
115
|
+
def execute_tick_impl(signals:, phase_handlers:, enabled_optional_phases:, **context)
|
|
112
116
|
state = tick_state
|
|
113
117
|
state.increment_tick
|
|
114
118
|
|
|
@@ -122,15 +126,14 @@ module Legion
|
|
|
122
126
|
# @mode_forced is set by set_mode and cleared here so the force lasts exactly one cycle.
|
|
123
127
|
if @mode_forced
|
|
124
128
|
log.debug "[tick] ##{state.tick_count} skipping mode evaluation (mode forced to #{state.mode})"
|
|
125
|
-
@mode_forced
|
|
126
|
-
@force_expires_at = nil
|
|
129
|
+
@mode_forced = false
|
|
127
130
|
else
|
|
128
131
|
evaluate_mode_transition(signals: signals)
|
|
129
132
|
end
|
|
130
133
|
|
|
131
|
-
phases = Helpers::Constants.phases_for_mode(state.mode)
|
|
134
|
+
phases = Helpers::Constants.phases_for_mode(state.mode, enabled_optional_phases: enabled_optional_phases)
|
|
132
135
|
budget = Helpers::Constants.tick_budget(state.mode)
|
|
133
|
-
start_time =
|
|
136
|
+
start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
134
137
|
ctx = {
|
|
135
138
|
budget: budget,
|
|
136
139
|
start_time: start_time,
|
|
@@ -141,18 +144,26 @@ module Legion
|
|
|
141
144
|
results = run_phases(phases, state, ctx)
|
|
142
145
|
finish_dream_cycle(state, phases, results)
|
|
143
146
|
|
|
144
|
-
total_elapsed =
|
|
147
|
+
total_elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
|
|
145
148
|
skipped = phases - results.keys
|
|
146
149
|
log_tick_complete(state, results, phases, total_elapsed, skipped)
|
|
147
150
|
|
|
148
151
|
{
|
|
149
|
-
tick_number:
|
|
150
|
-
mode:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
155
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
|
|
156
167
|
end
|
|
157
168
|
|
|
158
169
|
def run_phases(phases, state, ctx)
|
|
@@ -161,7 +172,7 @@ module Legion
|
|
|
161
172
|
start_time = ctx[:start_time]
|
|
162
173
|
log.debug "[tick] ##{state.tick_count} running #{phases.size} phases with #{budget}s budget"
|
|
163
174
|
phases.each do |phase|
|
|
164
|
-
elapsed =
|
|
175
|
+
elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
|
|
165
176
|
if elapsed >= budget
|
|
166
177
|
log.debug "[tick] ##{state.tick_count} budget exhausted at #{elapsed.round(3)}s, skipping remaining phases"
|
|
167
178
|
break
|
|
@@ -175,14 +186,35 @@ module Legion
|
|
|
175
186
|
end
|
|
176
187
|
|
|
177
188
|
def run_single_phase(phase, handler, state, signals, results, **context)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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)
|
|
181
198
|
status = result.is_a?(Hash) ? (result[:status] || :ok) : :ok
|
|
182
199
|
log.debug "[tick] ##{state.tick_count} phase=#{phase} status=#{status} (#{phase_elapsed}ms)"
|
|
183
200
|
result
|
|
184
201
|
end
|
|
185
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
|
+
|
|
186
218
|
def log_tick_complete(state, results, phases, total_elapsed, skipped)
|
|
187
219
|
skipped_suffix = skipped.empty? ? '' : " skipped=#{skipped}"
|
|
188
220
|
log.debug "[tick] ##{state.tick_count} complete | mode=#{state.mode} " \
|
|
@@ -196,19 +228,26 @@ module Legion
|
|
|
196
228
|
|
|
197
229
|
skipped = phases - results.keys
|
|
198
230
|
failed = failed_dream_phases(results)
|
|
199
|
-
state.record_dream_completed
|
|
200
231
|
state.transition_to(:dormant)
|
|
201
232
|
if skipped.empty? && failed.empty?
|
|
233
|
+
state.record_dream_completed
|
|
202
234
|
log.info '[tick] dream cycle complete; backing off to dormant'
|
|
203
235
|
else
|
|
236
|
+
state.record_dream_deferred
|
|
204
237
|
log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
|
|
205
238
|
end
|
|
206
239
|
end
|
|
207
240
|
|
|
208
241
|
def failed_dream_phases(results)
|
|
209
242
|
results.each_with_object([]) do |(phase, result), failed|
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
212
251
|
end
|
|
213
252
|
end
|
|
214
253
|
|
|
@@ -223,7 +262,8 @@ module Legion
|
|
|
223
262
|
end
|
|
224
263
|
|
|
225
264
|
def dream_backoff_elapsed?(state)
|
|
226
|
-
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
|
|
227
267
|
end
|
|
228
268
|
|
|
229
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
|