little_ghost 0.2.0 → 0.3.0
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/README.md +19 -5
- data/docs/guides/core_concepts.md +167 -38
- data/docs/guides/getting_started.md +8 -4
- data/lib/little_ghost/agent/delegation.rb +34 -7
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +115 -156
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/assembly.rb +311 -0
- data/lib/little_ghost/assembly_builder.rb +459 -0
- data/lib/little_ghost/assembly_execution.rb +452 -0
- data/lib/little_ghost/errors.rb +8 -0
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +911 -0
- data/lib/little_ghost/run.rb +120 -32
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +7 -2
- data/lib/little_ghost/runtime.rb +64 -6
- data/lib/little_ghost/sandbox.rb +1 -1
- data/lib/little_ghost/support/executor.rb +14 -2
- data/lib/little_ghost/support/loader.rb +2 -2
- data/lib/little_ghost/support.rb +15 -3
- data/lib/little_ghost/swarm.rb +431 -0
- data/lib/little_ghost/tool.rb +32 -6
- data/lib/little_ghost/tracing/open_telemetry.rb +13 -2
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +198 -73
- data/lib/little_ghost.rb +11 -4
- metadata +11 -4
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
class Assembly
|
|
7
|
+
MAX_STEP_OUTPUT_BYTES = 64 * 1024 # :nodoc:
|
|
8
|
+
MAX_STEP_EVENTS = 10_000 # :nodoc:
|
|
9
|
+
MAX_STEP_EVENT_BYTES = 10 * 1024 * 1024 # :nodoc:
|
|
10
|
+
|
|
11
|
+
# One attempt to execute an assembly step.
|
|
12
|
+
Attempt = Data.define(:number, :status, :started_at, :finished_at, :usage, :error) do
|
|
13
|
+
def initialize(number:, status:, started_at:, finished_at:, usage: Usage.new, error: nil)
|
|
14
|
+
super(number:, status: status.to_sym, started_at:, finished_at:, usage:, error: error&.to_s&.freeze)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One logical child execution in a composite assembly.
|
|
19
|
+
Step = Data.define(
|
|
20
|
+
:id, :parent_id, :predecessor_ids, :branch_id, :participant,
|
|
21
|
+
:assembly_id, :assembly_kind, :status, :attempts, :usage, :output, :output_truncated
|
|
22
|
+
) do
|
|
23
|
+
def initialize(id:, participant:, assembly_id:, assembly_kind:, status:, attempts:, usage:,
|
|
24
|
+
parent_id: nil, predecessor_ids: [], branch_id: nil, output: nil, output_truncated: false)
|
|
25
|
+
super(
|
|
26
|
+
id: id.to_s.freeze,
|
|
27
|
+
parent_id: parent_id&.to_s&.freeze,
|
|
28
|
+
predecessor_ids: Array(predecessor_ids).map { |value| value.to_s.freeze }.freeze,
|
|
29
|
+
branch_id: branch_id&.to_s&.freeze,
|
|
30
|
+
participant: participant.to_s.freeze,
|
|
31
|
+
assembly_id: assembly_id.to_s.freeze,
|
|
32
|
+
assembly_kind: assembly_kind.to_sym,
|
|
33
|
+
status: status.to_sym,
|
|
34
|
+
attempts: Array(attempts).freeze,
|
|
35
|
+
usage:,
|
|
36
|
+
output:,
|
|
37
|
+
output_truncated: output_truncated == true
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Immutable queries over the steps returned by one assembly invocation.
|
|
43
|
+
class Trajectory
|
|
44
|
+
include Enumerable
|
|
45
|
+
|
|
46
|
+
# Immutable steps in execution order.
|
|
47
|
+
attr_reader :steps
|
|
48
|
+
|
|
49
|
+
# Builds query indexes for +steps+.
|
|
50
|
+
def initialize(steps)
|
|
51
|
+
@steps = Array(steps).freeze
|
|
52
|
+
@by_id = @steps.to_h { |step| [step.id, step] }.freeze
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Iterates through steps in execution order.
|
|
57
|
+
def each(&block) = steps.each(&block)
|
|
58
|
+
# Finds one step by its stable ID.
|
|
59
|
+
def step(id) = @by_id[id.to_s]
|
|
60
|
+
# Returns steps whose parent is +id+.
|
|
61
|
+
def children(id) = steps.select { |item| item.parent_id == id.to_s }.freeze
|
|
62
|
+
# Returns attempts belonging to one step.
|
|
63
|
+
def attempts_for(id) = step(id)&.attempts || [].freeze
|
|
64
|
+
|
|
65
|
+
# Returns declared predecessor-to-step ID pairs.
|
|
66
|
+
def transitions
|
|
67
|
+
steps.flat_map do |item|
|
|
68
|
+
item.predecessor_ids.map { |predecessor| [predecessor, item.id].freeze }
|
|
69
|
+
end.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Indicates whether attempts from two steps overlapped in time.
|
|
73
|
+
def concurrent?(first_id, second_id)
|
|
74
|
+
first = step(first_id)
|
|
75
|
+
second = step(second_id)
|
|
76
|
+
return false unless first && second
|
|
77
|
+
|
|
78
|
+
first.attempts.any? do |left|
|
|
79
|
+
second.attempts.any? do |right|
|
|
80
|
+
left.started_at < right.finished_at && right.started_at < left.finished_at
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
StepExecution = Data.define(:result, :step, :events, :transition) # :nodoc:
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def execute_assembly_step(
|
|
91
|
+
reference:,
|
|
92
|
+
participant:,
|
|
93
|
+
input:,
|
|
94
|
+
history:,
|
|
95
|
+
context:,
|
|
96
|
+
cancellation_token:,
|
|
97
|
+
deadline:,
|
|
98
|
+
settings:,
|
|
99
|
+
template_locals:,
|
|
100
|
+
template_paths:,
|
|
101
|
+
parent_operation_id:,
|
|
102
|
+
policies: {},
|
|
103
|
+
parent_id: nil,
|
|
104
|
+
predecessor_ids: [],
|
|
105
|
+
branch_id: nil,
|
|
106
|
+
checkpoint: nil,
|
|
107
|
+
build_options: {},
|
|
108
|
+
step_id: SecureRandom.uuid
|
|
109
|
+
)
|
|
110
|
+
policy = normalize_step_policy(policies)
|
|
111
|
+
attempts = []
|
|
112
|
+
usage = Usage.new
|
|
113
|
+
|
|
114
|
+
(policy.fetch(:retries) + 1).times do |index|
|
|
115
|
+
attempt_number = index + 1
|
|
116
|
+
started_at = Time.now
|
|
117
|
+
child = if build_options.any? && runtime.respond_to?(:build_agent)
|
|
118
|
+
runtime.build_agent(reference, run:, **build_options)
|
|
119
|
+
else
|
|
120
|
+
runtime.build_assembly(reference, run:, **build_options)
|
|
121
|
+
end
|
|
122
|
+
child_deadline = step_deadline(deadline, policy[:timeout])
|
|
123
|
+
attempt_events = []
|
|
124
|
+
attempt_event_bytes = 0
|
|
125
|
+
result = nil
|
|
126
|
+
attempt_usage = Usage.new
|
|
127
|
+
begin
|
|
128
|
+
with_active_assembly(child) do
|
|
129
|
+
child.stream(
|
|
130
|
+
input,
|
|
131
|
+
history:,
|
|
132
|
+
context: isolated_assembly_state(context),
|
|
133
|
+
cancellation_token:,
|
|
134
|
+
deadline: child_deadline,
|
|
135
|
+
settings:,
|
|
136
|
+
template_locals: template_locals.merge(runtime.template_locals(run:, agent: child)),
|
|
137
|
+
template_paths:,
|
|
138
|
+
parent_operation_id:,
|
|
139
|
+
checkpoint:
|
|
140
|
+
).each do |event|
|
|
141
|
+
result = event.data[:result] if event.type == :invocation_stop
|
|
142
|
+
attempt_usage = event.data[:usage] || attempt_usage if event.type == :invocation_error
|
|
143
|
+
attempt_event_bytes = buffer_assembly_event!(attempt_events, event, bytes: attempt_event_bytes)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
raise ProtocolError, "assembly step #{participant.inspect} did not return a result" unless result
|
|
147
|
+
|
|
148
|
+
attempt_usage = result.usage
|
|
149
|
+
usage += attempt_usage
|
|
150
|
+
attempts << Attempt.new(
|
|
151
|
+
number: attempt_number,
|
|
152
|
+
status: :completed,
|
|
153
|
+
started_at:,
|
|
154
|
+
finished_at: Time.now,
|
|
155
|
+
usage: attempt_usage
|
|
156
|
+
)
|
|
157
|
+
output, truncated = projected_step_output(result.output)
|
|
158
|
+
nested_steps = reparent_steps(result.steps, step_id)
|
|
159
|
+
step = Step.new(
|
|
160
|
+
id: step_id,
|
|
161
|
+
parent_id:,
|
|
162
|
+
predecessor_ids:,
|
|
163
|
+
branch_id:,
|
|
164
|
+
participant:,
|
|
165
|
+
assembly_id: child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant,
|
|
166
|
+
assembly_kind: child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly,
|
|
167
|
+
status: :completed,
|
|
168
|
+
attempts:,
|
|
169
|
+
usage:,
|
|
170
|
+
output:,
|
|
171
|
+
output_truncated: truncated
|
|
172
|
+
)
|
|
173
|
+
combined = copy_run_result(result, steps: [step, *nested_steps])
|
|
174
|
+
events = attempt_events.map do |event|
|
|
175
|
+
(event.type == :invocation_stop) ? StreamEvent.build(event.type, **event.data.merge(result: combined)) : event
|
|
176
|
+
end
|
|
177
|
+
transition = child.assembly_transition if child.respond_to?(:assembly_transition)
|
|
178
|
+
return StepExecution.new(result: combined, step:, events: events.freeze, transition:)
|
|
179
|
+
rescue => error
|
|
180
|
+
if own_step_timeout?(error, deadline, child_deadline, policy[:timeout])
|
|
181
|
+
error = AssemblyStepTimeoutError.new("assembly step #{participant.inspect} exceeded its timeout")
|
|
182
|
+
end
|
|
183
|
+
attempt_usage = error_usage(attempt_events, attempt_usage)
|
|
184
|
+
usage += attempt_usage
|
|
185
|
+
attempts << Attempt.new(
|
|
186
|
+
number: attempt_number,
|
|
187
|
+
status: :failed,
|
|
188
|
+
started_at:,
|
|
189
|
+
finished_at: Time.now,
|
|
190
|
+
usage: attempt_usage,
|
|
191
|
+
error: error.class.name
|
|
192
|
+
)
|
|
193
|
+
retrying = retry_step?(error, policy, index)
|
|
194
|
+
annotate_step_error(error, step_id:, attempts:, usage:, child:, participant:)
|
|
195
|
+
if block_given?
|
|
196
|
+
yield StreamEvent.build(
|
|
197
|
+
:assembly_step_error,
|
|
198
|
+
step_id:,
|
|
199
|
+
participant: participant.to_s,
|
|
200
|
+
attempt: attempt_number,
|
|
201
|
+
error_type: error.class.name,
|
|
202
|
+
usage:,
|
|
203
|
+
terminal: !retrying
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
raise error unless retrying
|
|
207
|
+
|
|
208
|
+
if block_given?
|
|
209
|
+
yield StreamEvent.build(
|
|
210
|
+
:assembly_step_retry,
|
|
211
|
+
step_id:,
|
|
212
|
+
participant: participant.to_s,
|
|
213
|
+
attempt: attempt_number,
|
|
214
|
+
error_type: error.class.name,
|
|
215
|
+
delay: policy.fetch(:retry_delay)
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
wait_for_retry(policy.fetch(:retry_delay), cancellation_token, deadline)
|
|
219
|
+
ensure
|
|
220
|
+
begin
|
|
221
|
+
child.close
|
|
222
|
+
rescue => cleanup_error
|
|
223
|
+
cleanup_usage = usage
|
|
224
|
+
annotate_step_error(
|
|
225
|
+
cleanup_error,
|
|
226
|
+
step_id:,
|
|
227
|
+
attempts:,
|
|
228
|
+
usage: cleanup_usage,
|
|
229
|
+
child:,
|
|
230
|
+
participant:
|
|
231
|
+
)
|
|
232
|
+
if block_given?
|
|
233
|
+
yield StreamEvent.build(
|
|
234
|
+
:assembly_step_error,
|
|
235
|
+
step_id:,
|
|
236
|
+
participant: participant.to_s,
|
|
237
|
+
attempt: attempt_number,
|
|
238
|
+
error_type: cleanup_error.class.name,
|
|
239
|
+
usage: cleanup_usage,
|
|
240
|
+
terminal: true
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
raise
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def normalize_step_policy(values)
|
|
250
|
+
self.class.validate_step_policy!(values)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
class << self
|
|
254
|
+
def validate_step_policy!(values) # :nodoc:
|
|
255
|
+
values = values.compact
|
|
256
|
+
retries = Integer(values.fetch(:retries, 0))
|
|
257
|
+
raise ArgumentError, "retries must be at least 0" if retries.negative?
|
|
258
|
+
|
|
259
|
+
retry_on = Array(values[:retry_on])
|
|
260
|
+
if retries.positive? && retry_on.empty?
|
|
261
|
+
raise ArgumentError, "retry_on is required when retries is greater than 0"
|
|
262
|
+
end
|
|
263
|
+
unless retry_on.all? { |error| error.is_a?(Class) && error <= Exception }
|
|
264
|
+
raise ArgumentError, "retry_on must contain exception classes"
|
|
265
|
+
end
|
|
266
|
+
timeout = Float(values[:timeout]) if values[:timeout]
|
|
267
|
+
retry_delay = Float(values.fetch(:retry_delay, 0))
|
|
268
|
+
raise ArgumentError, "timeout must be positive" if timeout && (!timeout.positive? || !timeout.finite?)
|
|
269
|
+
raise ArgumentError, "retry_delay must be non-negative" if retry_delay.negative? || !retry_delay.finite?
|
|
270
|
+
|
|
271
|
+
{retries:, retry_on: retry_on.freeze, timeout:, retry_delay:}.freeze
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def retry_step?(error, policy, index)
|
|
276
|
+
return false if index >= policy.fetch(:retries)
|
|
277
|
+
return false if error.is_a?(CancelledError) || error.is_a?(CleanupError)
|
|
278
|
+
|
|
279
|
+
policy.fetch(:retry_on).any? { |type| error.is_a?(type) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def step_deadline(parent_deadline, timeout)
|
|
283
|
+
local = Time.now + timeout if timeout
|
|
284
|
+
[parent_deadline, local].compact.min
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def own_step_timeout?(error, parent_deadline, child_deadline, timeout)
|
|
288
|
+
error.is_a?(DeadlineExceededError) && timeout && child_deadline && child_deadline != parent_deadline
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def wait_for_retry(delay, cancellation_token, deadline)
|
|
292
|
+
return if delay.zero?
|
|
293
|
+
|
|
294
|
+
stop_at = Time.now + delay
|
|
295
|
+
loop do
|
|
296
|
+
cancellation_token.raise_if_cancelled!
|
|
297
|
+
raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
|
|
298
|
+
|
|
299
|
+
remaining = stop_at - Time.now
|
|
300
|
+
break unless remaining.positive?
|
|
301
|
+
|
|
302
|
+
cancellation_token.wait([remaining, 0.05].min)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def error_usage(events, fallback)
|
|
307
|
+
events.reverse_each do |event|
|
|
308
|
+
return event.data[:usage] if event.type == :invocation_error && event.data[:usage]
|
|
309
|
+
end
|
|
310
|
+
fallback
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def annotate_step_error(error, step_id:, attempts:, usage:, child:, participant:)
|
|
314
|
+
error.instance_variable_set(:@little_ghost_step_id, step_id)
|
|
315
|
+
error.instance_variable_set(:@little_ghost_step_attempts, attempts.dup.freeze)
|
|
316
|
+
error.instance_variable_set(:@little_ghost_step_usage, usage)
|
|
317
|
+
error.instance_variable_set(
|
|
318
|
+
:@little_ghost_step_assembly_id,
|
|
319
|
+
child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant.to_s
|
|
320
|
+
)
|
|
321
|
+
error.instance_variable_set(
|
|
322
|
+
:@little_ghost_step_assembly_kind,
|
|
323
|
+
child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def projected_step_output(output)
|
|
328
|
+
text = output.is_a?(String) ? output : JSON.generate(output)
|
|
329
|
+
return [deep_frozen_assembly_value(output), false] if text.bytesize <= MAX_STEP_OUTPUT_BYTES
|
|
330
|
+
|
|
331
|
+
[nil, true]
|
|
332
|
+
rescue JSON::GeneratorError, TypeError
|
|
333
|
+
[nil, true]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def reparent_steps(steps, parent_id)
|
|
337
|
+
Array(steps).map do |step|
|
|
338
|
+
next step if step.parent_id
|
|
339
|
+
|
|
340
|
+
Step.new(**step.to_h.merge(parent_id:))
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def copy_run_result(result, usage: result.usage, steps: result.steps)
|
|
345
|
+
RunResult.new(
|
|
346
|
+
message: result.message,
|
|
347
|
+
stop_reason: result.stop_reason,
|
|
348
|
+
usage:,
|
|
349
|
+
messages: result.messages,
|
|
350
|
+
state: result.state,
|
|
351
|
+
structured_result: result.structured_result,
|
|
352
|
+
steps:
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def isolated_assembly_state(value)
|
|
357
|
+
case value
|
|
358
|
+
when Hash
|
|
359
|
+
value.to_h { |key, item| [isolated_assembly_state(key), isolated_assembly_state(item)] }
|
|
360
|
+
when Array
|
|
361
|
+
value.map { |item| isolated_assembly_state(item) }
|
|
362
|
+
when String
|
|
363
|
+
value.dup
|
|
364
|
+
when NilClass, TrueClass, FalseClass, Numeric, Symbol
|
|
365
|
+
value
|
|
366
|
+
else
|
|
367
|
+
raise ArgumentError, "assembly context must contain only JSON-like state"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def deep_frozen_assembly_value(value)
|
|
372
|
+
copied = isolated_assembly_state(value)
|
|
373
|
+
case copied
|
|
374
|
+
when Hash
|
|
375
|
+
copied.each do |key, item|
|
|
376
|
+
deep_freeze_assembly_value(key)
|
|
377
|
+
deep_freeze_assembly_value(item)
|
|
378
|
+
end
|
|
379
|
+
when Array
|
|
380
|
+
copied.each { |item| deep_freeze_assembly_value(item) }
|
|
381
|
+
end
|
|
382
|
+
copied.freeze
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def deep_freeze_assembly_value(value)
|
|
386
|
+
case value
|
|
387
|
+
when Hash
|
|
388
|
+
value.each do |key, item|
|
|
389
|
+
deep_freeze_assembly_value(key)
|
|
390
|
+
deep_freeze_assembly_value(item)
|
|
391
|
+
end
|
|
392
|
+
when Array
|
|
393
|
+
value.each { |item| deep_freeze_assembly_value(item) }
|
|
394
|
+
end
|
|
395
|
+
value.freeze
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def buffer_assembly_event!(buffer, event, bytes:)
|
|
399
|
+
raise AssemblyLimitError, "assembly step emitted too many events" if buffer.length >= MAX_STEP_EVENTS
|
|
400
|
+
|
|
401
|
+
bytes += buffered_assembly_size(event)
|
|
402
|
+
raise AssemblyLimitError, "assembly step emitted too much event data" if bytes > MAX_STEP_EVENT_BYTES
|
|
403
|
+
|
|
404
|
+
buffer << event
|
|
405
|
+
bytes
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def buffered_assembly_size(value, ancestors = {}, depth = 0)
|
|
409
|
+
return MAX_STEP_EVENT_BYTES + 1 if depth > 32
|
|
410
|
+
|
|
411
|
+
identity = value.object_id
|
|
412
|
+
return 0 if ancestors.key?(identity)
|
|
413
|
+
|
|
414
|
+
case value
|
|
415
|
+
when String
|
|
416
|
+
value.bytesize
|
|
417
|
+
when Hash
|
|
418
|
+
ancestors[identity] = true
|
|
419
|
+
value.sum do |key, item|
|
|
420
|
+
buffered_assembly_size(key, ancestors, depth + 1) + buffered_assembly_size(item, ancestors, depth + 1)
|
|
421
|
+
end
|
|
422
|
+
when Array
|
|
423
|
+
ancestors[identity] = true
|
|
424
|
+
value.sum { |item| buffered_assembly_size(item, ancestors, depth + 1) }
|
|
425
|
+
when Data
|
|
426
|
+
ancestors[identity] = true
|
|
427
|
+
value.members.sum { |member| buffered_assembly_size(value.public_send(member), ancestors, depth + 1) }
|
|
428
|
+
else
|
|
429
|
+
64
|
|
430
|
+
end
|
|
431
|
+
ensure
|
|
432
|
+
ancestors.delete(identity) if identity
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def enqueue_assembly_event(queue, value, cancellation_token)
|
|
436
|
+
loop do
|
|
437
|
+
cancellation_token.raise_if_cancelled!
|
|
438
|
+
queue.push(value, true)
|
|
439
|
+
return
|
|
440
|
+
rescue ThreadError
|
|
441
|
+
cancellation_token.wait(0.01)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def enqueue_assembly_terminal(queue, value)
|
|
446
|
+
queue.push(value, true)
|
|
447
|
+
rescue ThreadError
|
|
448
|
+
queue.pop(true)
|
|
449
|
+
retry
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
data/lib/little_ghost/errors.rb
CHANGED
|
@@ -13,6 +13,14 @@ module LittleGhost
|
|
|
13
13
|
class CredentialError < ConfigurationError; end
|
|
14
14
|
# Raised when an invocation payload or operation is invalid.
|
|
15
15
|
class InvocationError < Error; end
|
|
16
|
+
# Base class for failures coordinating one or more assemblies.
|
|
17
|
+
class AssemblyError < Error; end
|
|
18
|
+
# Raised when an assembly exceeds its configured execution bound.
|
|
19
|
+
class AssemblyLimitError < AssemblyError; end
|
|
20
|
+
# Raised when an assembly cannot choose one valid next participant.
|
|
21
|
+
class AssemblyRoutingError < AssemblyError; end
|
|
22
|
+
# Raised when one assembly step exceeds its local timeout.
|
|
23
|
+
class AssemblyStepTimeoutError < AssemblyError; end
|
|
16
24
|
# Raised when an invocation contains an unsupported input form.
|
|
17
25
|
class UnsupportedInputError < InvocationError; end
|
|
18
26
|
# Base class for provider response and protocol failures.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Runs one dormant Run in a supervised worker while the caller remains free to
|
|
5
|
+
# serve health checks, deliver interruptions, or coordinate process shutdown.
|
|
6
|
+
#
|
|
7
|
+
# execution = agent.start_execution(message: "Investigate transfer 481") do |event|
|
|
8
|
+
# event_buffer << event
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# execution.interrupt_response(message: "Include the latest ledger entry")
|
|
12
|
+
# execution.wait(deadline: Time.now + 30)
|
|
13
|
+
# execution.run.completed? # => true
|
|
14
|
+
#
|
|
15
|
+
# An execution owns its worker. The worker receives a snapshot of the caller's
|
|
16
|
+
# request-scoped ExecutionState. The Run continues to own its workspace,
|
|
17
|
+
# sandbox, session, entrypoint, and registered resources. +close+ requests
|
|
18
|
+
# cooperative cancellation and waits for both the worker and in-flight
|
|
19
|
+
# interruption calls.
|
|
20
|
+
class Execution
|
|
21
|
+
# The supervised Run and an exception raised outside the Run's ordinary
|
|
22
|
+
# terminal outcome, such as event delivery or cleanup failure.
|
|
23
|
+
attr_reader :run
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Starts +run+ immediately and returns its supervising Execution.
|
|
27
|
+
#
|
|
28
|
+
# The optional block receives each StreamEvent on the worker thread. It
|
|
29
|
+
# must be safe to call from that thread and should not retain sensitive
|
|
30
|
+
# event content longer than the application requires.
|
|
31
|
+
def start(run, &event_consumer)
|
|
32
|
+
new(run, event_consumer:).send(:start)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(run, event_consumer: nil) # :nodoc:
|
|
37
|
+
raise ArgumentError, "run must be a LittleGhost::Run" unless run.is_a?(Run)
|
|
38
|
+
unless event_consumer.nil? || event_consumer.respond_to?(:call)
|
|
39
|
+
raise ArgumentError, "event consumer must be callable"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@run = run
|
|
43
|
+
@event_consumer = event_consumer
|
|
44
|
+
@state = :pending
|
|
45
|
+
@error = nil
|
|
46
|
+
@mutex = Mutex.new
|
|
47
|
+
@condition = ConditionVariable.new
|
|
48
|
+
@active_interruptions = 0
|
|
49
|
+
@closing = false
|
|
50
|
+
@execution_state = ExecutionState.capture
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns +:pending+, +:running+, or +:finished+.
|
|
54
|
+
def state
|
|
55
|
+
@mutex.synchronize { @state }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns an event-delivery or cleanup exception raised by the worker.
|
|
59
|
+
def error
|
|
60
|
+
@mutex.synchronize { @error }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Indicates that the worker or an interruption call is still active.
|
|
64
|
+
def active?
|
|
65
|
+
@mutex.synchronize { @state != :finished || @active_interruptions.positive? }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Indicates that the worker and all interruption calls have finished.
|
|
69
|
+
def finished?
|
|
70
|
+
!active?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Prepares and delivers one interruption to the active run.
|
|
74
|
+
#
|
|
75
|
+
# +payload+ may be a message or a Hash containing +message+ and the options
|
|
76
|
+
# accepted by Run#interrupt_response. Runtime hooks receive the Hash before
|
|
77
|
+
# delivery, allowing them to materialize trusted application attachments.
|
|
78
|
+
# Calls may overlap, but +close+ prevents new calls and waits for calls that
|
|
79
|
+
# have already begun.
|
|
80
|
+
def interrupt_response(payload = nil, **options)
|
|
81
|
+
if payload.nil? && options.key?(:message)
|
|
82
|
+
payload = options.delete(:message)
|
|
83
|
+
end
|
|
84
|
+
interruption_started = false
|
|
85
|
+
begin_interruption!
|
|
86
|
+
interruption_started = true
|
|
87
|
+
run.interrupt_response_with do
|
|
88
|
+
prepared = run.prepare_interruption(interruption_payload(payload, options))
|
|
89
|
+
interruption_arguments(prepared, options)
|
|
90
|
+
end
|
|
91
|
+
ensure
|
|
92
|
+
finish_interruption! if interruption_started
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Requests cooperative cancellation and returns +self+.
|
|
96
|
+
def cancel
|
|
97
|
+
run.cancellation_token.cancel
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Waits for the worker and in-flight interruptions, then returns the Run.
|
|
102
|
+
#
|
|
103
|
+
# +deadline+ is an absolute Time. Reaching it raises DeadlineExceededError
|
|
104
|
+
# without cancelling the run. An event-delivery or cleanup failure raised by
|
|
105
|
+
# the worker is re-raised after all supervised work finishes.
|
|
106
|
+
def wait(deadline: nil)
|
|
107
|
+
worker = wait_until_finished(deadline:)
|
|
108
|
+
worker.join
|
|
109
|
+
caught = error
|
|
110
|
+
raise caught if caught
|
|
111
|
+
|
|
112
|
+
run
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Prevents new interruptions, requests cancellation, and waits for shutdown.
|
|
116
|
+
# The operation is idempotent. +deadline+ has the same meaning as in #wait.
|
|
117
|
+
def close(deadline: nil)
|
|
118
|
+
@mutex.synchronize { @closing = true }
|
|
119
|
+
cancel
|
|
120
|
+
wait(deadline:)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
protected
|
|
124
|
+
|
|
125
|
+
def start # :nodoc:
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
raise Error, "execution has already started" unless @state == :pending
|
|
128
|
+
|
|
129
|
+
@state = :running
|
|
130
|
+
@worker = Thread.new { execute }
|
|
131
|
+
end
|
|
132
|
+
self
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def execute
|
|
138
|
+
ExecutionState.with(@execution_state) do
|
|
139
|
+
run.each { |event| @event_consumer&.call(event) }
|
|
140
|
+
end
|
|
141
|
+
rescue => caught
|
|
142
|
+
@mutex.synchronize { @error = caught }
|
|
143
|
+
ensure
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@state = :finished
|
|
146
|
+
@condition.broadcast
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def begin_interruption!
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
raise AgentInterruptError, "Execution is closing" if @closing
|
|
153
|
+
raise AgentInterruptError, "Execution has already finished" if @state == :finished
|
|
154
|
+
|
|
155
|
+
@active_interruptions += 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def finish_interruption!
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@active_interruptions -= 1 if @active_interruptions.positive?
|
|
162
|
+
@condition.broadcast
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def interruption_payload(payload, options)
|
|
167
|
+
values = payload.is_a?(Hash) ? payload.dup : {message: payload}
|
|
168
|
+
options.each { |key, value| values[key] = value }
|
|
169
|
+
values
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def interruption_arguments(prepared, fallback)
|
|
173
|
+
unless prepared.is_a?(Hash)
|
|
174
|
+
return [prepared, fallback.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline)]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
values = prepared.transform_keys(&:to_sym)
|
|
178
|
+
message = values.delete(:message) { raise ArgumentError, "prepared interruption must include a message" }
|
|
179
|
+
allowed = values.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline)
|
|
180
|
+
[message, fallback.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline).merge(allowed)]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def wait_until_finished(deadline:)
|
|
184
|
+
monotonic_deadline = if deadline
|
|
185
|
+
monotonic_time + [deadline - Time.now, 0].max
|
|
186
|
+
end
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
until @state == :finished && @active_interruptions.zero?
|
|
189
|
+
if monotonic_deadline
|
|
190
|
+
remaining = monotonic_deadline - monotonic_time
|
|
191
|
+
raise DeadlineExceededError, "Execution did not finish before the wait deadline" unless remaining.positive?
|
|
192
|
+
|
|
193
|
+
@condition.wait(@mutex, remaining)
|
|
194
|
+
else
|
|
195
|
+
@condition.wait(@mutex)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
@worker
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def monotonic_time
|
|
203
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|