little_ghost 0.5.0 → 0.7.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 +3 -3
- data/docs/guides/code_mode.md +6 -3
- data/docs/guides/integrations.md +4 -4
- data/docs/guides/models_and_providers.md +91 -0
- data/docs/guides/production.md +114 -7
- data/docs/guides/tools.md +26 -0
- data/lib/little_ghost/agent.rb +5 -5
- data/lib/little_ghost/assembly.rb +9 -11
- data/lib/little_ghost/assembly_execution.rb +8 -17
- data/lib/little_ghost/code_mode/broker.rb +4 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
- data/lib/little_ghost/code_mode/ruby/host.rb +50 -10
- data/lib/little_ghost/code_mode/ruby/session.rb +22 -18
- data/lib/little_ghost/code_mode/ruby_engine.rb +1 -0
- data/lib/little_ghost/configuration.rb +101 -9
- data/lib/little_ghost/embeddings/request.rb +103 -0
- data/lib/little_ghost/embeddings/response.rb +55 -0
- data/lib/little_ghost/errors.rb +2 -0
- data/lib/little_ghost/execution.rb +31 -25
- data/lib/little_ghost/execution_state.rb +3 -3
- data/lib/little_ghost/graph.rb +6 -3
- data/lib/little_ghost/instrumentation.rb +2 -1
- data/lib/little_ghost/mcp/toolset.rb +1 -1
- data/lib/little_ghost/model.rb +20 -0
- data/lib/little_ghost/model_operations.rb +210 -0
- data/lib/little_ghost/network/certificate_authority.rb +6 -0
- data/lib/little_ghost/network/envoy_gateway.rb +11 -2
- data/lib/little_ghost/providers/base.rb +16 -1
- data/lib/little_ghost/providers/bedrock/aws_protocol.rb +1 -1
- data/lib/little_ghost/providers/bedrock/http_client.rb +28 -7
- data/lib/little_ghost/providers/bedrock.rb +93 -8
- data/lib/little_ghost/providers/openai.rb +80 -4
- data/lib/little_ghost/run.rb +9 -15
- data/lib/little_ghost/run_context.rb +2 -1
- data/lib/little_ghost/runtime/hooks/artifacts.rb +8 -0
- data/lib/little_ghost/runtime.rb +35 -3
- data/lib/little_ghost/sandbox/process_runner.rb +1 -1
- data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
- data/lib/little_ghost/session_stores/filesystem.rb +29 -13
- data/lib/little_ghost/subagents/manager.rb +170 -70
- data/lib/little_ghost/support/cancellation_token.rb +2 -2
- data/lib/little_ghost/support/executor.rb +84 -15
- data/lib/little_ghost/support/http_client.rb +19 -5
- data/lib/little_ghost/support/interruptible_stream.rb +4 -2
- data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
- data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
- data/lib/little_ghost/support/task.rb +126 -0
- data/lib/little_ghost/support/task_runner.rb +74 -0
- data/lib/little_ghost/support.rb +4 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +6 -4
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +3 -3
- data/lib/little_ghost.rb +42 -0
- metadata +27 -6
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "base64"
|
|
5
5
|
require "digest"
|
|
6
|
+
require "monitor"
|
|
6
7
|
require "time"
|
|
7
8
|
require_relative "definition"
|
|
8
9
|
require_relative "control_tool"
|
|
@@ -37,7 +38,9 @@ module LittleGhost
|
|
|
37
38
|
# With a parent session, durable definitions retain only committed compact
|
|
38
39
|
# transcripts and limited state snapshots. Failed or cancelled turns never
|
|
39
40
|
# become committed conversation history. Call #close to cancel and join
|
|
40
|
-
# workers owned by a directly constructed manager.
|
|
41
|
+
# workers owned by a directly constructed manager. If cooperative fiber
|
|
42
|
+
# cleanup exceeds the deadline, the manager remains closed to new work and
|
|
43
|
+
# a later #close retries cleanup.
|
|
41
44
|
class Manager
|
|
42
45
|
# Raised when one or more managed workers cannot stop within the cleanup
|
|
43
46
|
# deadline.
|
|
@@ -100,7 +103,6 @@ module LittleGhost
|
|
|
100
103
|
:progress_message,
|
|
101
104
|
:progress_sequence
|
|
102
105
|
)
|
|
103
|
-
|
|
104
106
|
class Completion # :nodoc:
|
|
105
107
|
def initialize
|
|
106
108
|
@mutex = Mutex.new
|
|
@@ -248,12 +250,17 @@ module LittleGhost
|
|
|
248
250
|
@cancellation_token = cancellation_token.child
|
|
249
251
|
@deadline = deadline
|
|
250
252
|
@observer = observer
|
|
253
|
+
@observer_events = []
|
|
254
|
+
@observer_flushing = false
|
|
255
|
+
@task_runner = runtime ? runtime.task_runner : Support::TaskRunner.new
|
|
251
256
|
@parent_session = parent_session
|
|
252
257
|
@parent_agent_path = AgentPath.validate!(parent_agent_path)
|
|
253
258
|
@parent_link = parent_session && self.class.parent_link(parent_session)
|
|
254
259
|
@registry_session = parent_session && registry_session
|
|
255
260
|
@capacity = Capacity.new(max_concurrent)
|
|
256
261
|
@mutex = Mutex.new
|
|
262
|
+
@close_monitor = Monitor.new
|
|
263
|
+
@close_active = false
|
|
257
264
|
@registry_mutex = Mutex.new
|
|
258
265
|
@restore_mutex = Mutex.new
|
|
259
266
|
@condition = ConditionVariable.new
|
|
@@ -262,6 +269,8 @@ module LittleGhost
|
|
|
262
269
|
@identity_slots = 0
|
|
263
270
|
@turn_count = 0
|
|
264
271
|
@closed = false
|
|
272
|
+
@close_complete = false
|
|
273
|
+
@closed_agents = {}
|
|
265
274
|
restore_identities
|
|
266
275
|
end
|
|
267
276
|
|
|
@@ -330,14 +339,28 @@ module LittleGhost
|
|
|
330
339
|
raise Error, "Subagent manager is closed"
|
|
331
340
|
end
|
|
332
341
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
342
|
+
begin
|
|
343
|
+
turn, queued_snapshot = enqueue(
|
|
344
|
+
identity,
|
|
345
|
+
task,
|
|
346
|
+
event: "spawned",
|
|
347
|
+
count_turn: false,
|
|
348
|
+
parent_operation_id:,
|
|
349
|
+
context:
|
|
350
|
+
)
|
|
351
|
+
rescue
|
|
352
|
+
synchronize_close do
|
|
353
|
+
should_close = @mutex.synchronize do
|
|
354
|
+
if @identities.delete(subagent_id)
|
|
355
|
+
@identity_slots -= 1
|
|
356
|
+
@turn_count -= 1
|
|
357
|
+
end
|
|
358
|
+
!@closed_agents.key?(agent.object_id)
|
|
359
|
+
end
|
|
360
|
+
agent.close if should_close && agent.respond_to?(:close)
|
|
361
|
+
end
|
|
362
|
+
raise
|
|
363
|
+
end
|
|
341
364
|
return {status: "working", subagent: queued_snapshot} if mode == "async"
|
|
342
365
|
|
|
343
366
|
turn.completion.value(cancellation_token: @cancellation_token, deadline: @deadline)
|
|
@@ -665,61 +688,99 @@ module LittleGhost
|
|
|
665
688
|
end
|
|
666
689
|
|
|
667
690
|
# Cancels queued work, cooperatively stops workers, and closes child
|
|
668
|
-
# agents. Raises CleanupError if workers do not stop within the bound.
|
|
691
|
+
# agents. Raises CleanupError if workers do not stop within the bound. A
|
|
692
|
+
# later call retries unfinished cleanup without accepting new work.
|
|
669
693
|
def close
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
cancel_queued_turns(identity)
|
|
685
|
-
emit("cancelled", identity, turn:)
|
|
694
|
+
synchronize_close { perform_close }
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
private
|
|
698
|
+
|
|
699
|
+
def synchronize_close
|
|
700
|
+
@close_monitor.synchronize do
|
|
701
|
+
return if @close_active
|
|
702
|
+
|
|
703
|
+
@close_active = true
|
|
704
|
+
begin
|
|
705
|
+
yield
|
|
706
|
+
ensure
|
|
707
|
+
@close_active = false
|
|
686
708
|
end
|
|
687
|
-
@condition.broadcast
|
|
688
|
-
@identities.values.filter_map(&:worker)
|
|
689
709
|
end
|
|
710
|
+
end
|
|
690
711
|
|
|
691
|
-
|
|
692
|
-
|
|
712
|
+
def perform_close
|
|
713
|
+
started_at = monotonic_time
|
|
714
|
+
deadline = started_at + @close_timeout
|
|
715
|
+
cooperative_deadline = started_at + (@close_timeout / 2.0)
|
|
716
|
+
identities = @mutex.synchronize do
|
|
717
|
+
return if @close_complete
|
|
718
|
+
|
|
719
|
+
unless @closed
|
|
720
|
+
@closed = true
|
|
721
|
+
@cancellation_token.cancel
|
|
722
|
+
@identities.each_value do |identity|
|
|
723
|
+
next if %w[idle failed cancelled persisting].include?(identity.status)
|
|
724
|
+
|
|
725
|
+
turn = identity.current
|
|
726
|
+
identity.status = "cancelled"
|
|
727
|
+
turn&.completion&.resolve(cancelled_turn(identity, turn))
|
|
728
|
+
identity.progress_message = nil
|
|
729
|
+
identity.current_turn = nil
|
|
730
|
+
identity.current = nil
|
|
731
|
+
cancel_queued_turns(identity)
|
|
732
|
+
queue_observer_event("cancelled", identity, turn:)
|
|
733
|
+
end
|
|
734
|
+
@condition.broadcast
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
@identities.values.dup
|
|
738
|
+
end
|
|
739
|
+
flush_observer_events
|
|
740
|
+
|
|
741
|
+
worker_entries = identities.filter_map do |identity|
|
|
742
|
+
worker = identity.worker
|
|
743
|
+
[identity, worker] if worker
|
|
744
|
+
end
|
|
745
|
+
workers = worker_entries.map(&:last)
|
|
693
746
|
workers.each do |worker|
|
|
694
747
|
remaining = cooperative_deadline - monotonic_time
|
|
695
748
|
break unless remaining.positive?
|
|
696
749
|
|
|
697
|
-
worker
|
|
750
|
+
wait_for_worker(worker, remaining)
|
|
698
751
|
end
|
|
699
|
-
workers.select(&:alive?).each(&:
|
|
752
|
+
workers.select(&:alive?).each(&:terminate)
|
|
700
753
|
workers.each do |worker|
|
|
701
754
|
remaining = deadline - monotonic_time
|
|
702
755
|
break unless remaining.positive?
|
|
703
756
|
|
|
704
|
-
worker
|
|
757
|
+
wait_for_worker(worker, remaining)
|
|
705
758
|
end
|
|
706
759
|
first_error = nil
|
|
707
|
-
|
|
708
|
-
|
|
760
|
+
surviving_task_entries = worker_entries.select { |_identity, worker| worker.alive? }
|
|
761
|
+
surviving_identities = surviving_task_entries.map(&:first).uniq(&:object_id)
|
|
762
|
+
unless surviving_identities.empty?
|
|
709
763
|
first_error ||= CleanupError.new(
|
|
710
|
-
"#{
|
|
764
|
+
"#{surviving_identities.length} subagent worker(s) did not stop within #{@close_timeout} seconds"
|
|
711
765
|
)
|
|
712
766
|
end
|
|
713
|
-
|
|
767
|
+
|
|
768
|
+
blocked_identities = surviving_task_entries.map(&:first)
|
|
769
|
+
blocked_agents = blocked_identities.filter_map(&:agent).to_h { |agent| [agent.object_id, true] }
|
|
770
|
+
agents = identities.reverse.filter_map(&:agent).uniq(&:object_id).reject do |agent|
|
|
771
|
+
blocked_agents.key?(agent.object_id) || @mutex.synchronize { @closed_agents.key?(agent.object_id) }
|
|
772
|
+
end
|
|
714
773
|
agents.each do |agent|
|
|
715
774
|
agent.close if agent.respond_to?(:close)
|
|
775
|
+
@mutex.synchronize { @closed_agents[agent.object_id] = true }
|
|
716
776
|
rescue => error
|
|
717
777
|
first_error ||= error
|
|
718
778
|
end
|
|
719
779
|
raise first_error if first_error
|
|
720
|
-
end
|
|
721
780
|
|
|
722
|
-
|
|
781
|
+
@mutex.synchronize { @close_complete = true }
|
|
782
|
+
nil
|
|
783
|
+
end
|
|
723
784
|
|
|
724
785
|
def restore_identities
|
|
725
786
|
return unless @registry_session
|
|
@@ -1086,6 +1147,8 @@ module LittleGhost
|
|
|
1086
1147
|
return rejection if rejection
|
|
1087
1148
|
end
|
|
1088
1149
|
|
|
1150
|
+
worker = @task_runner.spawn { run_identity(identity) } unless identity.worker&.alive?
|
|
1151
|
+
|
|
1089
1152
|
turn = Turn.new(
|
|
1090
1153
|
number: identity.next_turn,
|
|
1091
1154
|
message: message,
|
|
@@ -1100,17 +1163,14 @@ module LittleGhost
|
|
|
1100
1163
|
@turn_count += 1 if count_turn
|
|
1101
1164
|
identity.queue << turn
|
|
1102
1165
|
identity.status = "queued" unless identity.status == "running"
|
|
1103
|
-
|
|
1166
|
+
identity.worker = worker if worker
|
|
1167
|
+
queue_observer_event(event, identity, turn:)
|
|
1104
1168
|
queued_snapshot = snapshot(identity)
|
|
1105
|
-
unless identity.worker&.alive?
|
|
1106
|
-
execution_state = ExecutionState.capture
|
|
1107
|
-
identity.worker = Thread.new do
|
|
1108
|
-
ExecutionState.with(execution_state) { run_identity(identity) }
|
|
1109
|
-
end
|
|
1110
|
-
end
|
|
1111
1169
|
@condition.broadcast
|
|
1112
1170
|
end
|
|
1113
1171
|
[turn, queued_snapshot]
|
|
1172
|
+
ensure
|
|
1173
|
+
flush_observer_events
|
|
1114
1174
|
end
|
|
1115
1175
|
|
|
1116
1176
|
def run_identity(identity)
|
|
@@ -1145,11 +1205,12 @@ module LittleGhost
|
|
|
1145
1205
|
identity.status = "running"
|
|
1146
1206
|
identity.progress_message = nil
|
|
1147
1207
|
identity.progress_sequence += 1
|
|
1148
|
-
|
|
1208
|
+
queue_observer_event("turn_started", identity, turn:)
|
|
1149
1209
|
@condition.broadcast
|
|
1150
1210
|
true
|
|
1151
1211
|
end
|
|
1152
1212
|
end
|
|
1213
|
+
flush_observer_events
|
|
1153
1214
|
next unless should_run
|
|
1154
1215
|
|
|
1155
1216
|
begin
|
|
@@ -1194,9 +1255,10 @@ module LittleGhost
|
|
|
1194
1255
|
end
|
|
1195
1256
|
ensure
|
|
1196
1257
|
@mutex.synchronize do
|
|
1197
|
-
identity.worker = nil if identity.worker
|
|
1258
|
+
identity.worker = nil if identity.worker&.current?
|
|
1198
1259
|
@condition.broadcast
|
|
1199
1260
|
end
|
|
1261
|
+
flush_observer_events
|
|
1200
1262
|
end
|
|
1201
1263
|
|
|
1202
1264
|
def run_agent_turn(identity, turn, options)
|
|
@@ -1221,11 +1283,12 @@ module LittleGhost
|
|
|
1221
1283
|
@condition.broadcast
|
|
1222
1284
|
if tool_use
|
|
1223
1285
|
event_name = (event.type == :tool_start) ? "tool_started" : "tool_finished"
|
|
1224
|
-
|
|
1286
|
+
queue_observer_event(event_name, identity, turn:, tool_call_id: tool_use.id, tool_name: tool_use.name)
|
|
1225
1287
|
else
|
|
1226
|
-
|
|
1288
|
+
queue_observer_event("activity", identity, turn:)
|
|
1227
1289
|
end
|
|
1228
1290
|
end
|
|
1291
|
+
flush_observer_events
|
|
1229
1292
|
end
|
|
1230
1293
|
|
|
1231
1294
|
def progress_message(event)
|
|
@@ -1321,9 +1384,10 @@ module LittleGhost
|
|
|
1321
1384
|
}
|
|
1322
1385
|
value[:response_truncated] = true if truncated
|
|
1323
1386
|
turn.completion.resolve(value)
|
|
1324
|
-
|
|
1387
|
+
queue_observer_event("turn_finished", identity, turn:)
|
|
1325
1388
|
@condition.broadcast
|
|
1326
1389
|
end
|
|
1390
|
+
flush_observer_events
|
|
1327
1391
|
end
|
|
1328
1392
|
|
|
1329
1393
|
def retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
|
|
@@ -1384,10 +1448,11 @@ module LittleGhost
|
|
|
1384
1448
|
error: identity.latest_error
|
|
1385
1449
|
)
|
|
1386
1450
|
end
|
|
1387
|
-
|
|
1451
|
+
queue_observer_event("turn_failed", identity, turn:, error_type: error.class.name)
|
|
1388
1452
|
fail_queued_turns(identity)
|
|
1389
1453
|
@condition.broadcast
|
|
1390
1454
|
end
|
|
1455
|
+
flush_observer_events
|
|
1391
1456
|
end
|
|
1392
1457
|
|
|
1393
1458
|
def cancel_unrun_turn(identity, turn)
|
|
@@ -1400,9 +1465,10 @@ module LittleGhost
|
|
|
1400
1465
|
identity.current = nil
|
|
1401
1466
|
identity.status = "cancelled"
|
|
1402
1467
|
cancel_queued_turns(identity)
|
|
1403
|
-
|
|
1468
|
+
queue_observer_event("cancelled", identity, turn:) if newly_cancelled
|
|
1404
1469
|
@condition.broadcast
|
|
1405
1470
|
end
|
|
1471
|
+
flush_observer_events
|
|
1406
1472
|
end
|
|
1407
1473
|
|
|
1408
1474
|
def cancelled_turn(identity, turn)
|
|
@@ -1418,7 +1484,7 @@ module LittleGhost
|
|
|
1418
1484
|
def cancel_queued_turns(identity)
|
|
1419
1485
|
identity.queue.each do |turn|
|
|
1420
1486
|
turn.completion.resolve(cancelled_turn(identity, turn))
|
|
1421
|
-
|
|
1487
|
+
queue_observer_event("cancelled", identity, turn:)
|
|
1422
1488
|
end
|
|
1423
1489
|
identity.queue.clear
|
|
1424
1490
|
end
|
|
@@ -1432,7 +1498,7 @@ module LittleGhost
|
|
|
1432
1498
|
turn: turn.number,
|
|
1433
1499
|
error: "A previous turn failed; spawn a new identity."
|
|
1434
1500
|
)
|
|
1435
|
-
|
|
1501
|
+
queue_observer_event("turn_failed", identity, turn:)
|
|
1436
1502
|
end
|
|
1437
1503
|
identity.queue.clear
|
|
1438
1504
|
end
|
|
@@ -1526,7 +1592,7 @@ module LittleGhost
|
|
|
1526
1592
|
%w[idle failed cancelled].include?(identity.status)
|
|
1527
1593
|
end
|
|
1528
1594
|
|
|
1529
|
-
def
|
|
1595
|
+
def queue_observer_event(event, identity, turn: nil, **attributes)
|
|
1530
1596
|
return unless @observer
|
|
1531
1597
|
|
|
1532
1598
|
value = {
|
|
@@ -1545,9 +1611,7 @@ module LittleGhost
|
|
|
1545
1611
|
end
|
|
1546
1612
|
end
|
|
1547
1613
|
value.merge!(attributes)
|
|
1548
|
-
@
|
|
1549
|
-
rescue
|
|
1550
|
-
nil
|
|
1614
|
+
@observer_events << value.freeze
|
|
1551
1615
|
end
|
|
1552
1616
|
|
|
1553
1617
|
def observe_delegated_activity(identity)
|
|
@@ -1565,21 +1629,57 @@ module LittleGhost
|
|
|
1565
1629
|
@condition.broadcast
|
|
1566
1630
|
identity.current
|
|
1567
1631
|
end
|
|
1568
|
-
|
|
1632
|
+
if turn
|
|
1633
|
+
@mutex.synchronize { queue_observer_event("activity", identity, turn:) }
|
|
1634
|
+
flush_observer_events
|
|
1635
|
+
end
|
|
1569
1636
|
end
|
|
1570
1637
|
|
|
1571
1638
|
def emit_factory_failure(definition, subagent_id, error, parent_operation_id: nil)
|
|
1572
1639
|
return unless @observer
|
|
1573
1640
|
|
|
1574
|
-
@
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1641
|
+
@mutex.synchronize do
|
|
1642
|
+
@observer_events << {
|
|
1643
|
+
event: "factory_failed",
|
|
1644
|
+
subagent_id:,
|
|
1645
|
+
kind: definition.kind,
|
|
1646
|
+
status: "failed",
|
|
1647
|
+
error_type: error.class.name,
|
|
1648
|
+
parent_operation_id:
|
|
1649
|
+
}.compact.freeze
|
|
1650
|
+
end
|
|
1651
|
+
flush_observer_events
|
|
1652
|
+
end
|
|
1653
|
+
|
|
1654
|
+
def flush_observer_events
|
|
1655
|
+
return unless @observer
|
|
1656
|
+
|
|
1657
|
+
should_flush = @mutex.synchronize do
|
|
1658
|
+
next false if @observer_flushing || @observer_events.empty?
|
|
1659
|
+
|
|
1660
|
+
@observer_flushing = true
|
|
1661
|
+
end
|
|
1662
|
+
return unless should_flush
|
|
1663
|
+
|
|
1664
|
+
loop do
|
|
1665
|
+
value = @mutex.synchronize do
|
|
1666
|
+
event = @observer_events.shift
|
|
1667
|
+
@observer_flushing = false unless event
|
|
1668
|
+
event
|
|
1669
|
+
end
|
|
1670
|
+
break unless value
|
|
1671
|
+
|
|
1672
|
+
begin
|
|
1673
|
+
@observer.call(value)
|
|
1674
|
+
rescue
|
|
1675
|
+
nil
|
|
1676
|
+
end
|
|
1677
|
+
end
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
def wait_for_worker(worker, timeout)
|
|
1681
|
+
worker.wait(deadline: Time.now + timeout)
|
|
1682
|
+
rescue DeadlineExceededError
|
|
1583
1683
|
nil
|
|
1584
1684
|
end
|
|
1585
1685
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module LittleGhost
|
|
4
4
|
module Support
|
|
5
5
|
# CancellationToken lets related work stop cooperatively without killing its
|
|
6
|
-
# calling thread. Child tokens make cancellation flow through a
|
|
7
|
-
# work.
|
|
6
|
+
# calling thread or fiber. Child tokens make cancellation flow through a
|
|
7
|
+
# run's tree of work.
|
|
8
8
|
#
|
|
9
9
|
# Cancellation is idempotent and flows only downward. Long-running
|
|
10
10
|
# extensions should call #raise_if_cancelled! at bounded intervals.
|
|
@@ -6,15 +6,38 @@ module LittleGhost
|
|
|
6
6
|
# the final results. It gives framework extensions bounded parallelism without
|
|
7
7
|
# losing cancellation or request-scoped state.
|
|
8
8
|
#
|
|
9
|
-
# ExecutionState is copied to workers. +on_result+ runs on the
|
|
10
|
-
#
|
|
11
|
-
# error, or otherwise the first input-order error, is raised.
|
|
12
|
-
class Executor
|
|
13
|
-
# Sets the maximum number of
|
|
14
|
-
def initialize(max_concurrency: 8)
|
|
9
|
+
# ExecutionState is copied to workers. +on_result+ runs on the caller in
|
|
10
|
+
# completion order. After all workers join, the first
|
|
11
|
+
# cleanup error, or otherwise the first input-order error, is raised.
|
|
12
|
+
class Executor # :nodoc:
|
|
13
|
+
# Sets the maximum number of batch workers and their execution policy.
|
|
14
|
+
def initialize(max_concurrency: 8, runner: TaskRunner.new, wait_through_interruptions: false)
|
|
15
15
|
raise ArgumentError, "max_concurrency must be at least 1" if max_concurrency < 1
|
|
16
16
|
|
|
17
17
|
@max_concurrency = max_concurrency
|
|
18
|
+
@runner = runner
|
|
19
|
+
@wait_through_interruptions = wait_through_interruptions
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :runner # :nodoc:
|
|
23
|
+
|
|
24
|
+
# Submits one unit of work and returns its Task.
|
|
25
|
+
def submit(&work)
|
|
26
|
+
@runner.spawn(&work)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs one unit of work and returns its result.
|
|
30
|
+
def call(&work)
|
|
31
|
+
resolve(submit(&work))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Runs one unit of work only when runner capacity is immediately
|
|
35
|
+
# available. Returns an accepted flag and the result.
|
|
36
|
+
def try_call(&work)
|
|
37
|
+
task = @runner.try_spawn(&work)
|
|
38
|
+
return [false, nil] unless task
|
|
39
|
+
|
|
40
|
+
[true, resolve(task)]
|
|
18
41
|
end
|
|
19
42
|
|
|
20
43
|
# Maps +values+ with at most the configured number of workers.
|
|
@@ -34,11 +57,10 @@ module LittleGhost
|
|
|
34
57
|
error_mutex = Mutex.new
|
|
35
58
|
first_worker_error = nil
|
|
36
59
|
worker_count = [@max_concurrency, items.length].min
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
ExecutionState.with(execution_state) do
|
|
60
|
+
workers = []
|
|
61
|
+
begin
|
|
62
|
+
worker_count.times do
|
|
63
|
+
workers << submit do
|
|
42
64
|
loop do
|
|
43
65
|
index = begin
|
|
44
66
|
queue.pop(true)
|
|
@@ -57,15 +79,29 @@ module LittleGhost
|
|
|
57
79
|
completions << index
|
|
58
80
|
end
|
|
59
81
|
end
|
|
82
|
+
ensure
|
|
83
|
+
completions << :worker_finished
|
|
60
84
|
end
|
|
61
85
|
end
|
|
86
|
+
rescue
|
|
87
|
+
cancellation_token.cancel
|
|
88
|
+
workers.each(&:wait)
|
|
89
|
+
raise
|
|
62
90
|
end
|
|
63
91
|
callback_error = nil
|
|
64
92
|
begin
|
|
65
|
-
|
|
66
|
-
|
|
93
|
+
completed_items = 0
|
|
94
|
+
finished_workers = 0
|
|
95
|
+
until completed_items == items.length || finished_workers == workers.length
|
|
96
|
+
completion = completions.pop
|
|
97
|
+
if completion == :worker_finished
|
|
98
|
+
finished_workers += 1
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
completed_items += 1
|
|
67
103
|
begin
|
|
68
|
-
on_result.call(
|
|
104
|
+
on_result.call(completion, results[completion]) if on_result && !errors[completion]
|
|
69
105
|
rescue => error
|
|
70
106
|
callback_error = error
|
|
71
107
|
cancellation_token.cancel
|
|
@@ -73,15 +109,48 @@ module LittleGhost
|
|
|
73
109
|
end
|
|
74
110
|
end
|
|
75
111
|
ensure
|
|
76
|
-
workers.each(&:
|
|
112
|
+
workers.each(&:wait)
|
|
77
113
|
end
|
|
78
114
|
|
|
79
115
|
raise callback_error if callback_error
|
|
116
|
+
task_error = workers.filter_map(&:error).first
|
|
117
|
+
raise task_error if task_error
|
|
118
|
+
|
|
80
119
|
first_error = errors.compact.find { |error| error.is_a?(CleanupError) } || first_worker_error
|
|
81
120
|
raise first_error if first_error
|
|
82
121
|
|
|
83
122
|
results
|
|
84
123
|
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def resolve(task)
|
|
128
|
+
interruption = nil
|
|
129
|
+
loop do
|
|
130
|
+
task.wait
|
|
131
|
+
break
|
|
132
|
+
rescue Exception => error # rubocop:disable Lint/RescueException
|
|
133
|
+
raise unless @wait_through_interruptions
|
|
134
|
+
|
|
135
|
+
interruption ||= error
|
|
136
|
+
break unless task.alive?
|
|
137
|
+
end
|
|
138
|
+
raise interruption if interruption
|
|
139
|
+
raise task.error if task.error
|
|
140
|
+
|
|
141
|
+
task.result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The process-wide Executor for work moved off a scheduler thread.
|
|
145
|
+
BLOCKING = new(
|
|
146
|
+
runner: PooledThreadRunner.new,
|
|
147
|
+
wait_through_interruptions: true
|
|
148
|
+
) # :nodoc:
|
|
149
|
+
private_constant :BLOCKING
|
|
150
|
+
|
|
151
|
+
class << self
|
|
152
|
+
def blocking = BLOCKING # :nodoc:
|
|
153
|
+
end
|
|
85
154
|
end
|
|
86
155
|
end
|
|
87
156
|
end
|
|
@@ -70,6 +70,7 @@ module LittleGhost
|
|
|
70
70
|
method: :post,
|
|
71
71
|
headers:,
|
|
72
72
|
body:,
|
|
73
|
+
cancellation_token:,
|
|
73
74
|
deadline:,
|
|
74
75
|
allow_insecure_http: @allow_insecure_http,
|
|
75
76
|
label: "Provider"
|
|
@@ -80,7 +81,8 @@ module LittleGhost
|
|
|
80
81
|
|
|
81
82
|
# Executes a bounded request and returns the complete response body.
|
|
82
83
|
def request(uri:, method: :get, headers: {}, body: nil, allow_insecure_http: false,
|
|
83
|
-
cancellation_token: nil, deadline: nil)
|
|
84
|
+
cancellation_token: nil, deadline: nil, label: "HTTP request", max_response_bytes: @max_response_bytes)
|
|
85
|
+
response_limit = positive_integer(max_response_bytes, :max_response_bytes)
|
|
84
86
|
response_body = +""
|
|
85
87
|
each_chunk(
|
|
86
88
|
uri:,
|
|
@@ -89,8 +91,14 @@ module LittleGhost
|
|
|
89
91
|
body:,
|
|
90
92
|
cancellation_token:,
|
|
91
93
|
deadline: deadline || Time.now + @read_timeout,
|
|
92
|
-
allow_insecure_http
|
|
93
|
-
|
|
94
|
+
allow_insecure_http:,
|
|
95
|
+
label:
|
|
96
|
+
) do |chunk|
|
|
97
|
+
if response_body.bytesize + chunk.bytesize > response_limit
|
|
98
|
+
raise ProtocolError, "#{label} response exceeded #{response_limit} bytes"
|
|
99
|
+
end
|
|
100
|
+
response_body << chunk
|
|
101
|
+
end
|
|
94
102
|
response_body
|
|
95
103
|
end
|
|
96
104
|
|
|
@@ -119,7 +127,12 @@ module LittleGhost
|
|
|
119
127
|
http.write_timeout = remaining_timeout(deadline, @read_timeout)
|
|
120
128
|
http.request(request) do |response|
|
|
121
129
|
unless response.is_a?(Net::HTTPSuccess)
|
|
122
|
-
response_body = read_limited(
|
|
130
|
+
response_body = read_limited(
|
|
131
|
+
response,
|
|
132
|
+
@max_error_body_bytes,
|
|
133
|
+
cancellation_token:,
|
|
134
|
+
deadline:
|
|
135
|
+
)
|
|
123
136
|
raise Providers::HTTPError.new(
|
|
124
137
|
"#{label} failed with HTTP #{response.code}",
|
|
125
138
|
status: response.code.to_i,
|
|
@@ -165,9 +178,10 @@ module LittleGhost
|
|
|
165
178
|
[remaining, maximum].min
|
|
166
179
|
end
|
|
167
180
|
|
|
168
|
-
def read_limited(response, limit)
|
|
181
|
+
def read_limited(response, limit, cancellation_token:, deadline:)
|
|
169
182
|
body = +""
|
|
170
183
|
response.read_body do |chunk|
|
|
184
|
+
check_control!(cancellation_token, deadline)
|
|
171
185
|
remaining = limit - body.bytesize
|
|
172
186
|
body << chunk.byteslice(0, remaining) if remaining.positive?
|
|
173
187
|
break if body.bytesize >= limit
|
|
@@ -8,7 +8,9 @@ module LittleGhost
|
|
|
8
8
|
#
|
|
9
9
|
# The producer receives an emitter callable. Ending enumeration early stops
|
|
10
10
|
# and joins the producer; CleanupError is raised if it cannot be stopped
|
|
11
|
-
# within the fixed shutdown bound.
|
|
11
|
+
# within the fixed shutdown bound. The producer remains on a worker thread
|
|
12
|
+
# even when the consumer uses a Fiber scheduler, allowing cleanup to
|
|
13
|
+
# interrupt a provider SDK or socket read that remains blocked.
|
|
12
14
|
#
|
|
13
15
|
# stream = LittleGhost::Support::InterruptibleStream.new(
|
|
14
16
|
# cancellation_token: token
|
|
@@ -36,7 +38,7 @@ module LittleGhost
|
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Yields produced values, raising producer, cancellation, deadline, or
|
|
39
|
-
# cleanup errors in the
|
|
41
|
+
# cleanup errors in the caller.
|
|
40
42
|
def each
|
|
41
43
|
return enum_for(__method__) unless block_given?
|
|
42
44
|
|