phronomy 0.16.0 → 0.18.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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -2,219 +2,36 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # A thread-safe FIFO queue for passing values between concurrent tasks.
5
+ # Thread-safe FIFO queue used at explicit thread boundaries.
6
6
  #
7
- # Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
8
- # standard-library type directly. A future implementation may replace the
9
- # backing primitive without changing call sites.
10
- #
11
- # @example Producer / consumer
12
- # queue = Phronomy::Concurrency::AsyncQueue.new
13
- # Runtime.instance.spawn { queue.push(expensive_io()) }
14
- # value = queue.pop # blocks until the producer pushes
15
- # @api private
7
+ # It intentionally contains no scheduler/Fiber semantics. Cooperative
8
+ # application execution is represented by FSMSession state and EventLoop events.
16
9
  class AsyncQueue
17
- # @param max_size [Integer, nil] optional upper bound on queue depth.
18
- # When set, {#push} blocks the caller until a slot is available.
19
- # @api private
20
10
  def initialize(max_size: nil)
21
11
  @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
22
- @max_size = max_size
23
- @waiter_mutex = Mutex.new
24
- @cross_thread_waiter = nil # [fiber, scheduler] set by _pop_cooperative; consumed by push
25
- @cross_thread_scheduler = nil # set by expect_cross_thread_push
26
12
  end
27
13
 
28
- # Enqueues +item+.
29
- # In a cooperative scheduler context with a bounded queue (max_size:), suspends
30
- # the current Fiber via a scheduler signal when the queue is full rather than
31
- # blocking the OS thread. Without a scheduler, falls back to the standard
32
- # SizedQueue blocking behaviour.
33
- # @param item [Object] value to enqueue
34
- # @return [self]
35
- # @api private
36
14
  def push(item)
37
- scheduler = Phronomy::Runtime::Scheduler.current
38
- if scheduler && @max_size
39
- _push_cooperative(scheduler, item)
40
- else
41
- @queue.push(item)
42
- scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
43
- # Wake a cross-thread waiter if one is registered.
44
- # Handles the case where a DeterministicScheduler Fiber is suspended
45
- # in _pop_cooperative waiting for a push from a non-scheduler thread
46
- # (e.g. EventLoop thread where Scheduler.current is nil).
47
- # enqueue_fiber is thread-safe; complete_blocking_await decrements
48
- # @pending_awaits so run_until_idle can eventually exit.
49
- if @cross_thread_scheduler
50
- waiter = @waiter_mutex.synchronize do
51
- w = @cross_thread_waiter
52
- @cross_thread_waiter = nil
53
- w
54
- end
55
- if waiter
56
- fiber, sched = waiter
57
- sched.complete_blocking_await
58
- sched.enqueue_fiber(-> { fiber.resume })
59
- end
60
- end
61
- end
15
+ @queue.push(item)
62
16
  self
63
17
  end
64
18
 
65
- # Dequeues and returns the next item.
66
- # In a cooperative scheduler context, suspends the current Fiber (yielding
67
- # control back to the scheduler) rather than blocking the OS thread.
68
- #
69
- # When +timeout+ is given the semantics depend on the active backend:
70
- #
71
- # * **Thread backend** (`:thread`) — uses real wall-clock time via
72
- # +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
73
- # Returns +nil+ if no item arrives within the specified number of real seconds.
74
- # * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
75
- # *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
76
- # the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
77
- # In tests this means the timeout is fully deterministic and does not depend on
78
- # actual elapsed wall time. However, in production `:fiber` mode the timeout
79
- # may never expire unless the scheduler explicitly advances virtual time.
80
- #
81
- # @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
82
- # in production workloads is not guaranteed and may differ from wall-clock
83
- # expectations.
84
- # @note **Cooperative timeout limitation**: on the cooperative path, the
85
- # deadline is re-checked *after* a wake-up signal arrives. If virtual time
86
- # has already passed the deadline when the consumer is woken by a producer
87
- # push, the consumer returns +nil+ rather than the pushed item. Without any
88
- # wake-up signal the waiting Fiber remains suspended even after
89
- # +scheduler.advance+ — the timeout does not self-fire.
90
- # @param timeout [Numeric, nil] seconds to wait before returning +nil+.
91
- # Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
92
- # @return [Object, nil] the next item, or +nil+ when timeout expires
93
- # @api private
94
19
  def pop(timeout: nil)
95
- scheduler = Phronomy::Runtime::Scheduler.current
96
- if scheduler
97
- _pop_cooperative(scheduler, timeout: timeout)
98
- elsif timeout
99
- @queue.pop(timeout: timeout)
100
- else
101
- @queue.pop
102
- end
20
+ timeout ? @queue.pop(timeout: timeout) : @queue.pop
103
21
  end
104
22
 
105
- # Returns the current number of items in the queue.
106
- # @return [Integer]
107
- # @api private
108
23
  def size
109
24
  @queue.size
110
25
  end
111
26
 
112
- # Returns +true+ when the queue contains no items.
113
- # @return [Boolean]
114
- # @api private
115
27
  def empty?
116
28
  @queue.empty?
117
29
  end
118
30
 
119
- # Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
120
- # @return [self]
121
- # @api private
122
31
  def close
123
32
  @queue.close
124
33
  self
125
34
  end
126
-
127
- # Marks this queue as expecting pushes from a non-scheduler OS thread.
128
- # When set, {#pop} in cooperative mode uses +track_blocking_await+ so that
129
- # {Runtime::DeterministicScheduler#run_until_idle} does not exit while
130
- # waiting for the cross-thread push. Called by {EventLoop#register} when
131
- # a cooperative scheduler is active on the calling thread.
132
- # @param scheduler [Runtime::Scheduler]
133
- # @return [self]
134
- # @api private
135
- def expect_cross_thread_push(scheduler)
136
- @cross_thread_scheduler = scheduler
137
- self
138
- end
139
-
140
- private
141
-
142
- # Cooperative pop for DeterministicScheduler context.
143
- # Suspends the current Fiber via the scheduler's signal mechanism rather than
144
- # blocking the OS thread.
145
- #
146
- # Two suspension paths:
147
- # * **Same-scheduler** (default): uses {CoopSignal} — the producer is another
148
- # Fiber on the same DeterministicScheduler. run_until_idle is allowed to
149
- # exit; the producer's push will enqueue the consumer Fiber.
150
- # * **Cross-thread** ({#expect_cross_thread_push} was called): uses
151
- # +track_blocking_await+ so that run_until_idle does not exit while waiting
152
- # for a push from a non-scheduler OS thread (e.g. EventLoop). The push
153
- # side calls +complete_blocking_await+ + +enqueue_fiber+ to resume.
154
- #
155
- # The empty?/register pair for the cross-thread path is wrapped in
156
- # @waiter_mutex to eliminate the race between the empty check and the
157
- # registration of the waker.
158
- # @api private
159
- # @param scheduler [Runtime::Scheduler]
160
- # @param timeout [Numeric, nil]
161
- # @return [Object, nil]
162
- def _pop_cooperative(scheduler, timeout:)
163
- @coop_signal ||= scheduler.new_signal
164
- deadline = timeout ? (scheduler.virtual_time + timeout) : nil
165
-
166
- loop do
167
- unless @queue.empty?
168
- item = @queue.pop(timeout: 0)
169
- # Notify a push-waiter (bounded queue) that a slot opened up.
170
- scheduler.raise_signal(@push_signal) if @push_signal
171
- return item
172
- end
173
- return nil if deadline && scheduler.virtual_time >= deadline
174
-
175
- if @cross_thread_scheduler
176
- # Cross-thread path: atomically check the queue and register a waker
177
- # so that a concurrent push cannot slip between the empty? check above
178
- # and the registration below.
179
- will_yield = false
180
- @waiter_mutex.synchronize do
181
- if @queue.empty?
182
- @cross_thread_waiter = [Fiber.current, scheduler]
183
- scheduler.track_blocking_await
184
- will_yield = true
185
- end
186
- # else: push arrived between the loop's empty? check and here;
187
- # will_yield stays false and the next loop iteration dequeues it.
188
- end
189
- Fiber.yield(:cooperative_suspend) if will_yield
190
- else
191
- scheduler.wait_for_signal(@coop_signal)
192
- end
193
-
194
- return nil if deadline && scheduler.virtual_time >= deadline
195
- end
196
- end
197
-
198
- # Cooperative push for DeterministicScheduler context with a bounded queue.
199
- # Suspends the current Fiber via a scheduler signal when the queue is full,
200
- # rather than blocking the OS thread.
201
- # @api private
202
- # @param scheduler [Runtime::Scheduler]
203
- # @param item [Object]
204
- # @return [void]
205
- def _push_cooperative(scheduler, item)
206
- @push_signal ||= scheduler.new_signal
207
-
208
- loop do
209
- unless @queue.size >= @max_size
210
- @queue.push(item)
211
- # Notify any pop-waiter that an item is now available.
212
- scheduler.raise_signal(@coop_signal) if @coop_signal
213
- return
214
- end
215
- scheduler.wait_for_signal(@push_signal)
216
- end
217
- end
218
35
  end
219
36
  end
220
37
  end
@@ -6,7 +6,7 @@ module Phronomy
6
6
  # optionally a {Deadline}.
7
7
  #
8
8
  # +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
9
- # tool code. All work performed within a scope should observe the scope's
9
+ # tool code. All work performed within a scope should observe the scope's
10
10
  # token; when the scope is cancelled (explicitly or by deadline expiry) the
11
11
  # token is cancelled and all child tasks that check it will stop.
12
12
  #
@@ -18,10 +18,9 @@ module Phronomy
18
18
  #
19
19
  # @example Explicit cancellation
20
20
  # scope = Phronomy::Concurrency::CancellationScope.new
21
- # Phronomy::Runtime.instance.spawn(name: "worker") do
22
- # scope.token.raise_if_cancelled!
23
- # # ... do work ...
24
- # end
21
+ # operation = Phronomy::Runtime.instance.offload.submit(
22
+ # cancellation_token: scope.token
23
+ # ) { synchronous_operation }
25
24
  # scope.cancel! if some_condition
26
25
  class CancellationScope
27
26
  # @return [CancellationToken] the token owned by this scope
@@ -33,7 +32,7 @@ module Phronomy
33
32
  # @param parent_token [CancellationToken, nil] when provided, cancellation of
34
33
  # the parent token is propagated to this scope's token via a callback
35
34
  # (for explicit cancel) and/or the Runtime timer queue (for monotonic
36
- # deadline expiry). No polling thread is spawned.
35
+ # deadline expiry). No polling thread is spawned.
37
36
  # @api private
38
37
  def initialize(parent_token: nil)
39
38
  @token = Phronomy::Concurrency::CancellationToken.new
@@ -92,7 +91,7 @@ module Phronomy
92
91
  end
93
92
 
94
93
  # Pops from +queue+ with a timeout derived from the attached deadline (or
95
- # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
94
+ # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
96
95
  # the scope is cancelled and the block is called (or a {TimeoutError} raised).
97
96
  #
98
97
  # @param queue [Phronomy::Concurrency::AsyncQueue] the queue to pop from
@@ -2,91 +2,46 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # Provides cooperative cancellation for agent invocations.
6
- #
7
- # Pass a token to an agent via +config: { cancellation_token: token }+.
8
- # The agent checks the token before each LLM call and raises
9
- # {Phronomy::CancellationError} when the token is cancelled or the
10
- # optional deadline has passed.
11
- #
12
- # A token may be shared across multiple agent invocations and across threads;
13
- # all access to internal state is protected by a Mutex.
14
- #
15
- # @example Explicit cancel from another thread
16
- # token = Phronomy::Concurrency::CancellationToken.new
17
- # Thread.new { sleep 5; token.cancel! }
18
- # result = agent.invoke("...", config: { cancellation_token: token })
19
- #
20
- # @example Hard deadline via monotonic clock (recommended)
21
- # token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
22
- # result = agent.invoke("...", config: { cancellation_token: token })
23
- #
24
- # @example Hard deadline via wall-clock (legacy)
25
- # token = Phronomy::Concurrency::CancellationToken.new(deadline: Time.now + 30)
26
- # result = agent.invoke("...", config: { cancellation_token: token })
27
- #
28
- # @example Propagate to parallel workers
29
- # token = Phronomy::Concurrency::CancellationToken.new
30
- # orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
5
+ # Cooperative cancellation token for Agent/Tool work.
31
6
  class CancellationToken
32
- # Returns a new token that will expire after +seconds+ seconds, measured
33
- # with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
34
- # a token with +deadline: Time.now + seconds+, this factory is immune to NTP
35
- # adjustments and DST transitions.
36
- #
37
- # @param seconds [Numeric] duration in seconds until the token expires.
38
- # @return [CancellationToken]
7
+ # Creates a token that expires after +seconds+ measured with the monotonic clock.
39
8
  # @api public
40
9
  def self.timeout_after(seconds)
41
10
  monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
42
11
  new(monotonic_deadline: monotonic_deadline)
43
12
  end
44
13
 
45
- # @param deadline [Time, nil] optional wall-clock deadline; the token reports
46
- # +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
47
- # {.timeout_after} for duration-based cancellation.
48
- # @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
49
- # {.timeout_after}; prefer that factory method over passing this directly.
14
+ # @param monotonic_deadline [Float, nil] internal monotonic timestamp.
50
15
  # @api public
51
- # mutant:disable - removing @cancelled = false is equivalent because nil is falsey
52
- def initialize(deadline: nil, monotonic_deadline: nil)
16
+ def initialize(monotonic_deadline: nil)
53
17
  @cancelled = false
54
- @deadline = deadline
55
18
  @monotonic_deadline = monotonic_deadline
56
19
  @mutex = Mutex.new
57
20
  @cancel_callbacks = []
58
21
  end
59
22
 
60
- # @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
61
- attr_reader :deadline
62
-
63
- # Returns the remaining seconds until the monotonic deadline fires, or +nil+
64
- # when no monotonic deadline is set. Returns 0.0 if already past.
65
- # @return [Float, nil]
66
23
  # @api public
67
24
  def remaining_monotonic_seconds
68
25
  return nil if @monotonic_deadline.nil?
26
+
69
27
  remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
28
  [remaining, 0.0].max
71
29
  end
72
30
 
73
- # Registers a one-shot callback invoked when this token is explicitly
74
- # cancelled via {#cancel!}. If the token is already cancelled, the block
75
- # is called immediately (still within the caller's thread).
31
+ # Registers a callback for explicit cancellation.
76
32
  #
77
- # Callbacks are NOT fired for deadline-based cancellation (i.e. when
78
- # {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
79
- # {Phronomy::Concurrency::CancellationScope#deadline_in}, which registers
80
- # a timer via {Runtime#timer_queue} and calls {#cancel!} on expiry — this
81
- # fires all +on_cancel+ callbacks automatically. {.timeout_after} is a
82
- # lightweight alternative that uses lazy clock comparison only and does
83
- # NOT trigger callbacks on expiry.
33
+ # Deadline expiry by itself only changes {#cancelled?}. Components that need
34
+ # callback delivery for a monotonic deadline must promote that deadline to
35
+ # +cancel!+ through the Runtime timer queue.
36
+ #
37
+ # Cancellation callbacks are independent notifications. Failure of one
38
+ # callback is logged and does not suppress delivery to other callbacks.
84
39
  #
85
- # @yield called with no arguments when (or if) the token is cancelled
86
40
  # @return [self]
87
41
  # @api public
88
- # mutant:disable - mutex removal mutation is GVL-safe equivalent under MRI
89
42
  def on_cancel(&block)
43
+ raise ArgumentError, "on_cancel requires a block" unless block
44
+
90
45
  already_cancelled = @mutex.synchronize do
91
46
  if @cancelled
92
47
  true
@@ -95,50 +50,67 @@ module Phronomy
95
50
  false
96
51
  end
97
52
  end
98
- block.call if already_cancelled
53
+ deliver_cancel_callback(block) if already_cancelled
99
54
  self
100
55
  end
101
56
 
102
- # Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
103
- # Thread-safe; idempotent calling multiple times has no additional effect.
57
+ # Explicitly cancels the token and invokes each currently registered callback
58
+ # once. The callback registry is cleared before callbacks run so completed
59
+ # registrations are not retained for the lifetime of a long-lived token.
60
+ #
61
+ # Cancellation callbacks are isolated from one another: a StandardError raised
62
+ # by one callback is logged and the remaining callbacks are still delivered.
63
+ #
104
64
  # @return [self]
105
65
  # @api public
106
- # mutant:disable - mutex removal and dup-vs-ref mutations are GVL-safe equivalents
107
66
  def cancel!
108
67
  callbacks = @mutex.synchronize do
109
68
  return self if @cancelled
69
+
110
70
  @cancelled = true
111
- @cancel_callbacks.dup
71
+ callbacks = @cancel_callbacks
72
+ @cancel_callbacks = []
73
+ callbacks
112
74
  end
113
- callbacks.each(&:call)
75
+ callbacks.each { |callback| deliver_cancel_callback(callback) }
114
76
  self
115
77
  end
116
78
 
117
- # Returns +true+ when the token has been explicitly cancelled via {#cancel!},
118
- # when the wall-clock deadline has passed, or when the monotonic deadline
119
- # (set by {.timeout_after}) has elapsed. Thread-safe.
120
- # @return [Boolean]
121
79
  # @api public
122
- # mutant:disable - mutex removal on @cancelled read is GVL-safe equivalent under MRI
123
80
  def cancelled?
124
81
  return true if @mutex.synchronize { @cancelled }
125
- return true if !@deadline.nil? && Time.now >= @deadline
82
+
126
83
  !@monotonic_deadline.nil? &&
127
84
  Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
128
85
  end
129
86
 
130
- # Raises {Phronomy::CancellationError} if the token is cancelled.
131
- # A convenience method for cooperative cancellation checks inside tools,
132
- # RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
133
- #
134
- # @param message [String] optional error message
135
- # @return [nil] when the token is not cancelled
136
- # @raise [Phronomy::CancellationError] when the token is cancelled
137
87
  # @api public
138
- # mutant:disable - raise(CancellationError) resolves to raise(Phronomy::CancellationError) in this namespace
139
88
  def raise_if_cancelled!(message = "invocation cancelled")
140
89
  raise Phronomy::CancellationError, message if cancelled?
141
90
  end
91
+
92
+ private
93
+
94
+ def deliver_cancel_callback(callback)
95
+ callback.call
96
+ rescue => error
97
+ Phronomy.configuration.logger&.error do
98
+ "[CancellationToken] on_cancel callback raised #{error.class}: #{error.message}"
99
+ end
100
+ end
101
+
102
+ # Removes an explicit-cancellation callback that is no longer needed.
103
+ #
104
+ # This exists so framework operation registrations do not keep their captured
105
+ # state alive when the operation completes before the token is cancelled. It is
106
+ # intentionally not part of the public cancellation API.
107
+ #
108
+ # A concurrent +cancel!+ may already have taken the callback for delivery;
109
+ # callers must therefore make their callback idempotent.
110
+ def unregister_cancel_callback(callback)
111
+ @mutex.synchronize { @cancel_callbacks.delete(callback) }
112
+ self
113
+ end
142
114
  end
143
115
  end
144
116
  end
@@ -44,9 +44,8 @@ module Phronomy
44
44
  end
45
45
 
46
46
  # Attaches this deadline to a {CancellationToken} by cancelling the token
47
- # when the deadline expires. Uses the Runtime timer queue (a single
48
- # background thread shared by all deadlines) instead of spawning one thread
49
- # per deadline.
47
+ # when the deadline expires. The Runtime timer queue is driven by EventLoop;
48
+ # no timer-specific OS thread is created.
50
49
  #
51
50
  # @param token [CancellationToken]
52
51
  # @param timer_queue [Runtime::TimerQueue, nil] queue to register with;