igniter 0.4.0 → 0.4.3

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/LLM_V1.md +335 -0
  5. data/docs/PATTERNS.md +189 -0
  6. data/docs/SERVER_V1.md +313 -0
  7. data/examples/README.md +129 -0
  8. data/examples/agents.rb +150 -0
  9. data/examples/differential.rb +161 -0
  10. data/examples/distributed_server.rb +94 -0
  11. data/examples/effects.rb +184 -0
  12. data/examples/invariants.rb +179 -0
  13. data/examples/order_pipeline.rb +163 -0
  14. data/examples/provenance.rb +122 -0
  15. data/examples/saga.rb +110 -0
  16. data/lib/igniter/agent/mailbox.rb +96 -0
  17. data/lib/igniter/agent/message.rb +21 -0
  18. data/lib/igniter/agent/ref.rb +86 -0
  19. data/lib/igniter/agent/runner.rb +129 -0
  20. data/lib/igniter/agent/state_holder.rb +23 -0
  21. data/lib/igniter/agent.rb +155 -0
  22. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  23. data/lib/igniter/differential/divergence.rb +29 -0
  24. data/lib/igniter/differential/formatter.rb +96 -0
  25. data/lib/igniter/differential/report.rb +86 -0
  26. data/lib/igniter/differential/runner.rb +130 -0
  27. data/lib/igniter/differential.rb +51 -0
  28. data/lib/igniter/dsl/contract_builder.rb +32 -0
  29. data/lib/igniter/effect.rb +91 -0
  30. data/lib/igniter/effect_registry.rb +78 -0
  31. data/lib/igniter/errors.rb +11 -1
  32. data/lib/igniter/execution_report/builder.rb +54 -0
  33. data/lib/igniter/execution_report/formatter.rb +50 -0
  34. data/lib/igniter/execution_report/node_entry.rb +24 -0
  35. data/lib/igniter/execution_report/report.rb +65 -0
  36. data/lib/igniter/execution_report.rb +32 -0
  37. data/lib/igniter/extensions/differential.rb +114 -0
  38. data/lib/igniter/extensions/execution_report.rb +27 -0
  39. data/lib/igniter/extensions/invariants.rb +116 -0
  40. data/lib/igniter/extensions/provenance.rb +45 -0
  41. data/lib/igniter/extensions/saga.rb +74 -0
  42. data/lib/igniter/integrations/agents.rb +18 -0
  43. data/lib/igniter/invariant.rb +50 -0
  44. data/lib/igniter/model/effect_node.rb +37 -0
  45. data/lib/igniter/model.rb +1 -0
  46. data/lib/igniter/property_testing/formatter.rb +66 -0
  47. data/lib/igniter/property_testing/generators.rb +115 -0
  48. data/lib/igniter/property_testing/result.rb +45 -0
  49. data/lib/igniter/property_testing/run.rb +43 -0
  50. data/lib/igniter/property_testing/runner.rb +47 -0
  51. data/lib/igniter/property_testing.rb +64 -0
  52. data/lib/igniter/provenance/builder.rb +97 -0
  53. data/lib/igniter/provenance/lineage.rb +82 -0
  54. data/lib/igniter/provenance/node_trace.rb +65 -0
  55. data/lib/igniter/provenance/text_formatter.rb +70 -0
  56. data/lib/igniter/provenance.rb +29 -0
  57. data/lib/igniter/registry.rb +67 -0
  58. data/lib/igniter/runtime/resolver.rb +15 -0
  59. data/lib/igniter/saga/compensation.rb +31 -0
  60. data/lib/igniter/saga/compensation_record.rb +20 -0
  61. data/lib/igniter/saga/executor.rb +85 -0
  62. data/lib/igniter/saga/formatter.rb +49 -0
  63. data/lib/igniter/saga/result.rb +47 -0
  64. data/lib/igniter/saga.rb +56 -0
  65. data/lib/igniter/stream_loop.rb +80 -0
  66. data/lib/igniter/supervisor.rb +167 -0
  67. data/lib/igniter/version.rb +1 -1
  68. data/lib/igniter.rb +10 -0
  69. metadata +57 -1
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Order Pipeline — guard + collection + branch + export
4
+ #
5
+ # Flow:
6
+ # 1. guard — require in_stock == true before proceeding
7
+ # 2. collection — compute subtotal per line item (LineItemContract per item)
8
+ # 3. compute — aggregate item subtotals into order_subtotal
9
+ # 4. branch — choose domestic or international shipping strategy
10
+ # 5. export — pull shipping_cost and eta out of the selected branch
11
+ # 6. output — order_subtotal, shipping_cost, eta, grand_total
12
+ #
13
+ # Run: ruby examples/order_pipeline.rb
14
+
15
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
16
+ require "igniter"
17
+
18
+ # ── Line Item ────────────────────────────────────────────────────────────────
19
+
20
+ class LineItemContract < Igniter::Contract
21
+ define do
22
+ input :sku, type: :string
23
+ input :quantity, type: :numeric
24
+ input :unit_price, type: :numeric
25
+
26
+ compute :subtotal, depends_on: %i[quantity unit_price] do |quantity:, unit_price:|
27
+ (quantity * unit_price).round(2)
28
+ end
29
+
30
+ output :sku
31
+ output :subtotal
32
+ end
33
+ end
34
+
35
+ # ── Shipping Strategies ──────────────────────────────────────────────────────
36
+
37
+ class DomesticShippingContract < Igniter::Contract
38
+ define do
39
+ input :country, type: :string
40
+ input :subtotal, type: :numeric
41
+
42
+ compute :shipping_cost, depends_on: :subtotal do |subtotal:|
43
+ subtotal >= 100 ? 0.0 : 9.99
44
+ end
45
+
46
+ compute :eta, depends_on: :country do |country:|
47
+ country == "US" ? "2-3 business days" : "3-5 business days"
48
+ end
49
+
50
+ output :shipping_cost
51
+ output :eta
52
+ end
53
+ end
54
+
55
+ class InternationalShippingContract < Igniter::Contract
56
+ define do
57
+ input :country, type: :string
58
+ input :subtotal, type: :numeric
59
+
60
+ compute :shipping_cost, depends_on: :subtotal do |subtotal:|
61
+ (subtotal * 0.15).round(2)
62
+ end
63
+
64
+ compute :eta, depends_on: :country do |**|
65
+ "7-14 business days"
66
+ end
67
+
68
+ output :shipping_cost
69
+ output :eta
70
+ end
71
+ end
72
+
73
+ # ── Order Pipeline ────────────────────────────────────────────────────────────
74
+
75
+ class OrderPipelineContract < Igniter::Contract
76
+ define do # rubocop:disable Metrics/BlockLength
77
+ input :line_items, type: :array
78
+ input :country, type: :string
79
+ input :in_stock, type: :boolean
80
+
81
+ # Guard fires before anything else is computed
82
+ guard :stock_available,
83
+ with: :in_stock,
84
+ eq: true,
85
+ message: "Order cannot be placed: items are out of stock"
86
+
87
+ # Gate node: depends on the guard — if guard fails, this node fails too,
88
+ # which blocks the collection and all downstream nodes from running.
89
+ compute :gated_items, depends_on: %i[line_items stock_available] do |line_items:, **|
90
+ line_items
91
+ end
92
+
93
+ # Fan-out: one LineItemContract per item hash
94
+ collection :items,
95
+ with: :gated_items,
96
+ each: LineItemContract,
97
+ key: :sku,
98
+ mode: :collect
99
+
100
+ # Sum succeeded item subtotals
101
+ compute :order_subtotal, depends_on: :items do |items:|
102
+ items.successes.values.sum { |item| item.result.subtotal }.round(2)
103
+ end
104
+
105
+ # Route to the right shipping strategy based on country
106
+ branch :shipping,
107
+ with: :country,
108
+ inputs: { country: :country, subtotal: :order_subtotal } do
109
+ on "US", contract: DomesticShippingContract
110
+ on "CA", contract: DomesticShippingContract
111
+ default contract: InternationalShippingContract
112
+ end
113
+
114
+ # Lift shipping outputs up to the top-level graph
115
+ # export calls output internally, so no separate output declarations needed
116
+ export :shipping_cost, :eta, from: :shipping
117
+
118
+ compute :grand_total, depends_on: %i[order_subtotal shipping_cost] do |order_subtotal:, shipping_cost:|
119
+ (order_subtotal + shipping_cost).round(2)
120
+ end
121
+
122
+ output :items
123
+ output :order_subtotal
124
+ output :grand_total
125
+ end
126
+ end
127
+
128
+ # ── Run ───────────────────────────────────────────────────────────────────────
129
+
130
+ line_items = [
131
+ { sku: "ruby-book", quantity: 2, unit_price: 29.99 },
132
+ { sku: "rails-course", quantity: 1, unit_price: 49.99 },
133
+ { sku: "keyboard", quantity: 1, unit_price: 89.99 }
134
+ ]
135
+
136
+ # US order — free shipping over $100
137
+ us_order = OrderPipelineContract.new(line_items: line_items, country: "US", in_stock: true)
138
+ us_order.resolve_all
139
+
140
+ puts "=== US Order ==="
141
+ puts "items_summary=#{us_order.result.items.summary.inspect}"
142
+ puts "order_subtotal=#{us_order.result.order_subtotal}"
143
+ puts "shipping_cost=#{us_order.result.shipping_cost}"
144
+ puts "eta=#{us_order.result.eta}"
145
+ puts "grand_total=#{us_order.result.grand_total}"
146
+
147
+ # International order — 15% shipping
148
+ intl_order = OrderPipelineContract.new(line_items: line_items, country: "DE", in_stock: true)
149
+ intl_order.resolve_all
150
+
151
+ puts "\n=== International Order (DE) ==="
152
+ puts "shipping_cost=#{intl_order.result.shipping_cost}"
153
+ puts "eta=#{intl_order.result.eta}"
154
+ puts "grand_total=#{intl_order.result.grand_total}"
155
+
156
+ # Out of stock — guard fires and raises ResolutionError
157
+ oos_order = OrderPipelineContract.new(line_items: line_items, country: "US", in_stock: false)
158
+ begin
159
+ oos_order.resolve_all
160
+ rescue Igniter::ResolutionError => e
161
+ puts "\n=== Out of Stock ==="
162
+ puts "error=#{e.message.split(" [").first}"
163
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Provenance — data lineage for Igniter contracts
4
+ #
5
+ # After a contract resolves, provenance answers:
6
+ # "How was this output computed, and which inputs influenced it?"
7
+ #
8
+ # Run: ruby examples/provenance.rb
9
+
10
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
11
+ require "igniter"
12
+ require "igniter/extensions/provenance"
13
+
14
+ # ── Contracts ─────────────────────────────────────────────────────────────────
15
+
16
+ class TierDiscountContract < Igniter::Contract
17
+ TIERS = { bronze: 0, silver: 10, gold: 20, platinum: 30 }.freeze
18
+
19
+ define do
20
+ input :user_tier, type: :symbol
21
+
22
+ compute :discount_pct, depends_on: :user_tier do |user_tier:|
23
+ TierDiscountContract::TIERS.fetch(user_tier, 0)
24
+ end
25
+
26
+ output :discount_pct
27
+ end
28
+ end
29
+
30
+ class PricingContract < Igniter::Contract
31
+ define do
32
+ input :base_price, type: :numeric
33
+ input :quantity, type: :numeric
34
+ input :user_tier, type: :symbol
35
+
36
+ compute :unit_price, depends_on: :base_price do |base_price:|
37
+ (base_price * 1.08).round(2) # +8% margin
38
+ end
39
+
40
+ compute :subtotal, depends_on: %i[unit_price quantity] do |unit_price:, quantity:|
41
+ (unit_price * quantity).round(2)
42
+ end
43
+
44
+ compose :tier_info, contract: TierDiscountContract,
45
+ inputs: { user_tier: :user_tier }
46
+
47
+ compute :discount_amount, depends_on: %i[subtotal tier_info] do |subtotal:, tier_info:|
48
+ (subtotal * tier_info.discount_pct / 100.0).round(2)
49
+ end
50
+
51
+ compute :grand_total, depends_on: %i[subtotal discount_amount] do |subtotal:, discount_amount:|
52
+ (subtotal - discount_amount).round(2)
53
+ end
54
+
55
+ output :unit_price
56
+ output :subtotal
57
+ output :discount_amount
58
+ output :grand_total
59
+ end
60
+ end
61
+
62
+ # ── Run ───────────────────────────────────────────────────────────────────────
63
+
64
+ puts "=== Provenance Demo ==="
65
+ puts
66
+
67
+ contract = PricingContract.new(
68
+ base_price: 100.0,
69
+ quantity: 3,
70
+ user_tier: :gold
71
+ )
72
+ contract.resolve_all
73
+
74
+ puts "grand_total=#{contract.result.grand_total}"
75
+ puts
76
+
77
+ # ── ASCII tree ────────────────────────────────────────────────────────────────
78
+
79
+ puts "--- explain(:grand_total) ---"
80
+ puts contract.explain(:grand_total)
81
+ puts
82
+
83
+ # ── Query API ─────────────────────────────────────────────────────────────────
84
+
85
+ lin = contract.lineage(:grand_total)
86
+
87
+ puts "--- contributing_inputs ---"
88
+ lin.contributing_inputs.each do |input_name, val|
89
+ puts " #{input_name} = #{val.inspect}"
90
+ end
91
+ puts
92
+
93
+ puts "--- sensitive_to? ---"
94
+ puts "sensitive_to?(:base_price) = #{lin.sensitive_to?(:base_price)}"
95
+ puts "sensitive_to?(:quantity) = #{lin.sensitive_to?(:quantity)}"
96
+ puts "sensitive_to?(:user_tier) = #{lin.sensitive_to?(:user_tier)}"
97
+ puts "sensitive_to?(:unknown_key) = #{lin.sensitive_to?(:unknown_key)}"
98
+ puts
99
+
100
+ puts "--- path_to(:base_price) ---"
101
+ puts lin.path_to(:base_price).inspect
102
+ puts
103
+
104
+ puts "--- path_to(:user_tier) ---"
105
+ puts lin.path_to(:user_tier).inspect
106
+ puts
107
+
108
+ # ── Composition output lineage ────────────────────────────────────────────────
109
+
110
+ puts "--- explain(:discount_amount) ---"
111
+ puts contract.explain(:discount_amount)
112
+ puts
113
+
114
+ # ── Structured lineage (to_h) ─────────────────────────────────────────────────
115
+
116
+ puts "--- lineage(:subtotal).to_h (keys only) ---"
117
+ h = contract.lineage(:subtotal).to_h
118
+ puts "node=#{h[:node]}, kind=#{h[:kind]}, value=#{h[:value]}"
119
+ puts "contributing=#{h[:contributing].keys.inspect}"
120
+ puts
121
+
122
+ puts "done=true"
data/examples/saga.rb ADDED
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Saga Pattern — compensating transactions for Igniter contracts.
4
+ #
5
+ # When a step fails mid-execution, the saga system automatically runs
6
+ # compensating actions for all previously completed steps in reverse order.
7
+ #
8
+ # Run: ruby examples/saga.rb
9
+
10
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
11
+ require "igniter"
12
+ require "igniter/extensions/saga"
13
+ require "igniter/extensions/execution_report"
14
+
15
+ # ── Contracts ─────────────────────────────────────────────────────────────────
16
+
17
+ class OrderWorkflow < Igniter::Contract
18
+ define do
19
+ input :order_id, type: :string
20
+ input :amount, type: :numeric
21
+
22
+ compute :reserve_stock, depends_on: :order_id do |order_id:|
23
+ puts " [forward] reserve_stock for order #{order_id}"
24
+ { reservation_id: "rsv-#{order_id}" }
25
+ end
26
+
27
+ compute :charge_card, depends_on: %i[order_id amount reserve_stock] do |order_id:, amount:, **|
28
+ puts " [forward] charge_card: $#{amount}"
29
+ raise "Card declined: amount $#{amount} exceeds limit" if amount > 500
30
+
31
+ { charge_id: "chg-#{order_id}", amount: amount }
32
+ end
33
+
34
+ compute :send_confirmation, depends_on: %i[reserve_stock charge_card] do |charge_card:, **|
35
+ puts " [forward] send_confirmation for charge #{charge_card[:charge_id]}"
36
+ { sent: true }
37
+ end
38
+
39
+ output :reserve_stock
40
+ output :charge_card
41
+ output :send_confirmation
42
+ end
43
+
44
+ compensate :charge_card do |_inputs:, value:|
45
+ puts " [compensate] refunding charge #{value[:charge_id]}"
46
+ end
47
+
48
+ compensate :reserve_stock do |_inputs:, value:|
49
+ puts " [compensate] releasing reservation #{value[:reservation_id]}"
50
+ end
51
+ end
52
+
53
+ puts "=== Saga Demo ==="
54
+ puts
55
+
56
+ # ── 1. Successful saga ─────────────────────────────────────────────────────────
57
+
58
+ puts "--- 1. Successful execution (amount=$100) ---"
59
+ result_ok = OrderWorkflow.new(order_id: "ord-001", amount: 100.0).resolve_saga
60
+ puts result_ok.explain
61
+ puts
62
+
63
+ # ── 2. Failed saga — compensations triggered ───────────────────────────────────
64
+
65
+ puts "--- 2. Failed execution (amount=$999 — exceeds limit) ---"
66
+ result_fail = OrderWorkflow.new(order_id: "ord-002", amount: 999.0).resolve_saga
67
+ puts result_fail.explain
68
+ puts
69
+
70
+ # ── 3. Result query API ────────────────────────────────────────────────────────
71
+
72
+ puts "--- 3. Result query API ---"
73
+ puts "success? = #{result_fail.success?}"
74
+ puts "failed_node = #{result_fail.failed_node.inspect}"
75
+ puts "error = #{result_fail.error.message}"
76
+ puts "compensations_ran = #{result_fail.compensations.map(&:node_name).inspect}"
77
+ puts "all compensations ok? #{result_fail.compensations.all?(&:success?)}"
78
+ puts
79
+
80
+ # ── 4. Execution report ────────────────────────────────────────────────────────
81
+
82
+ puts "--- 4. execution_report (after failed saga) ---"
83
+ failed_contract = OrderWorkflow.new(order_id: "ord-003", amount: 999.0)
84
+ begin
85
+ failed_contract.resolve_all
86
+ rescue Igniter::Error
87
+ nil
88
+ end
89
+ puts failed_contract.execution_report.explain
90
+ puts
91
+
92
+ # ── 5. Execution report on successful contract ────────────────────────────────
93
+
94
+ puts "--- 5. execution_report (successful) ---"
95
+ ok_contract = OrderWorkflow.new(order_id: "ord-004", amount: 50.0)
96
+ ok_contract.resolve_all
97
+ puts ok_contract.execution_report.explain
98
+ puts
99
+
100
+ # ── 6. to_h / structured output ───────────────────────────────────────────────
101
+
102
+ puts "--- 6. result.to_h ---"
103
+ h = result_fail.to_h
104
+ puts "success: #{h[:success]}"
105
+ puts "failed_node: #{h[:failed_node].inspect}"
106
+ puts "error: #{h[:error]}"
107
+ puts "compensations: #{h[:compensations].map { |c| [c[:node], c[:success]] }.inspect}"
108
+ puts
109
+
110
+ puts "done=true"
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Agent
5
+ # Thread-safe bounded queue for passing Messages between threads.
6
+ #
7
+ # Overflow policies (applied when capacity is reached):
8
+ # :block — block the caller until space is available (default)
9
+ # :drop_oldest — discard the oldest message and enqueue the new one
10
+ # :drop_newest — discard the incoming message silently
11
+ # :error — raise Igniter::Agent::MailboxFullError
12
+ class Mailbox
13
+ DEFAULT_CAPACITY = 256
14
+
15
+ def initialize(capacity: DEFAULT_CAPACITY, overflow: :block)
16
+ @capacity = capacity
17
+ @overflow = overflow
18
+ @queue = []
19
+ @mutex = Mutex.new
20
+ @not_empty = ConditionVariable.new
21
+ @not_full = ConditionVariable.new
22
+ @closed = false
23
+ end
24
+
25
+ # Enqueue a message. Returns self (chainable).
26
+ def push(message) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
27
+ @mutex.synchronize do
28
+ return self if @closed
29
+
30
+ if @queue.size >= @capacity
31
+ case @overflow
32
+ when :block
33
+ @not_full.wait(@mutex) while @queue.size >= @capacity && !@closed
34
+ return self if @closed
35
+ when :drop_oldest
36
+ @queue.shift
37
+ when :drop_newest
38
+ return self
39
+ when :error
40
+ raise MailboxFullError, "Mailbox full (capacity=#{@capacity})"
41
+ end
42
+ end
43
+
44
+ @queue << message
45
+ @not_empty.signal
46
+ end
47
+ self
48
+ end
49
+
50
+ # Dequeue the next message. Blocks until a message arrives or the mailbox
51
+ # is closed. Returns nil if closed and empty, or if +timeout+ expires.
52
+ def pop(timeout: nil) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
53
+ @mutex.synchronize do
54
+ if timeout
55
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
56
+ while @queue.empty? && !@closed
57
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
+ return nil if remaining <= 0
59
+
60
+ @not_empty.wait(@mutex, remaining)
61
+ end
62
+ else
63
+ @not_empty.wait(@mutex) while @queue.empty? && !@closed
64
+ end
65
+
66
+ return nil if @queue.empty?
67
+
68
+ msg = @queue.shift
69
+ @not_full.signal
70
+ msg
71
+ end
72
+ end
73
+
74
+ # Close the mailbox. Blocked callers in +pop+ wake up and return nil.
75
+ def close
76
+ @mutex.synchronize do
77
+ @closed = true
78
+ @not_empty.broadcast
79
+ @not_full.broadcast
80
+ end
81
+ end
82
+
83
+ def closed?
84
+ @mutex.synchronize { @closed }
85
+ end
86
+
87
+ def size
88
+ @mutex.synchronize { @queue.size }
89
+ end
90
+
91
+ def empty?
92
+ @mutex.synchronize { @queue.empty? }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Agent
5
+ # Immutable message passed between agents through a Mailbox.
6
+ #
7
+ # type — Symbol identifying the message kind (e.g. :increment, :get)
8
+ # payload — frozen Hash of message data (default: {})
9
+ # reply_to — one-shot Mailbox for sync request-reply (nil for async)
10
+ class Message
11
+ attr_reader :type, :payload, :reply_to
12
+
13
+ def initialize(type:, payload: {}, reply_to: nil)
14
+ @type = type.to_sym
15
+ @payload = (payload || {}).freeze
16
+ @reply_to = reply_to
17
+ freeze
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Agent
5
+ # External handle to a running agent.
6
+ #
7
+ # Callers interact with agents exclusively through Ref — they never touch
8
+ # the Mailbox, StateHolder, or Thread directly. This allows the Supervisor
9
+ # to swap out internals on restart without invalidating existing Ref objects.
10
+ class Ref
11
+ def initialize(thread:, mailbox:, state_holder:)
12
+ @thread = thread
13
+ @mailbox = mailbox
14
+ @state_holder = state_holder
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Asynchronous fire-and-forget. Returns self.
19
+ def send(type, payload = {})
20
+ mailbox.push(Message.new(type: type.to_sym, payload: payload))
21
+ self
22
+ end
23
+
24
+ # Synchronous request-reply. Blocks until the handler responds or timeout
25
+ # elapses. Raises Igniter::Agent::TimeoutError on timeout.
26
+ def call(type, payload = {}, timeout: 5)
27
+ reply_box = Mailbox.new(capacity: 1, overflow: :drop_newest)
28
+ mailbox.push(Message.new(type: type.to_sym, payload: payload, reply_to: reply_box))
29
+ reply = reply_box.pop(timeout: timeout)
30
+ raise TimeoutError, "Agent did not reply within #{timeout}s" unless reply
31
+
32
+ reply.payload[:value]
33
+ end
34
+
35
+ # Request graceful shutdown. Closes the mailbox so the runner exits after
36
+ # processing any in-flight message. Blocks until the thread finishes.
37
+ def stop(timeout: 5)
38
+ mailbox.close
39
+ thread&.join(timeout)
40
+ self
41
+ end
42
+
43
+ # Forcefully terminate the agent thread.
44
+ def kill
45
+ thread&.kill
46
+ mailbox.close
47
+ self
48
+ end
49
+
50
+ def alive?
51
+ thread&.alive? || false
52
+ end
53
+
54
+ # Read the current state snapshot without blocking the agent.
55
+ def state
56
+ state_holder.get
57
+ end
58
+
59
+ # Supervisor-internal: swap out internals when the agent restarts.
60
+ # Callers keep the same Ref object; the new thread/mailbox/state_holder
61
+ # are transparently injected.
62
+ def rebind(thread:, mailbox:, state_holder:)
63
+ @mutex.synchronize do
64
+ @thread = thread
65
+ @mailbox = mailbox
66
+ @state_holder = state_holder
67
+ end
68
+ self
69
+ end
70
+
71
+ private
72
+
73
+ def thread
74
+ @mutex.synchronize { @thread }
75
+ end
76
+
77
+ def mailbox
78
+ @mutex.synchronize { @mailbox }
79
+ end
80
+
81
+ def state_holder
82
+ @mutex.synchronize { @state_holder }
83
+ end
84
+ end
85
+ end
86
+ end