phronomy 0.18.0 → 0.19.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.
@@ -5,11 +5,13 @@ require "securerandom"
5
5
  module Phronomy
6
6
  # Execution boundary for compiled Workflows.
7
7
  #
8
- # WorkflowRunner prepares WorkflowContext instances, registers FSMSession
9
- # objects with the Runtime-owned EventLoop, observes completion, and persists
10
- # serializable Workflow snapshots. All Workflow execution APIs share this
11
- # path; their only differences are blocking and observation semantics.
8
+ # WorkflowRunner separates three identities:
9
+ # - application session_id remains caller/tracing metadata;
10
+ # - thread_id identifies durable Workflow state;
11
+ # - fsm_session_id identifies one Runtime FSMSession execution.
12
12
  #
13
+ # Workflow persistence is synchronous at the repository contract but is always
14
+ # invoked through Runtime's OffloadPool from EventLoop-driven lifecycle paths.
13
15
  # @api private
14
16
  class WorkflowRunner
15
17
  include Phronomy::Runnable
@@ -19,9 +21,20 @@ module Phronomy
19
21
  Execution = Data.define(
20
22
  :context,
21
23
  :thread_id,
24
+ :fsm_session_id,
22
25
  :recursion_limit,
23
- :store,
24
- :persist
26
+ :repository,
27
+ :persist,
28
+ :expected_revision
29
+ )
30
+
31
+ WorkflowPersistenceCommand = Struct.new(
32
+ :runner,
33
+ :result_task,
34
+ :result,
35
+ :error,
36
+ :thread_id,
37
+ :fsm_session_id
25
38
  )
26
39
 
27
40
  def initialize(
@@ -33,7 +46,7 @@ module Phronomy
33
46
  entry_point:,
34
47
  exit_actions: {},
35
48
  wait_state_names: [],
36
- state_store: nil
49
+ persistence: nil
37
50
  )
38
51
  @state_class = state_class
39
52
  @entry_actions = entry_actions
@@ -44,7 +57,7 @@ module Phronomy
44
57
  @external_events = external_events
45
58
  @entry_point = entry_point
46
59
  @wait_state_names = wait_state_names
47
- @state_store = state_store
60
+ @persistence = persistence
48
61
  @phase_machine_class = Workflow::PhaseMachineBuilder.new(
49
62
  entry_point: @entry_point,
50
63
  declared_states: @declared_states,
@@ -63,28 +76,20 @@ module Phronomy
63
76
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
64
77
 
65
78
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
66
- execution = prepare_new_execution(input, config)
67
- result = start_execution(execution).wait_result
79
+ result = start_new_execution(input, config).wait_result
68
80
  [result, nil]
69
81
  end
70
82
  end
71
83
 
72
84
  def invoke_deferred(input, config: {})
73
- execution = prepare_new_execution(input, config)
74
- start_execution(execution)
75
- rescue => error
76
- failed_task("workflow-async:preparation", error)
85
+ start_new_execution(input, config)
77
86
  end
78
87
 
79
88
  def stream(input, config: {}, &observer)
80
89
  ensure_blocking_call_allowed!(:stream, :invoke_async)
81
90
  raise ArgumentError, "stream requires a block" unless observer
82
91
 
83
- execution = prepare_new_execution(input, config)
84
- start_execution(
85
- execution,
86
- stable_observer: observer
87
- ).wait_result
92
+ start_new_execution(input, config, stable_observer: observer).wait_result
88
93
  end
89
94
 
90
95
  def resume(state:, input: nil)
@@ -93,38 +98,26 @@ module Phronomy
93
98
 
94
99
  def send_event(state:, event:, input: nil)
95
100
  ensure_blocking_call_allowed!(:send_event, :signal)
96
- context = input ? state.merge(input) : state
97
- current_phase = context.phase.to_sym
101
+ current_phase = state.phase.to_sym
98
102
  event_name = resolve_resume_event(current_phase, event)
99
- thread_id = context.thread_id
103
+ thread_id = state.thread_id
100
104
  unless thread_id
101
105
  raise ArgumentError, "Halted WorkflowContext has no thread_id"
102
106
  end
103
107
 
104
- execution = Execution.new(
105
- context: context,
106
- thread_id: thread_id.to_s,
107
- recursion_limit: Phronomy.configuration.recursion_limit,
108
- store: configured_store,
109
- persist: true
110
- )
111
- start_execution(
112
- execution,
113
- resume_event: event_name,
114
- resume_phase: current_phase
108
+ start_resume_execution(
109
+ state,
110
+ input: input,
111
+ event_name: event_name,
112
+ current_phase: current_phase
115
113
  ).wait_result
116
114
  end
117
115
 
118
- # Posts an application-defined event to a currently live Workflow session.
119
- #
120
- # Admission is asynchronous. A true result means the EventLoop accepted the
121
- # event for an admitted session; it does not mean that a transition matched.
122
- # A false result means that the Runtime is stopping or the session is no
123
- # longer admitted.
116
+ # Posts an application-defined event to the currently live Workflow owner.
117
+ # thread_id is resolved to its Runtime-only fsm_session_id by EventLoop; the
118
+ # application session_id is deliberately unrelated to this routing.
124
119
  def signal(thread_id:, event:, payload: nil)
125
- if thread_id.nil?
126
- raise ArgumentError, "thread_id is required"
127
- end
120
+ raise ArgumentError, "thread_id is required" if thread_id.nil?
128
121
 
129
122
  event_name = event.to_sym
130
123
  unless @external_events.key?(event_name)
@@ -133,15 +126,27 @@ module Phronomy
133
126
  "Valid events: #{@external_events.keys.inspect}"
134
127
  end
135
128
 
136
- Phronomy::Runtime.instance.event_loop.post_to_session(
137
- Phronomy::Event.new(
138
- type: event_name,
139
- target_id: thread_id.to_s,
140
- payload: payload
141
- )
129
+ Phronomy::Runtime.instance.event_loop.post_to_workflow(
130
+ thread_id: thread_id,
131
+ event: event_name,
132
+ payload: payload
142
133
  )
143
134
  end
144
135
 
136
+ # Called only by EventLoop for terminal Workflow persistence completion.
137
+ def deliver_persistence_on_event_loop(command)
138
+ event_loop = Phronomy::Runtime.instance.event_loop
139
+ event_loop.release_workflow(
140
+ command.thread_id,
141
+ owner_fsm_session_id: command.fsm_session_id
142
+ )
143
+ if command.error
144
+ fail_task(command.result_task, command.error)
145
+ else
146
+ complete_task(command.result_task, command.result)
147
+ end
148
+ end
149
+
145
150
  private
146
151
 
147
152
  def ensure_blocking_call_allowed!(method_name, async_alternative)
@@ -152,16 +157,186 @@ module Phronomy
152
157
  "Use #{async_alternative} instead."
153
158
  end
154
159
 
155
- def prepare_new_execution(input, config)
160
+ def start_new_execution(input, config, stable_observer: nil)
161
+ runtime = Phronomy::Runtime.instance
162
+ event_loop = runtime.event_loop
163
+ result_task = Phronomy::Task.deferred(name: "workflow:preparing")
164
+ explicit_thread_id = !config[:thread_id].nil?
156
165
  thread_id = (config[:thread_id] || SecureRandom.uuid).to_s
166
+ fsm_session_id = SecureRandom.uuid
157
167
  recursion_limit = config.fetch(
158
168
  :recursion_limit,
159
169
  Phronomy.configuration.recursion_limit
160
170
  )
161
- store = configured_store(config)
162
- snapshot = store&.load(thread_id) if config[:thread_id]
171
+ repository = configured_repository
172
+ persist = explicit_thread_id && !repository.nil?
173
+
174
+ event_loop.admit_workflow(
175
+ thread_id,
176
+ owner_fsm_session_id: fsm_session_id
177
+ )
178
+
179
+ if persist
180
+ load_operation = runtime.offload.submit(on_full: :raise) do
181
+ repository.load(thread_id)
182
+ end
183
+ load_operation.on_complete do |record, error|
184
+ if error
185
+ release_and_fail(
186
+ event_loop, result_task, thread_id, fsm_session_id, error
187
+ )
188
+ next
189
+ end
190
+
191
+ begin
192
+ execution = build_new_execution(
193
+ input,
194
+ thread_id: thread_id,
195
+ fsm_session_id: fsm_session_id,
196
+ recursion_limit: recursion_limit,
197
+ repository: repository,
198
+ persist: true,
199
+ record: record
200
+ )
201
+ register_execution(
202
+ execution,
203
+ result_task,
204
+ stable_observer: stable_observer
205
+ )
206
+ rescue => preparation_error
207
+ release_and_fail(
208
+ event_loop,
209
+ result_task,
210
+ thread_id,
211
+ fsm_session_id,
212
+ preparation_error
213
+ )
214
+ end
215
+ end
216
+ else
217
+ execution = build_new_execution(
218
+ input,
219
+ thread_id: thread_id,
220
+ fsm_session_id: fsm_session_id,
221
+ recursion_limit: recursion_limit,
222
+ repository: repository,
223
+ persist: false,
224
+ record: nil
225
+ )
226
+ register_execution(
227
+ execution,
228
+ result_task,
229
+ stable_observer: stable_observer
230
+ )
231
+ end
232
+ result_task
233
+ rescue => error
234
+ if defined?(event_loop) && event_loop &&
235
+ defined?(thread_id) && defined?(fsm_session_id)
236
+ event_loop.release_workflow(
237
+ thread_id,
238
+ owner_fsm_session_id: fsm_session_id
239
+ )
240
+ end
241
+ fail_task(result_task, error) if defined?(result_task) && result_task
242
+ result_task || failed_task("workflow:preparation", error)
243
+ end
244
+
245
+ def start_resume_execution(state, input:, event_name:, current_phase:)
246
+ runtime = Phronomy::Runtime.instance
247
+ event_loop = runtime.event_loop
248
+ thread_id = state.thread_id.to_s
249
+ fsm_session_id = SecureRandom.uuid
250
+ repository = configured_repository
251
+ result_task = Phronomy::Task.deferred(name: "workflow-resume:#{thread_id}")
252
+
253
+ event_loop.admit_workflow(
254
+ thread_id,
255
+ owner_fsm_session_id: fsm_session_id
256
+ )
257
+
258
+ if repository
259
+ load_operation = runtime.offload.submit(on_full: :raise) do
260
+ repository.load(thread_id)
261
+ end
262
+ load_operation.on_complete do |record, error|
263
+ if error
264
+ release_and_fail(
265
+ event_loop, result_task, thread_id, fsm_session_id, error
266
+ )
267
+ next
268
+ end
269
+
270
+ begin
271
+ expected_revision = validate_resume_snapshot!(state, record)
272
+ context = input ? state.merge(input) : state
273
+ execution = Execution.new(
274
+ context: context,
275
+ thread_id: thread_id,
276
+ fsm_session_id: fsm_session_id,
277
+ recursion_limit: Phronomy.configuration.recursion_limit,
278
+ repository: repository,
279
+ persist: true,
280
+ expected_revision: expected_revision
281
+ )
282
+ register_execution(
283
+ execution,
284
+ result_task,
285
+ resume_event: event_name,
286
+ resume_phase: current_phase
287
+ )
288
+ rescue => preparation_error
289
+ release_and_fail(
290
+ event_loop,
291
+ result_task,
292
+ thread_id,
293
+ fsm_session_id,
294
+ preparation_error
295
+ )
296
+ end
297
+ end
298
+ else
299
+ context = input ? state.merge(input) : state
300
+ execution = Execution.new(
301
+ context: context,
302
+ thread_id: thread_id,
303
+ fsm_session_id: fsm_session_id,
304
+ recursion_limit: Phronomy.configuration.recursion_limit,
305
+ repository: nil,
306
+ persist: false,
307
+ expected_revision: nil
308
+ )
309
+ register_execution(
310
+ execution,
311
+ result_task,
312
+ resume_event: event_name,
313
+ resume_phase: current_phase
314
+ )
315
+ end
316
+ result_task
317
+ rescue => error
318
+ if defined?(event_loop) && event_loop &&
319
+ defined?(thread_id) && defined?(fsm_session_id)
320
+ event_loop.release_workflow(
321
+ thread_id,
322
+ owner_fsm_session_id: fsm_session_id
323
+ )
324
+ end
325
+ fail_task(result_task, error) if defined?(result_task) && result_task
326
+ result_task || failed_task("workflow:resume-preparation", error)
327
+ end
163
328
 
164
- stored_fields = snapshot && snapshot[:fields]
329
+ def build_new_execution(
330
+ input,
331
+ thread_id:,
332
+ fsm_session_id:,
333
+ recursion_limit:,
334
+ repository:,
335
+ persist:,
336
+ record:
337
+ )
338
+ snapshot = record_value(record, :snapshot)
339
+ stored_fields = snapshot && (snapshot[:fields] || snapshot["fields"])
165
340
  initial_fields = if stored_fields
166
341
  stored_fields
167
342
  .transform_keys(&:to_sym)
@@ -176,45 +351,84 @@ module Phronomy
176
351
  Execution.new(
177
352
  context: context,
178
353
  thread_id: thread_id,
354
+ fsm_session_id: fsm_session_id,
179
355
  recursion_limit: recursion_limit,
180
- store: store,
181
- persist: !config[:thread_id].nil?
356
+ repository: repository,
357
+ persist: persist,
358
+ expected_revision: record_value(record, :revision)
182
359
  )
183
360
  end
184
361
 
185
- def configured_store(config = {})
186
- config.fetch(:state_store, @state_store) ||
187
- Phronomy.configuration.state_store
362
+ def validate_resume_snapshot!(state, record)
363
+ return nil unless record
364
+
365
+ durable_snapshot = normalize_snapshot(record_value(record, :snapshot))
366
+ local_snapshot = normalize_snapshot(snapshot_for(state))
367
+ return record_value(record, :revision) if durable_snapshot == local_snapshot
368
+
369
+ raise Phronomy::Persistence::ConflictError,
370
+ "Workflow state changed since the supplied halted context for " \
371
+ "thread_id #{state.thread_id.inspect}; explicit reload/reconciliation is required"
372
+ end
373
+
374
+ def normalize_snapshot(snapshot)
375
+ snapshot ||= {}
376
+ fields = snapshot[:fields] || snapshot["fields"] || {}
377
+ phase = snapshot[:phase] || snapshot["phase"]
378
+ {
379
+ fields: normalize_workflow_value(fields),
380
+ phase: phase&.to_s
381
+ }
188
382
  end
189
383
 
190
- def start_execution(
384
+ def normalize_workflow_value(value)
385
+ case value
386
+ when Hash
387
+ value.each_with_object({}) do |(key, child), result|
388
+ result[key.to_s] = normalize_workflow_value(child)
389
+ end
390
+ when Array
391
+ value.map { |child| normalize_workflow_value(child) }
392
+ when Symbol
393
+ value.to_s
394
+ else
395
+ value
396
+ end
397
+ end
398
+
399
+ def record_value(record, key)
400
+ return nil unless record
401
+ record.key?(key) ? record[key] : record[key.to_s]
402
+ end
403
+
404
+ def configured_repository
405
+ persistence = @persistence || Phronomy.configuration.persistence
406
+ persistence&.workflow_states
407
+ end
408
+
409
+ def register_execution(
191
410
  execution,
411
+ result_task,
192
412
  resume_event: nil,
193
413
  resume_phase: nil,
194
414
  stable_observer: nil
195
415
  )
196
416
  runtime = Phronomy::Runtime.instance
197
- result_task = Phronomy::Task.deferred(
198
- name: "workflow:#{execution.thread_id}"
199
- )
200
417
  source_task = Phronomy::Task.deferred(
201
- name: "workflow-source:#{execution.thread_id}"
418
+ name: "workflow-source:#{execution.fsm_session_id}"
202
419
  )
203
420
 
204
421
  source_task.on_complete do |result, error|
205
422
  finalize_execution(
423
+ execution: execution,
206
424
  result_task: result_task,
207
425
  result: result,
208
- error: error,
209
- store: execution.store,
210
- thread_id: execution.thread_id,
211
- persist: execution.persist
426
+ error: error
212
427
  )
213
428
  end
214
429
 
215
430
  session = build_session_for(
216
- context: execution.context,
217
- recursion_limit: execution.recursion_limit,
431
+ execution: execution,
218
432
  runtime: runtime,
219
433
  resume_event: resume_event,
220
434
  resume_phase: resume_phase,
@@ -223,56 +437,108 @@ module Phronomy
223
437
  runtime.event_loop.register(session, completion: source_task)
224
438
  result_task
225
439
  rescue => error
226
- fail_task(result_task, error) if result_task
227
- result_task || failed_task("workflow:registration", error)
440
+ Phronomy::Runtime.instance.event_loop.release_workflow(
441
+ execution.thread_id,
442
+ owner_fsm_session_id: execution.fsm_session_id
443
+ )
444
+ fail_task(result_task, error)
445
+ result_task
228
446
  end
229
447
 
230
- def finalize_execution(
231
- result_task:,
232
- result:,
233
- error:,
234
- store:,
235
- thread_id:,
236
- persist:
237
- )
448
+ # Called from source_task completion on EventLoop.
449
+ def finalize_execution(execution:, result_task:, result:, error:)
450
+ event_loop = Phronomy::Runtime.instance.event_loop
238
451
  if error
452
+ event_loop.release_workflow(
453
+ execution.thread_id,
454
+ owner_fsm_session_id: execution.fsm_session_id
455
+ )
239
456
  fail_task(result_task, error)
240
457
  return
241
458
  end
242
459
 
243
- begin
244
- persist_snapshot(store, thread_id, result, persist: persist)
245
- rescue => persistence_error
246
- fail_task(result_task, persistence_error)
460
+ unless execution.repository && execution.persist
461
+ event_loop.release_workflow(
462
+ execution.thread_id,
463
+ owner_fsm_session_id: execution.fsm_session_id
464
+ )
465
+ complete_task(result_task, result)
247
466
  return
248
467
  end
249
468
 
250
- complete_task(result_task, result)
469
+ snapshot = snapshot_for(result)
470
+ runtime = Phronomy::Runtime.instance
471
+ operation = runtime.offload.submit(on_full: :raise) do
472
+ execution.repository.save(
473
+ execution.thread_id,
474
+ expected_revision: execution.expected_revision,
475
+ snapshot: snapshot
476
+ )
477
+ end
478
+ operation.on_complete do |_revision, persistence_error|
479
+ command = WorkflowPersistenceCommand.new(
480
+ self,
481
+ result_task,
482
+ result,
483
+ persistence_error,
484
+ execution.thread_id,
485
+ execution.fsm_session_id
486
+ )
487
+ posted = event_loop.post(
488
+ Phronomy::Event.new(
489
+ type: :workflow_persistence_ready,
490
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
491
+ payload: {command: command}
492
+ )
493
+ )
494
+ settle_persistence_without_event_loop(command, event_loop) unless posted
495
+ end
496
+ rescue => persistence_start_error
497
+ event_loop.release_workflow(
498
+ execution.thread_id,
499
+ owner_fsm_session_id: execution.fsm_session_id
500
+ )
501
+ fail_task(result_task, persistence_start_error)
251
502
  end
252
503
 
253
- def persist_snapshot(store, thread_id, context, persist:)
254
- return unless store && persist
504
+ def settle_persistence_without_event_loop(command, event_loop)
505
+ event_loop.release_workflow(
506
+ command.thread_id,
507
+ owner_fsm_session_id: command.fsm_session_id
508
+ )
509
+ command.error ?
510
+ fail_task(command.result_task, command.error) :
511
+ complete_task(command.result_task, command.result)
512
+ rescue => error
513
+ fail_task(command.result_task, error)
514
+ end
255
515
 
256
- store.save(
516
+ def release_and_fail(event_loop, result_task, thread_id, fsm_session_id, error)
517
+ event_loop.release_workflow(
257
518
  thread_id,
258
- {
259
- fields: context.to_h,
260
- phase: context.phase.to_s
261
- }
519
+ owner_fsm_session_id: fsm_session_id
262
520
  )
521
+ fail_task(result_task, error)
522
+ end
523
+
524
+ def snapshot_for(context)
525
+ {
526
+ fields: context.to_h,
527
+ phase: context.phase.to_s
528
+ }
263
529
  end
264
530
 
265
531
  def build_session_for(
266
- context:,
267
- recursion_limit:,
532
+ execution:,
268
533
  runtime:,
269
534
  resume_event: nil,
270
535
  resume_phase: nil,
271
536
  stable_observer: nil
272
537
  )
273
538
  Phronomy::FSMSession.new(
274
- id: context.thread_id,
275
- context: context,
539
+ id: execution.fsm_session_id,
540
+ graph_thread_id: execution.thread_id,
541
+ context: execution.context,
276
542
  entry_point: @entry_point,
277
543
  entry_actions: @entry_actions,
278
544
  auto_state_set: @auto_state_set,
@@ -280,7 +546,7 @@ module Phronomy
280
546
  wait_state_names: @wait_state_names,
281
547
  external_events: @external_events,
282
548
  phase_machine_class: @phase_machine_class,
283
- recursion_limit: recursion_limit,
549
+ recursion_limit: execution.recursion_limit,
284
550
  event_loop: runtime.event_loop,
285
551
  resume_event: resume_event,
286
552
  resume_phase: resume_phase,
data/lib/phronomy.rb CHANGED
@@ -17,7 +17,10 @@ loader.inflector.inflect("llm_call_record" => "LLMCallRecord")
17
17
  loader.inflector.inflect("llm_input_manifest" => "LLMInputManifest")
18
18
  loader.inflector.inflect("llm_input_build_context" => "LLMInputBuildContext")
19
19
  loader.inflector.inflect("llm_input_patch" => "LLMInputPatch")
20
+ loader.inflector.inflect("before_llm_input" => "BeforeLLMInput")
20
21
  loader.collapse("#{__dir__}/phronomy/engine")
22
+ # Loaded via require_relative before loader.setup; ignore to avoid Zeitwerk constant-name mismatch.
23
+ loader.ignore("#{__dir__}/phronomy/ruby_llm_patches.rb")
21
24
  loader.setup
22
25
 
23
26
  require_relative "phronomy/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -119,11 +119,13 @@ files:
119
119
  - docs/decisions/011-delegate-transport-policy-to-adapters.md
120
120
  - docs/decisions/012-canonical-execution-log-and-context-policy.md
121
121
  - docs/decisions/013-journal-backed-knowledge-as-context-candidates.md
122
+ - docs/decisions/014-unified-persistence-durable-state.md
122
123
  - docs/features.md
123
124
  - docs/getting-started.md
124
125
  - docs/mcp-client.md
125
126
  - docs/migrations/0.15.md
126
127
  - docs/migrations/0.16.md
128
+ - docs/migrations/0.19.md
127
129
  - docs/runtime-and-concurrency.md
128
130
  - examples/workflows/agent_event_mapping.rb
129
131
  - examples/workflows/generic_task_event_mapping.rb
@@ -233,8 +235,6 @@ files:
233
235
  - lib/phronomy/persistence/in_memory.rb
234
236
  - lib/phronomy/ruby_llm_patches.rb
235
237
  - lib/phronomy/runnable.rb
236
- - lib/phronomy/state_store/base.rb
237
- - lib/phronomy/state_store/in_memory.rb
238
238
  - lib/phronomy/stream_callback_error.rb
239
239
  - lib/phronomy/testing.rb
240
240
  - lib/phronomy/testing/eval.rb