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.
@@ -10,9 +10,13 @@ module Phronomy
10
10
  ].freeze
11
11
  KNOWLEDGE_RESET_KINDS = %i[knowledge_cleared context_reset].freeze
12
12
 
13
- def initialize(persistence:, agent_root:)
13
+ def initialize(agent_root:, persistence: nil, records: nil)
14
14
  @persistence = persistence
15
15
  @agent_root = agent_root
16
+ @records = records&.dup&.freeze
17
+ if @records.nil? && @persistence.nil?
18
+ raise ArgumentError, "JournalProjection requires persistence: or records:"
19
+ end
16
20
  end
17
21
 
18
22
  def transcript_records
@@ -16,7 +16,7 @@ module Phronomy
16
16
  attr_accessor :trace_pii
17
17
  attr_accessor :logger
18
18
  attr_accessor :event_loop_stop_grace_seconds
19
- attr_accessor :state_store
19
+ attr_accessor :persistence
20
20
  attr_accessor :tool_result_max_size
21
21
  attr_accessor :llm_adapter
22
22
  attr_accessor :event_loop_starvation_threshold_seconds
@@ -53,6 +53,7 @@ module Phronomy
53
53
  @authorization_pool_size = 4
54
54
  @authorization_queue_size = 100
55
55
  @authorization_timeout = 5
56
+ @persistence = nil
56
57
  end
57
58
  end
58
59
  end
@@ -29,6 +29,7 @@ module Phronomy
29
29
  @fsms = {}
30
30
  @waiting = {}
31
31
  @admitted_session_ids = Set.new
32
+ @workflow_admissions = {}
32
33
 
33
34
  @lifecycle_mutex = Mutex.new
34
35
  @idle_cond = ConditionVariable.new
@@ -99,7 +100,7 @@ module Phronomy
99
100
  rescue
100
101
  @admitted_session_ids.delete(fsm_session.id)
101
102
  @outstanding_sessions -= 1
102
- @idle_cond.broadcast if @outstanding_sessions.zero?
103
+ @idle_cond.broadcast if runtime_idle_locked?
103
104
  raise
104
105
  end
105
106
  end
@@ -152,6 +153,73 @@ module Phronomy
152
153
  true
153
154
  end
154
155
 
156
+ # Reserves one logical Workflow thread for one concrete FSMSession execution.
157
+ # thread_id is durable Workflow identity; owner_fsm_session_id is the
158
+ # Runtime-only identity of the invocation/resume that currently owns it.
159
+ def admit_workflow(thread_id, owner_fsm_session_id:)
160
+ key = thread_id.to_s
161
+ owner = owner_fsm_session_id.to_s
162
+ raise ArgumentError, "thread_id must not be empty" if key.empty?
163
+ raise ArgumentError, "owner_fsm_session_id must not be empty" if owner.empty?
164
+
165
+ @lifecycle_mutex.synchronize do
166
+ ensure_accepting_registrations!
167
+ current_owner = @workflow_admissions[key]
168
+ if current_owner
169
+ raise Phronomy::Error,
170
+ "Workflow thread #{key.inspect} is already owned by " \
171
+ "FSMSession #{current_owner.inspect}"
172
+ end
173
+ @workflow_admissions[key] = owner
174
+ end
175
+ true
176
+ end
177
+
178
+ # Releases a Workflow reservation only when the caller is its current owner.
179
+ # A failed competing admission can therefore never release another session's
180
+ # reservation during cleanup.
181
+ def release_workflow(thread_id, owner_fsm_session_id:)
182
+ key = thread_id.to_s
183
+ owner = owner_fsm_session_id.to_s
184
+ @lifecycle_mutex.synchronize do
185
+ next false unless @workflow_admissions[key] == owner
186
+
187
+ @workflow_admissions.delete(key)
188
+ @idle_cond.broadcast if runtime_idle_locked?
189
+ true
190
+ end
191
+ end
192
+
193
+ def workflow_admission_owner(thread_id)
194
+ @lifecycle_mutex.synchronize { @workflow_admissions[thread_id.to_s] }
195
+ end
196
+
197
+ # Resolves durable Workflow identity to the currently owning FSMSession and
198
+ # enqueues the event atomically with that ownership check.
199
+ def post_to_workflow(thread_id:, event:, payload: nil)
200
+ queued_depth = nil
201
+ posted_event = nil
202
+ accepted = @lifecycle_mutex.synchronize do
203
+ next false unless accepting_events?
204
+
205
+ owner = @workflow_admissions[thread_id.to_s]
206
+ next false unless owner
207
+ next false unless @admitted_session_ids.include?(owner)
208
+
209
+ posted_event = Phronomy::Event.new(
210
+ type: event.to_sym,
211
+ target_id: owner,
212
+ payload: payload
213
+ )
214
+ queued_depth = enqueue([posted_event, monotonic_nanoseconds])
215
+ true
216
+ end
217
+ return false unless accepted
218
+
219
+ check_queue_backlog(queued_depth, posted_event)
220
+ true
221
+ end
222
+
155
223
  # Interrupts the queue wait so EventLoop can recompute the next timer deadline.
156
224
  def wake
157
225
  @queue.push(WAKE)
@@ -180,12 +248,12 @@ module Phronomy
180
248
  end
181
249
 
182
250
  def idle?
183
- @lifecycle_mutex.synchronize { @outstanding_sessions.zero? }
251
+ @lifecycle_mutex.synchronize { runtime_idle_locked? }
184
252
  end
185
253
 
186
254
  def wait_until_idle(deadline)
187
255
  @lifecycle_mutex.synchronize do
188
- until @outstanding_sessions.zero?
256
+ until runtime_idle_locked?
189
257
  remaining = deadline - monotonic_now
190
258
  return false if remaining <= 0
191
259
  @idle_cond.wait(@lifecycle_mutex, remaining)
@@ -287,7 +355,9 @@ module Phronomy
287
355
  session_id = event.payload.fetch(:session_id)
288
356
  session = @fsms.delete(session_id)
289
357
  waiter = @waiting.delete(session_id)
290
- # decrement before waking caller so wait_until_idle sees zero immediately
358
+ # decrement before waking caller so wait_until_idle sees the control-plane
359
+ # session count immediately; Workflow durable admission may intentionally
360
+ # keep Runtime non-idle until its terminal save completes.
291
361
  decrement_outstanding if session
292
362
  complete_waiter(waiter, event.payload.fetch(:result))
293
363
  when :start
@@ -297,10 +367,11 @@ module Phronomy
297
367
  @waiting[session.id] = waiter if waiter
298
368
  session.start
299
369
  when :agent_terminal_ready
300
- # Existing Agent terminalisation is retained in this proposal. A separate
301
- # follow-up may move it to an Agent-owned terminalising FSM state.
302
370
  cmd = event.payload.fetch(:command)
303
371
  cmd.coordinator.deliver_on_event_loop(cmd)
372
+ when :workflow_persistence_ready
373
+ cmd = event.payload.fetch(:command)
374
+ cmd.runner.deliver_persistence_on_event_loop(cmd)
304
375
  end
305
376
  end
306
377
 
@@ -314,7 +385,7 @@ module Phronomy
314
385
  def begin_stopping_if_idle
315
386
  @lifecycle_mutex.synchronize do
316
387
  return false unless @state == :draining
317
- return false unless @outstanding_sessions.zero?
388
+ return false unless runtime_idle_locked?
318
389
 
319
390
  @state = :stopping
320
391
  @queue.push(STOP)
@@ -337,6 +408,7 @@ module Phronomy
337
408
  @fsms.clear
338
409
  @lifecycle_mutex.synchronize do
339
410
  @admitted_session_ids.clear
411
+ @workflow_admissions.clear
340
412
  @outstanding_sessions = 0
341
413
  @idle_cond.broadcast
342
414
  end
@@ -356,6 +428,7 @@ module Phronomy
356
428
  @lifecycle_mutex.synchronize do
357
429
  @state = :failed
358
430
  @admitted_session_ids.clear
431
+ @workflow_admissions.clear
359
432
  @idle_cond.broadcast
360
433
  end
361
434
  cleanup_abandoned_work(error)
@@ -375,10 +448,14 @@ module Phronomy
375
448
  def decrement_outstanding
376
449
  @lifecycle_mutex.synchronize do
377
450
  @outstanding_sessions -= 1 if @outstanding_sessions.positive?
378
- @idle_cond.broadcast if @outstanding_sessions.zero?
451
+ @idle_cond.broadcast if runtime_idle_locked?
379
452
  end
380
453
  end
381
454
 
455
+ def runtime_idle_locked?
456
+ @outstanding_sessions.zero? && @workflow_admissions.empty?
457
+ end
458
+
382
459
  def join_until(deadline)
383
460
  remaining = deadline - monotonic_now
384
461
  return if remaining <= 0
@@ -391,6 +468,7 @@ module Phronomy
391
468
  @lifecycle_mutex.synchronize do
392
469
  @state = :terminated
393
470
  @admitted_session_ids.clear
471
+ @workflow_admissions.clear
394
472
  @thread = nil unless @thread&.alive?
395
473
  @idle_cond.broadcast
396
474
  end
@@ -25,9 +25,11 @@ module Phronomy
25
25
  event_loop:,
26
26
  resume_event: nil,
27
27
  resume_phase: nil,
28
- stable_observer: nil
28
+ stable_observer: nil,
29
+ graph_thread_id: nil
29
30
  )
30
31
  @id = id
32
+ @graph_thread_id = graph_thread_id || id
31
33
  @ctx = context
32
34
  @context = context
33
35
  @entry_point = entry_point
@@ -217,7 +219,7 @@ module Phronomy
217
219
  return if @done
218
220
 
219
221
  @done = true
220
- @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
222
+ @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: :__end__)
221
223
  post_terminal_event(:finished, @ctx)
222
224
  end
223
225
 
@@ -225,7 +227,7 @@ module Phronomy
225
227
  return if @done
226
228
 
227
229
  @done = true
228
- @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
230
+ @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: @current_state)
229
231
  post_terminal_event(:halted, @ctx)
230
232
  end
231
233
 
@@ -248,7 +250,7 @@ module Phronomy
248
250
 
249
251
  Phronomy.configuration.logger&.warn(
250
252
  "[Phronomy::FSMSession] EventLoop rejected terminal event " \
251
- "#{type.inspect} for #{@id}"
253
+ "#{type.inspect} for #{@id}"
252
254
  )
253
255
  end
254
256
 
@@ -69,6 +69,7 @@ module Phronomy
69
69
  @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
70
70
  timer_queue_provider: -> { timer_queue }
71
71
  )
72
+ @agent_activations = Phronomy::Agent::ActivationRegistry.new
72
73
  @lifecycle_mutex = Mutex.new
73
74
  @shutdown_mutex = Mutex.new
74
75
  @state = :running
@@ -107,6 +108,12 @@ module Phronomy
107
108
  @timer_service.timer_queue
108
109
  end
109
110
 
111
+ # Process-local live Agent executions. Activations are transient runtime
112
+ # state and deliberately do not belong to Persistence.
113
+ def __agent_activations
114
+ @agent_activations
115
+ end
116
+
110
117
  def event_loop
111
118
  @lifecycle_mutex.synchronize do
112
119
  case @state
@@ -66,7 +66,7 @@ module Phronomy
66
66
 
67
67
  def save(agent_id, expected_revision:, root:)
68
68
  @owner.synchronize do
69
- current = load(agent_id)
69
+ current = @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
70
70
  unless current.agent_revision == expected_revision
71
71
  raise ConflictError,
72
72
  "agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
@@ -162,7 +162,7 @@ module Phronomy
162
162
 
163
163
  def save(execution_id, expected_revision:, execution:)
164
164
  @owner.synchronize do
165
- current = load(execution_id)
165
+ current = @owner.state[:executions].fetch(execution_id.to_s) { raise NotFoundError, "execution not found: #{execution_id}" }
166
166
  unless current.execution_revision == expected_revision
167
167
  raise ConflictError,
168
168
  "execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
@@ -209,31 +209,112 @@ module Phronomy
209
209
  end
210
210
  end
211
211
 
212
- attr_reader :state
212
+ class WorkflowStates
213
+ def initialize(owner) = @owner = owner
214
+
215
+ def load(thread_id)
216
+ @owner.synchronize do
217
+ record = @owner.workflow_state_data[thread_id.to_s]
218
+ next nil unless record
219
+
220
+ {
221
+ snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
222
+ revision: record.fetch(:revision)
223
+ }.freeze
224
+ end
225
+ end
226
+
227
+ def save(thread_id, expected_revision:, snapshot:)
228
+ @owner.synchronize do
229
+ key = thread_id.to_s
230
+ current = @owner.workflow_state_data[key]
231
+ actual_revision = current&.fetch(:revision)
232
+ unless actual_revision == expected_revision
233
+ raise ConflictError,
234
+ "workflow state revision conflict for #{key}: " \
235
+ "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
236
+ end
237
+
238
+ next_revision = actual_revision ? actual_revision + 1 : 1
239
+ @owner.workflow_state_data[key] = {
240
+ snapshot: @owner.deep_dup_workflow_value(snapshot),
241
+ revision: next_revision
242
+ }
243
+ next_revision
244
+ end
245
+ end
246
+
247
+ def delete(thread_id, expected_revision:)
248
+ @owner.synchronize do
249
+ key = thread_id.to_s
250
+ current = @owner.workflow_state_data[key]
251
+ actual_revision = current&.fetch(:revision)
252
+ unless actual_revision == expected_revision
253
+ raise ConflictError,
254
+ "workflow state revision conflict for #{key}: " \
255
+ "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
256
+ end
257
+ @owner.workflow_state_data.delete(key)
258
+ end
259
+ nil
260
+ end
261
+ end
262
+
263
+ attr_reader :state, :workflow_state_data
213
264
 
214
265
  def initialize
215
266
  @monitor = Monitor.new
216
267
  @state = {contents: {}, agents: {}, journals: {}, executions: {}}
268
+ @workflow_state_data = {}
217
269
  @contents = Contents.new(self)
218
270
  @agents = Agents.new(self)
219
271
  @journals = Journals.new(self)
220
272
  @executions = Executions.new(self)
221
- @activations = Phronomy::Agent::ActivationRegistry.new
222
- super(contents: @contents, agents: @agents, journals: @journals,
223
- executions: @executions, activations: @activations)
273
+ @workflow_states = WorkflowStates.new(self)
274
+ super(
275
+ contents: @contents,
276
+ agents: @agents,
277
+ journals: @journals,
278
+ executions: @executions,
279
+ workflow_states: @workflow_states
280
+ )
224
281
  end
225
282
 
226
283
  def capabilities
227
284
  {atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
228
285
  end
229
286
 
287
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
288
+ synchronize do
289
+ stored = @state[:agents][agent_id.to_s]
290
+ unless stored
291
+ raise NotFoundError, "Agent not found: #{agent_id}"
292
+ end
293
+
294
+ if stored.agent_revision != agent_revision
295
+ raise ConflictError,
296
+ "agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
297
+ end
298
+
299
+ actual_position = Array(@state[:journals][agent_id.to_s]).length
300
+ if actual_position != journal_position
301
+ raise ConflictError,
302
+ "journal position conflict: expected #{journal_position}, actual #{actual_position}"
303
+ end
304
+
305
+ true
306
+ end
307
+ end
308
+
230
309
  def transaction
231
310
  synchronize do
232
- snapshot = Marshal.load(Marshal.dump(@state))
311
+ state_snapshot = Marshal.load(Marshal.dump(@state))
312
+ workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
233
313
  begin
234
314
  yield self
235
315
  rescue
236
- @state = snapshot
316
+ @state.replace(state_snapshot)
317
+ @workflow_state_data.replace(workflow_snapshot)
237
318
  raise
238
319
  end
239
320
  end
@@ -242,6 +323,30 @@ module Phronomy
242
323
  def synchronize(&block)
243
324
  @monitor.synchronize(&block)
244
325
  end
326
+
327
+ # Workflow fields historically accepted ordinary Ruby values in the
328
+ # in-memory store. Keep that contract without forcing the Agent durable
329
+ # state Marshal snapshot to serialize arbitrary Workflow values.
330
+ def deep_dup_workflow_value(value)
331
+ case value
332
+ when Hash
333
+ value.each_with_object({}) do |(key, child), result|
334
+ result[deep_dup_workflow_value(key)] = deep_dup_workflow_value(child)
335
+ end
336
+ when Array
337
+ value.map { |child| deep_dup_workflow_value(child) }
338
+ when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
339
+ value
340
+ else
341
+ return value if value.frozen?
342
+
343
+ begin
344
+ value.dup
345
+ rescue TypeError
346
+ value
347
+ end
348
+ end
349
+ end
245
350
  end
246
351
  end
247
352
  end
@@ -6,14 +6,14 @@ module Phronomy
6
6
  class NotFoundError < Phronomy::Error; end
7
7
  class UnsupportedBackendError < Phronomy::Error; end
8
8
 
9
- attr_reader :contents, :agents, :journals, :executions, :activations
9
+ attr_reader :contents, :agents, :journals, :executions, :workflow_states
10
10
 
11
- def initialize(contents:, agents:, journals:, executions:, activations:)
11
+ def initialize(contents:, agents:, journals:, executions:, workflow_states:)
12
12
  @contents = contents
13
13
  @agents = agents
14
14
  @journals = journals
15
15
  @executions = executions
16
- @activations = activations
16
+ @workflow_states = workflow_states
17
17
  validate_capabilities!
18
18
  end
19
19
 
@@ -25,6 +25,15 @@ module Phronomy
25
25
  raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
26
26
  end
27
27
 
28
+ # Verifies that a live Agent still owns the durable base it hydrated.
29
+ # Backends should implement this as a revision/position precondition check,
30
+ # not as a state reload returned to the caller.
31
+ # @api private
32
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
33
+ raise UnsupportedBackendError,
34
+ "#{self.class} does not provide Agent durable-watermark checks"
35
+ end
36
+
28
37
  private
29
38
 
30
39
  def validate_capabilities!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.18.0"
4
+ VERSION = "0.19.0"
5
5
  end
@@ -8,8 +8,8 @@ module Phronomy
8
8
  class Workflow
9
9
  include Phronomy::Runnable
10
10
 
11
- def self.define(context_class, state_store: nil, &block)
12
- builder = Builder.new(context_class, state_store: state_store)
11
+ def self.define(context_class, persistence: nil, &block)
12
+ builder = Builder.new(context_class, persistence: persistence)
13
13
  builder.instance_eval(&block)
14
14
  builder.build
15
15
  end
@@ -41,12 +41,13 @@ module Phronomy
41
41
  @runner.send_event(state: state, event: event, input: input)
42
42
  end
43
43
 
44
- # Sends an event to an active Workflow session without blocking.
44
+ # Sends an event to an active Workflow execution without blocking.
45
45
  #
46
- # This method is safe to call from an Agent/Tool listener running on the
47
- # EventLoop thread because it only enqueues a later dispatch.
46
+ # +thread_id+ is the logical/durable Workflow identity. EventLoop resolves it
47
+ # to the currently owning Runtime-only fsm_session_id. InvocationContext's
48
+ # application session_id is unrelated to this routing.
48
49
  #
49
- # @return [Boolean] true when admitted; false when the session is not live
50
+ # @return [Boolean] true when admitted; false when the Workflow is not live
50
51
  # or Runtime shutdown has begun
51
52
  # @api public
52
53
  def signal(thread_id:, event:, payload: nil)
@@ -76,9 +77,9 @@ module Phronomy
76
77
  class Builder
77
78
  FINISH = Phronomy::WorkflowRunner::FINISH
78
79
 
79
- def initialize(context_class, state_store: nil)
80
+ def initialize(context_class, persistence: nil)
80
81
  @context_class = context_class
81
- @state_store = state_store
82
+ @persistence = persistence
82
83
  @initial = nil
83
84
  @declared_states = []
84
85
  @entry_actions = {}
@@ -167,7 +168,7 @@ module Phronomy
167
168
  external_events: external_events,
168
169
  entry_point: @initial || @declared_states.first,
169
170
  wait_state_names: @wait_state_names.dup,
170
- state_store: @state_store
171
+ persistence: @persistence
171
172
  )
172
173
  Workflow.new(runner)
173
174
  end