phronomy 0.20.0 → 0.22.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.
@@ -1,15 +1,14 @@
1
1
  # Runtime and concurrency
2
2
 
3
3
  Phronomy uses an **EventLoop / FSMSession first** architecture for framework
4
- lifecycle coordination. `FSMSession` is the framework finite-state-machine session
5
- used to represent explicit lifecycle state and events. A `Task` is a completion
6
- handle, not an execution backend. Synchronous work that must stay off EventLoop is isolated in the bounded
7
- `OffloadPool`.
4
+ lifecycle coordination. `FSMSession` represents explicit lifecycle state and
5
+ events. `Phronomy::Task` is the common caller-facing completion handle, not an
6
+ execution backend. Synchronous work that must stay off EventLoop is isolated in
7
+ the bounded `OffloadPool`.
8
8
 
9
- For the design rationale, see Architecture Decision Record (ADR)
10
- [ADR-010: EventLoop / FSMSession First Concurrency](decisions/010-cooperative-first-concurrency.md).
9
+ For the design rationale, see [ADR-010](decisions/010-cooperative-first-concurrency.md).
11
10
  Durable-state ownership is defined by
12
- [ADR-014: Unified Persistence and Durable-State Ownership](decisions/014-unified-persistence-durable-state.md).
11
+ [ADR-014](decisions/014-unified-persistence-durable-state.md).
13
12
 
14
13
  ## Runtime model
15
14
 
@@ -23,17 +22,21 @@ Runtime
23
22
  │ └─ MultiAgent fan-out
24
23
  ├─ process-local Agent ActivationRegistry
25
24
  ├─ OffloadPool (bounded operating-system Threads)
25
+ │ ├─ private Operation records
26
26
  │ ├─ blocking input/output (I/O)
27
27
  │ ├─ central-processing-unit (CPU)-bound synchronous work
28
28
  │ └─ other long synchronous work
29
29
  ├─ named OffloadPools
30
30
  └─ EventLoop-driven timers
31
31
 
32
- Task = completion handle
32
+ EventLoop / FSMSession ─┐
33
+ ├─> Task = completion handle
34
+ OffloadPool ────────────┘
33
35
  ```
34
36
 
35
- The framework does not allocate one operating-system Thread per logical Agent/Workflow/Tool
36
- lifecycle. Logical waits remain explicit states plus later EventLoop events.
37
+ The framework does not allocate one operating-system Thread per logical
38
+ Agent/Workflow/Tool lifecycle. Logical waits remain explicit states plus later
39
+ EventLoop events.
37
40
 
38
41
  ## Live state and durable state
39
42
 
@@ -49,8 +52,8 @@ external writer that advances the durable base causes `Persistence::ConflictErro
49
52
  rather than automatic reload or merge.
50
53
 
51
54
  For Workflows, the current `WorkflowContext` and FSMSession own the active
52
- logical state. A durable Workflow hydrates once at invocation/resume and saves
53
- at the halted/terminal boundary.
55
+ logical state. A durable Workflow hydrates once at invocation/resume and saves at
56
+ the halted/terminal boundary.
54
57
 
55
58
  Content-addressed `Persistence#contents` values are immutable. Fetching a known
56
59
  content reference is value materialization rather than mutable state refresh.
@@ -61,56 +64,44 @@ Workflow execution keeps three identities separate:
61
64
 
62
65
  ```text
63
66
  session_id
64
- application session/correlation identity, for example a Rails session
67
+ application session/correlation identity
65
68
 
66
69
  thread_id
67
70
  durable Workflow identity and Persistence#workflow_states key
68
71
 
69
72
  fsm_session_id
70
- one Runtime FSMSession execution identity; generated again for each
71
- invoke/resume operation
73
+ one Runtime FSMSession execution identity; generated again for each invoke/resume
72
74
  ```
73
75
 
74
- The existing application `session_id` is tracing/caller metadata and is not used
75
- for durable Workflow ownership. EventLoop registers active FSMs by
76
- `fsm_session_id`; durable Workflow admission is a separate owner map:
76
+ The application `session_id` is tracing/caller metadata and is not used for
77
+ durable Workflow ownership. EventLoop registers active FSMs by `fsm_session_id`;
78
+ durable Workflow admission is a separate owner map:
77
79
 
78
80
  ```text
79
81
  thread_id -> owner_fsm_session_id
80
82
  ```
81
83
 
82
84
  The owner is acquired before `workflow_states.load(thread_id)` and remains held
83
- until the halted/terminal `workflow_states.save(...)` completes. Only the current
84
- owner may release the admission. `fsm_session_id` is Runtime-only metadata and is
85
- not stored in Workflow fields or durable snapshots.
86
-
87
- The admission map belongs to one Runtime and is process-local. It prevents two
88
- executions with the same durable `thread_id` from being admitted concurrently
89
- inside that Runtime, but it is not shared across Ruby processes, containers, or
90
- service replicas. Separate processes may therefore execute the same `thread_id`
91
- concurrently unless the application adds distributed coordination.
92
-
93
- `workflow_states` optimistic revisions detect stale terminal commits across those
94
- processes. They do not prevent duplicate execution from starting and cannot undo
95
- external side effects that both executions already performed before one save
96
- loses the revision race. CAS is stale/double-commit detection, not a distributed
97
- execution lock or duplicate-side-effect prevention mechanism.
85
+ until the halted/terminal `workflow_states.save(...)` completes. The admission map
86
+ is process-local. Cross-process duplicate execution requires application-level
87
+ distributed coordination; optimistic revisions detect stale terminal commits but
88
+ do not prevent duplicate side effects before that conflict is detected.
98
89
 
99
90
  ## Tool execution modes
100
91
 
101
92
  Phronomy exposes two execution modes for capabilities:
102
93
 
103
- - `:cooperative` — short EventLoop-safe work, or a specialized asynchronous Tool
104
- that starts another Phronomy lifecycle and returns immediately.
94
+ - `:cooperative` — short EventLoop-safe work, or specialized asynchronous work
95
+ that starts another Phronomy lifecycle and returns a Task immediately;
105
96
  - `:offloaded` — synchronous work that must not run to completion on EventLoop.
106
97
 
98
+ Both paths return `Phronomy::Task`. The execution mechanism differs; the
99
+ completion abstraction does not.
100
+
107
101
  Phronomy does not classify application work into framework-level I/O/CPU/process
108
- execution modes. That workload classification and capacity planning belong to the
102
+ execution modes. Workload classification and capacity planning belong to the
109
103
  application.
110
104
 
111
- A CPU-heavy operation may therefore use `:offloaded`, but thread offload does not
112
- remove CRuby Global VM Lock contention or physical CPU contention.
113
-
114
105
  ## Logical waiting versus offload
115
106
 
116
107
  Do not offload a logical wait merely to make it asynchronous.
@@ -129,7 +120,7 @@ Correct shape:
129
120
  parent FSMSession
130
121
  → start child lifecycle
131
122
  → return immediately
132
- → child settles
123
+ → child Task settles
133
124
  → post parent EventLoop event
134
125
  ```
135
126
 
@@ -141,22 +132,13 @@ waiting at the same time.
141
132
  `Persistence` repositories expose synchronous operations. Framework lifecycle
142
133
  code must not perform potentially blocking durable reads/writes on EventLoop.
143
134
  Agent preparation/commit and Workflow hydrate/save operations are submitted to
144
- `OffloadPool`; completion continues through completion callbacks or explicit
145
- EventLoop events.
135
+ `OffloadPool`; completion continues through Task callbacks or explicit EventLoop
136
+ events.
146
137
 
147
138
  A durable barrier may pause one logical lifecycle without blocking EventLoop.
148
- The next Agent provider call does not start until the corresponding Manifest and
149
- logical execution snapshot commit succeeds. A persistence failure or optimistic
150
- conflict fails that step rather than continuing with stale state.
151
-
152
- Approval wait is not a hydration boundary. The same live Agent instance,
153
- Activation, and AgentInvocation remain the owner and are resumed after approval.
154
- Approval itself remains an Agent-instance operation. An application that only has
155
- an `execution_id` first resolves the current process's owner with
156
- `Phronomy::Agent::Base.live_for_execution(execution_id)` or the expected concrete
157
- Agent class, then calls `agent.approve(...)` or `agent.approve_async(...)`.
158
- `live_for_execution` consults the Runtime-local ActivationRegistry and does not
159
- load a replacement Agent from Persistence.
139
+ Persistence does not implement async repository variants and must not depend on
140
+ EventLoop, FSMSession, Task settlement internals, or private OffloadPool operation
141
+ records.
160
142
 
161
143
  ## Sync versus async application APIs
162
144
 
@@ -172,23 +154,37 @@ load a replacement Agent from Persistence.
172
154
 
173
155
  Blocking synchronous APIs reject EventLoop re-entry with
174
156
  `Phronomy::EventLoopReentrancyError` when waiting would stall the same EventLoop
175
- needed for progress. `live_for_execution` itself only performs a Runtime-local
176
- registry lookup and does not wait for Task progress.
157
+ needed for progress.
177
158
 
178
159
  ## Task
179
160
 
180
161
  `Phronomy::Task` is thread-free. It represents one terminal result:
181
162
 
182
- - completed value,
183
- - failure,
163
+ - completed value;
164
+ - failure;
184
165
  - cancellation.
185
166
 
186
- `Task#wait_result(timeout:)` is a bridge for external synchronous callers. It is
187
- not the framework continuation mechanism.
167
+ Task is the common completion abstraction for logical EventLoop/FSMSession
168
+ lifecycles and OffloadPool-backed synchronous work.
169
+
170
+ `Task#wait_result(timeout:)` is a bridge for external synchronous callers. Its
171
+ timeout is waiter-local: it does not settle/cancel the Task or alter OffloadPool
172
+ abandonment state.
173
+
174
+ `Task#on_complete` registers an independent notification callback. Callback
175
+ execution thread is not guaranteed. A callback may be delivered by an OffloadPool
176
+ worker, a timer/cancellation caller, an EventLoop-related control path, or the
177
+ thread that registers after settlement. Callbacks must be thread-safe and should
178
+ complete quickly.
188
179
 
189
180
  `Task#map` is application-level composition. A transformation exception settles
190
- the mapped Task as failed. This is different from independent notification
191
- callbacks, described below.
181
+ the mapped Task as failed.
182
+
183
+ Framework components own Task settlement. Application code should not use
184
+ `Task#complete`, `Task#fail`, or `Task#cancel!` as operation-control APIs. Request
185
+ operation-wide cancellation through the `CancellationToken` accepted by the API
186
+ that created the Task. Task settlement never propagates backwards to cancel a
187
+ shared CancellationToken.
192
188
 
193
189
  ## OffloadPool
194
190
 
@@ -197,131 +193,98 @@ on EventLoop.
197
193
 
198
194
  Its guarantees include:
199
195
 
200
- - bounded worker count,
201
- - bounded queue depth,
202
- - queue backpressure,
203
- - operation-wide submit timeout/cancellation settlement,
204
- - abandoned-worker accounting,
205
- - runtime metrics,
196
+ - bounded worker count;
197
+ - bounded queue depth;
198
+ - queue backpressure;
199
+ - operation-wide submit timeout/cancellation settlement;
200
+ - abandoned-worker accounting;
201
+ - runtime metrics;
206
202
  - shutdown/drain behavior.
207
203
 
208
- It does not guarantee CPU/I/O fairness or CPU isolation. Applications that need
209
- resource isolation can create named Runtime pools.
204
+ `OffloadPool#submit` returns a `Phronomy::Task`. OffloadPool does not expose its
205
+ execution record as a caller-facing future/promise. Its private `Operation` owns:
206
+
207
+ - the submitted block;
208
+ - queue submission/start timing;
209
+ - worker-start linearization;
210
+ - submit timeout/cancellation flags;
211
+ - abandoned state;
212
+ - cancellation callback lifecycle;
213
+ - metrics state needed by the pool.
214
+
215
+ This separation keeps execution details private while allowing every asynchronous
216
+ Phronomy API to expose the same Task completion contract.
210
217
 
211
218
  ### EventLoop queue admission
212
219
 
213
220
  Framework-owned EventLoop-origin submissions must not wait for a free worker
214
221
  queue slot. They use non-blocking admission (`on_full: :raise`) and route
215
- `BackpressureError` through the ordinary FSM/completion path.
222
+ `BackpressureError` through the ordinary FSM/Task completion path.
216
223
 
217
224
  External management threads may choose a blocking admission policy when blocking
218
225
  the caller is acceptable.
219
226
 
220
- ## PendingOperation and blocking_wait
221
-
222
- `OffloadPool#submit` returns a private `PendingOperation` immediately after queue
223
- admission.
224
-
225
- `PendingOperation#blocking_wait(timeout:)` is intentionally a **low-level
226
- synchronous bridge** for non-EventLoop callers such as tests and diagnostics.
227
- The timeout belongs only to that waiter:
228
-
229
- - it raises `TimeoutError` to that calling thread,
230
- - it does not settle the PendingOperation,
231
- - it does not cancel the submitted operation,
232
- - it does not mark the operation abandoned.
233
-
234
- There is no waiter-local `cancellation_token:` argument. Operation-wide
235
- cancellation belongs exclusively to `OffloadPool#submit(cancellation_token:)`.
236
-
237
227
  ## Submit timeout and cancellation
238
228
 
239
- Submit-time timeout and submit cancellation settle the caller-facing operation.
240
- They do **not** asynchronously interrupt an already-running synchronous worker.
229
+ Submit-time timeout and submit cancellation settle the caller-facing Task. They
230
+ do **not** asynchronously interrupt an already-running synchronous worker.
241
231
 
242
232
  ### Before worker start
243
233
 
244
234
  If timeout/cancellation wins before execution starts:
245
235
 
246
- - the PendingOperation settles,
247
- - the submitted block does not run,
248
- - the operation is not counted as abandoned.
236
+ - the Task settles (`TimeoutError` failure or cancellation);
237
+ - the submitted block does not run;
238
+ - the private Operation is not counted as abandoned.
249
239
 
250
240
  ### After worker start
251
241
 
252
242
  If timeout/cancellation wins after execution starts:
253
243
 
254
- - the PendingOperation settles immediately,
255
- - the operation is marked abandoned,
256
- - the worker is allowed to continue until its synchronous call returns,
244
+ - the Task settles immediately;
245
+ - the private Operation is marked abandoned;
246
+ - the worker continues until its synchronous call returns;
257
247
  - the eventual worker result is discarded.
258
248
 
259
249
  Phronomy does not use `Thread#raise` to inject an exception into the worker.
260
250
  Application/library code that needs hard or transport-level deadlines should use
261
- its native timeout or, in the future, an appropriate process-isolation mechanism.
251
+ its native timeout or an appropriate future process-isolation mechanism.
262
252
 
263
253
  ### CancellationToken deadlines
264
254
 
265
- `CancellationToken.timeout_after(seconds)` uses a monotonic deadline.
266
- `cancelled?` becomes true after that deadline, but the token itself does not own a
267
- Thread.
268
-
269
- Components requiring callback delivery for a monotonic deadline must promote the
270
- deadline to explicit `cancel!` through the Runtime timer queue. OffloadPool does
271
- this for its submit cancellation token.
272
-
273
- `CancellationScope#deadline_in` is appropriate when the application needs a
274
- Runtime-timer-backed cancellation scope whose `on_cancel` subscribers are fired
275
- on expiry.
276
-
277
- ## Independent notification callbacks
278
-
279
- Independent notification fan-out is fault-isolated.
255
+ `CancellationToken.timeout_after(seconds)` uses a monotonic deadline. Components
256
+ requiring callback delivery promote the deadline to explicit `cancel!` through
257
+ the Runtime timer queue. OffloadPool does this for its submit cancellation token.
280
258
 
281
- The rule applies to:
259
+ A CancellationToken may be shared by multiple operations. For that reason,
260
+ settling or cancelling one Task does not cancel the token in the reverse
261
+ direction.
282
262
 
283
- - `CancellationToken#on_cancel`,
284
- - `Task#on_complete`,
285
- - `PendingOperation#on_complete`,
286
- - EventLoop timer callbacks.
263
+ ## Native async boundary
287
264
 
288
- A `StandardError` from one independent subscriber is logged and does not suppress
289
- later subscribers.
265
+ A genuine native-async driver that does not create a Phronomy-owned OS Thread and
266
+ does not block EventLoop need not consume an OffloadPool worker. If Phronomy
267
+ formally exposes such an extension point, it must adapt completion to
268
+ `Phronomy::Task` rather than exposing a provider-specific future or a private
269
+ Runtime type.
290
270
 
291
- This is deliberately different from a continuation/transform such as
292
- `Task#map`: a transform exception is the outcome of the derived operation and is
293
- therefore propagated into that derived Task.
294
-
295
- Callback execution thread is not guaranteed for low-level completion handles.
296
- Callbacks must therefore be thread-safe and should complete quickly. Framework
297
- lifecycle code normally turns completion into an explicit EventLoop event rather
298
- than mutating unrelated logical state from a worker thread.
271
+ The current Persistence, VectorStore, Embeddings, and LLM call-extension
272
+ contracts are synchronous at the external implementation boundary where
273
+ applicable. Phronomy owns the OffloadPool wrapper for synchronous work. The
274
+ current VectorStore async methods are framework convenience methods, not a
275
+ native-async backend SPI.
299
276
 
300
277
  ## Abandoned-worker metrics
301
278
 
302
279
  Two metrics answer different operational questions:
303
280
 
304
281
  - `offload_pool_abandoned_total` — cumulative count of operations that became
305
- abandoned after worker execution had started.
282
+ abandoned after worker execution had started;
306
283
  - `offload_pool_abandoned_active` — current number of abandoned operations whose
307
284
  synchronous workers still occupy pool capacity.
308
285
 
309
- Example:
310
-
311
- ```text
312
- offload_pool_size = 10
313
- offload_pool_active = 10
314
- offload_pool_abandoned_active = 8
315
- offload_pool_abandoned_total = 523
316
- offload_pool_queue_length = 40
317
- ```
318
-
319
- This means 10 workers are currently executing, 8 of them are doing work whose
320
- caller-facing result has already been abandoned, 523 abandonment events have
321
- occurred since process start, and 40 operations are queued.
322
-
323
- `Phronomy::Diagnostics.dump` exposes the same distinction for point-in-time
324
- troubleshooting.
286
+ The abandonment state belongs to the private OffloadPool Operation, not to Task.
287
+ Task reports only caller-facing settlement.
325
288
 
326
289
  ## EventLoop metrics
327
290
 
@@ -344,6 +307,6 @@ Runtime shutdown before resetting configuration.
344
307
  ## Further design records
345
308
 
346
309
  The `docs/decisions/` directory contains the historical and current Architecture
347
- Decision Records (ADRs). When an older ADR is superseded, use the superseding
348
- section/current ADR as the active design contract and keep the earlier document
349
- as historical rationale.
310
+ Decision Records. When an older ADR is superseded, use the superseding
311
+ section/current ADR as the active design contract and keep earlier documents as
312
+ historical rationale.
@@ -42,36 +42,33 @@ workflow = Phronomy::Workflow.define(GenerationContext) do
42
42
  request_id = context.generation_request_id
43
43
 
44
44
  # The Agent Task is intentionally not returned from the entry action.
45
- # on_event is the application-level integration channel.
46
- agent.invoke_async(
47
- context.prompt,
48
- on_event: ->(agent_event) {
49
- workflow_event =
50
- case agent_event.type
51
- when :done
52
- :generation_completed
53
- when :error, :timeout, :cancelled, :approval_required
54
- :generation_failed
55
- end
56
- next unless workflow_event
57
-
58
- workflow.signal(
59
- thread_id: context.thread_id,
60
- event: workflow_event,
61
- payload: {
62
- generation_request_id: request_id,
63
- agent_result: (
64
- agent_event.payload if agent_event.type == :done
65
- ),
66
- error:
67
- agent_event.payload[:error] ||
68
- Phronomy::Error.new(
69
- "Agent requested Tool approval"
70
- )
71
- }
72
- )
73
- }
74
- )
45
+ # The block is the application-level integration channel.
46
+ agent.invoke_async(context.prompt) do |agent_event|
47
+ workflow_event =
48
+ case agent_event.type
49
+ when :done
50
+ :generation_completed
51
+ when :error, :timeout, :cancelled, :approval_required
52
+ :generation_failed
53
+ end
54
+ next unless workflow_event
55
+
56
+ workflow.signal(
57
+ thread_id: context.thread_id,
58
+ event: workflow_event,
59
+ payload: {
60
+ generation_request_id: request_id,
61
+ agent_result: (
62
+ agent_event.payload if agent_event.type == :done
63
+ ),
64
+ error:
65
+ agent_event.payload[:error] ||
66
+ Phronomy::Error.new(
67
+ "Agent requested Tool approval"
68
+ )
69
+ }
70
+ )
71
+ end
75
72
 
76
73
  context
77
74
  }
@@ -5,7 +5,15 @@ module Phronomy
5
5
  # Public Agent execution API. Agent instances are always stateful and all
6
6
  # operations are coordinated through Phronomy::Persistence.
7
7
  module AsyncEventApi
8
- def invoke(input, thread_id: nil, config: {}, invocation_context: nil, on_event: nil)
8
+ def invoke(
9
+ input,
10
+ thread_id: nil,
11
+ config: {},
12
+ invocation_context: nil,
13
+ on_event: nil,
14
+ &block
15
+ )
16
+ listener = resolve_event_listener(on_event, block)
9
17
  if invocation_context
10
18
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
11
19
  end
@@ -15,7 +23,7 @@ module Phronomy
15
23
  input,
16
24
  thread_id: thread_id,
17
25
  config: config,
18
- on_event: on_event
26
+ on_event: listener
19
27
  ).wait_result
20
28
  [result, result[:usage]]
21
29
  end
@@ -27,8 +35,10 @@ module Phronomy
27
35
  config: {},
28
36
  invocation_context: nil,
29
37
  on_tool_approval_required: nil,
30
- on_event: nil
38
+ on_event: nil,
39
+ &block
31
40
  )
41
+ listener = resolve_event_listener(on_event, block)
32
42
  if invocation_context
33
43
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
34
44
  end
@@ -40,7 +50,7 @@ module Phronomy
40
50
  mode: :invoke,
41
51
  approval_policy: approval[:policy],
42
52
  approval_listener: approval[:listener],
43
- on_event: on_event
53
+ on_event: listener
44
54
  )
45
55
  end
46
56
 
@@ -4,13 +4,17 @@ module Phronomy
4
4
  module Agent
5
5
  # Routes Tool work according to the Tool execution contract.
6
6
  #
7
+ # Both execution modes return {Phronomy::Task}; only the execution mechanism
8
+ # differs.
9
+ #
7
10
  # :cooperative Tool calls execute inline and must return quickly. call_async
8
11
  # wraps their result in an already-settled Task and never consumes an
9
12
  # OffloadPool worker.
10
13
  #
11
- # :offloaded Tool calls route synchronous work through OffloadPool. Phronomy
12
- # does not distinguish whether the reason is blocking I/O, CPU-bound work, or
13
- # another long synchronous operation.
14
+ # :offloaded Tool calls route synchronous work through OffloadPool, whose
15
+ # caller-facing completion handle is also a Task. Phronomy does not distinguish
16
+ # whether the reason is blocking I/O, CPU-bound work, or another long
17
+ # synchronous operation.
14
18
  module ToolExecutor
15
19
  def self.call_async(
16
20
  tool:,