phronomy 0.18.0 → 0.20.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/CONTRIBUTING.md +30 -0
  4. data/README.md +2 -0
  5. data/docs/decisions/009-state-store-abstraction.md +1 -1
  6. data/docs/decisions/014-unified-persistence-durable-state.md +273 -0
  7. data/docs/features.md +27 -1
  8. data/docs/getting-started.md +37 -1
  9. data/docs/migrations/0.19.md +154 -0
  10. data/docs/persistence-backends.md +504 -0
  11. data/docs/runtime-and-concurrency.md +93 -2
  12. data/lib/phronomy/agent/agent_execution.rb +29 -0
  13. data/lib/phronomy/agent/base.rb +81 -36
  14. data/lib/phronomy/agent/context_assembler.rb +13 -3
  15. data/lib/phronomy/agent/execution_coordinator.rb +420 -249
  16. data/lib/phronomy/agent/journal_projection.rb +5 -1
  17. data/lib/phronomy/agent/llm_call_record.rb +20 -0
  18. data/lib/phronomy/configuration.rb +2 -1
  19. data/lib/phronomy/engine/event_loop.rb +86 -8
  20. data/lib/phronomy/engine/fsm_session.rb +6 -4
  21. data/lib/phronomy/engine/runtime.rb +7 -0
  22. data/lib/phronomy/persistence/in_memory.rb +113 -8
  23. data/lib/phronomy/persistence.rb +109 -6
  24. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
  25. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
  26. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
  27. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
  28. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
  29. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
  30. data/lib/phronomy/testing/persistence_contract.rb +41 -0
  31. data/lib/phronomy/version.rb +1 -1
  32. data/lib/phronomy/workflow.rb +10 -9
  33. data/lib/phronomy/workflow_runner.rb +361 -95
  34. data/lib/phronomy.rb +9 -0
  35. metadata +12 -5
  36. data/lib/phronomy/state_store/base.rb +0 -48
  37. data/lib/phronomy/state_store/in_memory.rb +0 -62
  38. data/scripts/check_private_enforcement.rb +0 -93
@@ -0,0 +1,504 @@
1
+ # Persistence backend contract
2
+
3
+ `Phronomy::Persistence` is the single durable-state backend abstraction used by
4
+ stateful Agents and durable Workflows. This document is the normative contract
5
+ for authors of custom Persistence backends.
6
+
7
+ The Backend SPI is **Beta**. It may evolve in a minor pre-1.0 release, but a
8
+ backend should not depend on Phronomy private APIs or Runtime internals.
9
+
10
+ ## Architecture boundary
11
+
12
+ A backend implements durable storage only:
13
+
14
+ ```text
15
+ Application
16
+
17
+ Agent / Workflow
18
+
19
+ Runtime / EventLoop / ExecutionCoordinator
20
+
21
+ Phronomy::Persistence synchronous Backend SPI
22
+
23
+ Database / durable storage
24
+ ```
25
+
26
+ Persistence does not own live execution state. In particular, a backend must not
27
+ persist or reconstruct the following as part of this SPI:
28
+
29
+ - `AgentExecutionActivation`;
30
+ - `AgentInvocation`;
31
+ - `FSMSession`;
32
+ - `Task` or callbacks;
33
+ - EventLoop queue contents;
34
+ - Runtime Workflow admission entries;
35
+ - in-flight provider operations.
36
+
37
+ Persistence operations are synchronous. Framework-owned blocking Persistence I/O
38
+ is submitted to the Runtime OffloadPool by Phronomy; a backend must not post
39
+ EventLoop events or introduce `load_async` / `save_async` variants into this
40
+ contract.
41
+
42
+ ## Required root surface
43
+
44
+ A Persistence backend exposes five durable repositories:
45
+
46
+ ```text
47
+ contents
48
+ agents
49
+ journals
50
+ executions
51
+ workflow_states
52
+ ```
53
+
54
+ and two root operations:
55
+
56
+ ```ruby
57
+ persistence.transaction { |tx| ... }
58
+ persistence.assert_agent_watermark!(
59
+ agent_id:,
60
+ agent_revision:,
61
+ journal_position:
62
+ )
63
+ ```
64
+
65
+ The object yielded by `transaction` is a transaction-scoped Persistence view. It
66
+ must respond to all five repository accessors and
67
+ `assert_agent_watermark!`. It may be the Persistence instance itself, but SQL
68
+ backends may instead yield an object bound to a checked-out connection or
69
+ transaction session.
70
+
71
+ ## Required capabilities
72
+
73
+ Every backend must advertise:
74
+
75
+ ```ruby
76
+ {
77
+ atomic_all: true,
78
+ atomic_admission: true,
79
+ optimistic_revision: true
80
+ }
81
+ ```
82
+
83
+ `Phronomy::Persistence::REQUIRED_CAPABILITIES` is the executable definition of
84
+ this requirement.
85
+
86
+ ### `atomic_all`
87
+
88
+ All durable repositories must be able to participate in one atomic transaction
89
+ domain. A transaction may change `contents`, `agents`, `journals`, `executions`,
90
+ and `workflow_states` and then either commit all changes or roll them all back.
91
+
92
+ This requirement deliberately does not claim exactly-once semantics after an
93
+ indeterminate database/network failure. If the underlying database cannot tell
94
+ the caller whether a commit happened, the backend should surface the storage
95
+ failure rather than pretending the outcome is known.
96
+
97
+ ### `atomic_admission`
98
+
99
+ This capability refers to **Agent execution admission**, not Workflow distributed
100
+ locking.
101
+
102
+ For one Agent, `executions.create_active` must atomically guarantee both:
103
+
104
+ ```text
105
+ execution_id is unique
106
+ AND
107
+ no active/suspended execution already exists for agent_id
108
+ ```
109
+
110
+ A conflict with an existing active/suspended execution raises
111
+ `Phronomy::AgentBusyError`.
112
+
113
+ Workflow admission remains Runtime/process-local. Cross-process Workflow
114
+ lease/fencing is an application/distributed-coordination concern and is not part
115
+ of this Backend SPI.
116
+
117
+ ### `optimistic_revision`
118
+
119
+ The backend must implement compare-and-swap semantics used by Agent roots,
120
+ Agent executions, Journals, Workflow snapshots, and the durable Agent watermark.
121
+ Stale writers must receive `Phronomy::Persistence::ConflictError`; they must not
122
+ silently overwrite newer durable state.
123
+
124
+ ## Error contract
125
+
126
+ Backends should translate backend-specific constraint errors into the following
127
+ portable Phronomy errors when the meaning matches.
128
+
129
+ ### `Phronomy::Persistence::NotFoundError`
130
+
131
+ A requested durable record does not exist.
132
+
133
+ ### `Phronomy::Persistence::ConflictError`
134
+
135
+ A persistence precondition failed, including revision, Journal position,
136
+ identity, duplicate-ID, or compare-and-swap conflicts.
137
+
138
+ ### `Phronomy::AgentBusyError`
139
+
140
+ An Agent already has an active or suspended execution and another execution
141
+ cannot be admitted.
142
+
143
+ ### `Phronomy::Persistence::SerializationError`
144
+
145
+ The backend cannot encode a value into its supported durable representation.
146
+ This is intended primarily for durable backends whose Workflow state domain is
147
+ narrower than the InMemory backend's Ruby-object domain.
148
+
149
+ ### `Phronomy::Persistence::UnsupportedBackendError`
150
+
151
+ The backend does not provide a required structural or capability contract.
152
+
153
+ Database availability, connection loss, and other transport/storage failures
154
+ must not be misreported as ordinary optimistic conflicts merely to fit this
155
+ error taxonomy.
156
+
157
+ ## Contents repository
158
+
159
+ The content repository should normally inherit from
160
+ `Phronomy::ContentStore::Base`, which supplies text/JSON helpers and the canonical
161
+ content-ID calculation.
162
+
163
+ Required primitive surface:
164
+
165
+ ```ruby
166
+ def put(bytes, canonicalization_version:)
167
+ def fetch(content_id)
168
+ def exist?(content_id)
169
+ ```
170
+
171
+ Required semantics:
172
+
173
+ - content is immutable and content-addressed;
174
+ - writing identical bytes is idempotent and returns the same content ID;
175
+ - `fetch` returns a binary `String` isolated from caller mutation;
176
+ - a missing content ID raises `Persistence::NotFoundError`;
177
+ - one content ID must never resolve to different bytes; a digest-integrity
178
+ violation raises `ContentStore::IntegrityError`.
179
+
180
+ Do not redefine the `sha256:<digest>` identity scheme in a backend. Content
181
+ references are durable data used by other Phronomy records.
182
+
183
+ ## Agents repository
184
+
185
+ Required surface:
186
+
187
+ ```ruby
188
+ def create(root)
189
+ def load(agent_id)
190
+ def save(agent_id, expected_revision:, root:)
191
+ def delete(agent_id)
192
+ ```
193
+
194
+ `create`:
195
+
196
+ - rejects an empty Agent ID;
197
+ - rejects a duplicate Agent ID with `ConflictError`;
198
+ - returns the stored `AgentRoot`.
199
+
200
+ `load`:
201
+
202
+ - returns `Phronomy::Agent::AgentRoot`, not a raw database Hash;
203
+ - raises `NotFoundError` when missing.
204
+
205
+ `save` atomically checks:
206
+
207
+ ```text
208
+ stored.agent_revision == expected_revision
209
+ root.agent_id == requested agent_id
210
+ root.agent_revision == expected_revision + 1
211
+ ```
212
+
213
+ Any failed precondition raises `ConflictError`.
214
+
215
+ `delete` is idempotent.
216
+
217
+ ## Journals repository
218
+
219
+ Required surface:
220
+
221
+ ```ruby
222
+ def append(agent_id, expected_position:, records:)
223
+ def read(agent_id, after: nil, limit: nil)
224
+ def head(agent_id)
225
+ def delete(agent_id)
226
+ ```
227
+
228
+ `append` atomically checks:
229
+
230
+ ```text
231
+ current Journal position == expected_position
232
+ every record.agent_id == agent_id
233
+ record_id is not already present in that Agent Journal
234
+ record_id is not duplicated inside the incoming batch
235
+ ```
236
+
237
+ Successful append assigns monotonically increasing sequences beginning at
238
+ `expected_position + 1` and returns the sequence-bearing `JournalRecord` values.
239
+
240
+ `read` returns records in ascending sequence order. `after: N` means records with
241
+ `sequence > N`; `limit:` caps the returned count. Caller mutation of a returned
242
+ collection must not mutate durable state.
243
+
244
+ `head` returns the current Journal position, or `0` for an empty Journal.
245
+
246
+ ## Executions repository
247
+
248
+ Required surface:
249
+
250
+ ```ruby
251
+ def create_active(execution)
252
+ def load(execution_id)
253
+ def save(execution_id, expected_revision:, execution:)
254
+ def list_active(agent_id)
255
+ def delete(execution_id)
256
+ def delete_for_agent(agent_id)
257
+ def assert_idle!(agent_id)
258
+ ```
259
+
260
+ `create_active` performs atomic Agent admission. A duplicate `execution_id`
261
+ raises `ConflictError`; an already busy Agent raises `AgentBusyError`.
262
+
263
+ `load` returns `Phronomy::Agent::AgentExecution`, not a raw database Hash, and
264
+ raises `NotFoundError` when missing.
265
+
266
+ `save` atomically checks:
267
+
268
+ ```text
269
+ stored.execution_revision == expected_revision
270
+ execution.execution_id == requested execution_id
271
+ execution.execution_revision == expected_revision + 1
272
+ ```
273
+
274
+ A failed precondition raises `ConflictError`.
275
+
276
+ `list_active(agent_id)` returns the Agent's active/suspended executions.
277
+
278
+ `assert_idle!` is used inside transactions before Agent context/Knowledge changes
279
+ and destructive operations. It must raise `AgentBusyError` if an active/suspended
280
+ execution exists. A SQL implementation must make this check part of a consistency
281
+ boundary that cannot race with Agent execution admission; a best-effort SELECT
282
+ outside the transaction is not sufficient.
283
+
284
+ ## Workflow states repository
285
+
286
+ Required surface:
287
+
288
+ ```ruby
289
+ def load(thread_id)
290
+ def save(thread_id, expected_revision:, snapshot:)
291
+ def delete(thread_id, expected_revision:)
292
+ ```
293
+
294
+ `load` returns `nil` when no row exists. Otherwise it returns a Hash containing a
295
+ snapshot and revision. String or Symbol Hash keys are accepted by Phronomy:
296
+
297
+ ```ruby
298
+ {
299
+ snapshot: {
300
+ fields: { ... },
301
+ phase: "awaiting_approval"
302
+ },
303
+ revision: 3
304
+ }
305
+ ```
306
+
307
+ `save` is compare-and-swap:
308
+
309
+ - missing row + `expected_revision: nil` creates revision `1`;
310
+ - existing revision `N` + `expected_revision: N` creates revision `N + 1`;
311
+ - any mismatch raises `ConflictError`.
312
+
313
+ `delete` succeeds only at the supplied current revision; a mismatch raises
314
+ `ConflictError`.
315
+
316
+ Caller mutation of a loaded snapshot must not mutate durable storage.
317
+
318
+ ### Workflow value serialization
319
+
320
+ `WorkflowContext#to_h` may contain ordinary Ruby application values. The
321
+ InMemory backend can preserve a broader set of Ruby values than a JSON database.
322
+ A durable backend is not required to serialize arbitrary Ruby objects such as
323
+ `Proc`, IO objects, sockets, or runtime callbacks.
324
+
325
+ A JSON/JSONB backend should document its supported value domain. A recommended
326
+ domain is:
327
+
328
+ ```text
329
+ nil
330
+ String
331
+ Integer / Float representable by the chosen JSON format
332
+ true / false
333
+ Array of supported values
334
+ Hash with String/Symbol keys and supported values
335
+ ```
336
+
337
+ If a value cannot be represented, raise `Persistence::SerializationError` rather
338
+ than silently converting it into a lossy form. JSON backends may return String
339
+ keys after decoding; `WorkflowRunner` deliberately accepts String and Symbol keys
340
+ and normalizes them when comparing durable snapshots.
341
+
342
+ Do not add generic Ruby object serialization to Phronomy core merely to make a
343
+ particular database backend accept arbitrary Workflow values.
344
+
345
+ ## Durable Agent watermark
346
+
347
+ `assert_agent_watermark!` is a public **Backend SPI** operation. It is not an
348
+ ordinary application API.
349
+
350
+ Phronomy uses it at durable barriers because a hydrated live Agent owns the
351
+ current logical state and Phronomy deliberately does not reload mutable Agent
352
+ state before every LLM/Tool cycle.
353
+
354
+ The backend must verify, in one storage consistency view:
355
+
356
+ ```text
357
+ stored AgentRoot.agent_revision == agent_revision
358
+ current Journal position == journal_position
359
+ ```
360
+
361
+ If the Agent is missing, raise `NotFoundError`. If either watermark component
362
+ differs, raise `ConflictError`. On success return `true`.
363
+
364
+ The operation must not return a replacement AgentRoot or Journal. A mismatch is a
365
+ conflict, not a request to reload/merge mutable state.
366
+
367
+ When used inside `Persistence#transaction`, a SQL backend should perform the
368
+ watermark check in the same database transaction as the subsequent durable
369
+ write.
370
+
371
+ ## Transaction contract
372
+
373
+ A transaction block may combine operations across all repositories:
374
+
375
+ ```ruby
376
+ persistence.transaction do |tx|
377
+ tx.assert_agent_watermark!(...)
378
+ content_ref = tx.contents.put_text("...")
379
+ tx.journals.append(...)
380
+ tx.executions.save(...)
381
+ tx.agents.save(...)
382
+ end
383
+ ```
384
+
385
+ If an exception escapes the block, mutations performed through the transaction
386
+ view must be rolled back as one unit.
387
+
388
+ A backend must not satisfy the SPI by committing Agent, Journal, Execution, or
389
+ Workflow changes in independent transactions and relying on later compensation.
390
+
391
+ ## Durable domain codecs
392
+
393
+ Repositories return Phronomy domain objects. A database backend should not copy
394
+ constructor knowledge for those objects into adapter code.
395
+
396
+ The supported canonical Hash codecs are:
397
+
398
+ ```ruby
399
+ Phronomy::Agent::AgentRoot#to_h
400
+ Phronomy::Agent::AgentRoot.from_h(hash)
401
+
402
+ Phronomy::Agent::JournalRecord#to_h
403
+ Phronomy::Agent::JournalRecord.from_h(hash)
404
+
405
+ Phronomy::Agent::LLMCallRecord#to_h
406
+ Phronomy::Agent::LLMCallRecord.from_h(hash)
407
+
408
+ Phronomy::Agent::AgentExecution#to_h
409
+ Phronomy::Agent::AgentExecution.from_h(hash)
410
+ ```
411
+
412
+ `AgentExecution.from_h` recursively restores nested `working_records` as
413
+ `JournalRecord` objects and nested `llm_calls` as `LLMCallRecord` objects.
414
+ String and Symbol top-level keys are accepted by these new execution/call codecs,
415
+ which permits adapters to use parsed JSON without reimplementing constructors.
416
+
417
+ The canonical Hash representation is the Phronomy/domain boundary. A backend is
418
+ free to map that representation to normalized SQL columns, JSON, or another
419
+ storage format internally.
420
+
421
+ ## Conformance tests
422
+
423
+ Phronomy ships its backend-independent RSpec shared examples as explicit test
424
+ support in the released gem. Backend projects opt in with:
425
+
426
+ ```ruby
427
+ require "phronomy/testing/persistence_contract"
428
+ ```
429
+
430
+ RSpec remains a **development/test dependency of the backend project**, not a
431
+ Phronomy runtime dependency. Ordinary `require "phronomy"` does not load RSpec,
432
+ and Phronomy's production Zeitwerk eager-load explicitly excludes the contract
433
+ support paths.
434
+
435
+ The entry point registers these shared examples:
436
+
437
+ ```text
438
+ a persistence content store
439
+ an Agent repository
440
+ a Journal repository
441
+ an Execution repository
442
+ a workflow state repository
443
+ a Persistence backend
444
+ ```
445
+
446
+ A backend's RSpec suite can apply the complete contract as follows:
447
+
448
+ ```ruby
449
+ require "phronomy"
450
+ require "phronomy/testing/persistence_contract"
451
+
452
+ RSpec.describe MyPersistenceBackend do
453
+ let(:persistence) { described_class.new(...) }
454
+
455
+ it_behaves_like "a persistence content store"
456
+ it_behaves_like "an Agent repository"
457
+ it_behaves_like "a Journal repository"
458
+ it_behaves_like "an Execution repository"
459
+ it_behaves_like "a workflow state repository"
460
+ it_behaves_like "a Persistence backend"
461
+ end
462
+ ```
463
+
464
+ `Persistence::InMemory` is run through the same shipped contract source in
465
+ Phronomy CI. The files under `spec/support/shared_examples/` are compatibility
466
+ require wrappers only; the authoritative shared-example implementations live
467
+ under `lib/phronomy/testing/persistence_contract/` so the core suite and external
468
+ backends cannot drift through copied definitions.
469
+
470
+ The generic suite verifies repository behavior, CAS semantics, admission,
471
+ mutation isolation, and whole-backend transaction behavior. Database-specific
472
+ concurrency/locking mechanisms remain backend integration-test concerns; the SPI
473
+ specifies outcomes rather than a particular SQL locking strategy.
474
+
475
+ ## SQL implementation guidance
476
+
477
+ The SPI specifies outcomes, not a locking mechanism. Typical SQL implementations
478
+ may use combinations of:
479
+
480
+ - unique constraints;
481
+ - conditional `UPDATE ... WHERE revision = ?`;
482
+ - row locks;
483
+ - serializable/repeatable-read isolation where appropriate;
484
+ - partial unique indexes for active Agent execution admission;
485
+ - transaction-scoped checks for Agent revision + Journal head.
486
+
487
+ Backend-specific database exceptions should be translated to the Phronomy error
488
+ contract where their meaning is known.
489
+
490
+ ## Explicit non-goals
491
+
492
+ This Backend SPI does not provide:
493
+
494
+ - durable reconstruction of a lost Agent Activation;
495
+ - serialization of Runtime objects;
496
+ - cross-process Workflow execution exclusion;
497
+ - exactly-once external Tool side effects;
498
+ - automatic conflict reload/merge;
499
+ - a generic serializer registry for arbitrary Workflow field classes;
500
+ - an async Persistence API.
501
+
502
+ For the architectural reasons behind these boundaries, see
503
+ [ADR-014: Unified Persistence for Durable State](decisions/014-unified-persistence-durable-state.md)
504
+ and [Runtime and concurrency](runtime-and-concurrency.md).
@@ -8,6 +8,8 @@ handle, not an execution backend. Synchronous work that must stay off EventLoop
8
8
 
9
9
  For the design rationale, see Architecture Decision Record (ADR)
10
10
  [ADR-010: EventLoop / FSMSession First Concurrency](decisions/010-cooperative-first-concurrency.md).
11
+ Durable-state ownership is defined by
12
+ [ADR-014: Unified Persistence and Durable-State Ownership](decisions/014-unified-persistence-durable-state.md).
11
13
 
12
14
  ## Runtime model
13
15
 
@@ -19,6 +21,7 @@ Runtime
19
21
  │ ├─ Workflow
20
22
  │ ├─ ToolInvocation
21
23
  │ └─ MultiAgent fan-out
24
+ ├─ process-local Agent ActivationRegistry
22
25
  ├─ OffloadPool (bounded operating-system Threads)
23
26
  │ ├─ blocking input/output (I/O)
24
27
  │ ├─ central-processing-unit (CPU)-bound synchronous work
@@ -32,6 +35,67 @@ Task = completion handle
32
35
  The framework does not allocate one operating-system Thread per logical Agent/Workflow/Tool
33
36
  lifecycle. Logical waits remain explicit states plus later EventLoop events.
34
37
 
38
+ ## Live state and durable state
39
+
40
+ A live Agent or Workflow owns its current logical state. `Persistence` is the
41
+ last committed durable representation and recovery source; it is not reloaded at
42
+ every semantic boundary.
43
+
44
+ For Agents, the live owner consists of the Agent instance plus its current
45
+ `AgentRoot`, hydrated Journal view, and `AgentExecutionActivation`. Mutable
46
+ Agent/Execution/Journal state is not automatically reloaded before every LLM or
47
+ Tool step. Durable writes use optimistic revision/position guardrails; an
48
+ external writer that advances the durable base causes `Persistence::ConflictError`
49
+ rather than automatic reload or merge.
50
+
51
+ 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.
54
+
55
+ Content-addressed `Persistence#contents` values are immutable. Fetching a known
56
+ content reference is value materialization rather than mutable state refresh.
57
+
58
+ ## Workflow identities and durable admission
59
+
60
+ Workflow execution keeps three identities separate:
61
+
62
+ ```text
63
+ session_id
64
+ application session/correlation identity, for example a Rails session
65
+
66
+ thread_id
67
+ durable Workflow identity and Persistence#workflow_states key
68
+
69
+ fsm_session_id
70
+ one Runtime FSMSession execution identity; generated again for each
71
+ invoke/resume operation
72
+ ```
73
+
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:
77
+
78
+ ```text
79
+ thread_id -> owner_fsm_session_id
80
+ ```
81
+
82
+ 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.
98
+
35
99
  ## Tool execution modes
36
100
 
37
101
  Phronomy exposes two execution modes for capabilities:
@@ -72,6 +136,28 @@ parent FSMSession
72
136
  This distinction prevents worker-slot starvation when many logical lifecycles are
73
137
  waiting at the same time.
74
138
 
139
+ ## Persistence I/O boundary
140
+
141
+ `Persistence` repositories expose synchronous operations. Framework lifecycle
142
+ code must not perform potentially blocking durable reads/writes on EventLoop.
143
+ Agent preparation/commit and Workflow hydrate/save operations are submitted to
144
+ `OffloadPool`; completion continues through completion callbacks or explicit
145
+ EventLoop events.
146
+
147
+ 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.
160
+
75
161
  ## Sync versus async application APIs
76
162
 
77
163
  | Calling context | Recommended approach |
@@ -82,11 +168,12 @@ waiting at the same time.
82
168
  | EventLoop callback | Never block waiting for a Task that requires EventLoop progress |
83
169
  | Top-level streaming | `agent.stream(...)` |
84
170
  | Non-blocking streaming | `agent.stream_async(...)` |
85
- | Approval from EventLoop callback | `approve_async` |
171
+ | Approval from EventLoop callback | Resolve with `live_for_execution`, call `agent.approve_async(...)`, and return immediately |
86
172
 
87
173
  Blocking synchronous APIs reject EventLoop re-entry with
88
174
  `Phronomy::EventLoopReentrancyError` when waiting would stall the same EventLoop
89
- needed for progress.
175
+ needed for progress. `live_for_execution` itself only performs a Runtime-local
176
+ registry lookup and does not wait for Task progress.
90
177
 
91
178
  ## Task
92
179
 
@@ -247,6 +334,10 @@ Use these to distinguish worker saturation from EventLoop backlog/latency.
247
334
  Runtime-owned EventLoop, then closes pools and timers according to the Runtime
248
335
  shutdown contract.
249
336
 
337
+ Workflow durable admission participates in EventLoop idleness: a Workflow whose
338
+ FSMSession has ended but whose durable save is still in flight remains owned until
339
+ that save completes and owner-aware admission is released.
340
+
250
341
  `Phronomy.reset_runtime!` exists primarily for test isolation and performs a real
251
342
  Runtime shutdown before resetting configuration.
252
343
 
@@ -84,6 +84,11 @@ module Phronomy
84
84
  self.class.new(**values)
85
85
  end
86
86
 
87
+ # Returns the canonical durable representation of this execution.
88
+ # Nested JournalRecord and LLMCallRecord values are recursively encoded.
89
+ #
90
+ # @return [Hash{String => Object}]
91
+ # @api public
87
92
  def to_h
88
93
  ATTRIBUTES.to_h do |name|
89
94
  value = public_send(name)
@@ -92,6 +97,30 @@ module Phronomy
92
97
  [name.to_s, value]
93
98
  end
94
99
  end
100
+
101
+ # Restores an execution from its canonical durable representation.
102
+ # String and Symbol top-level keys are accepted. Nested working Journal
103
+ # records and LLM Call records are restored through their public codecs so
104
+ # storage backends do not need to know their constructor details.
105
+ #
106
+ # @param hash [Hash]
107
+ # @return [AgentExecution]
108
+ # @api public
109
+ def self.from_h(hash)
110
+ attributes = ATTRIBUTES.to_h do |name|
111
+ key = hash.key?(name.to_s) ? name.to_s : name
112
+ [name, hash.fetch(key)]
113
+ end
114
+
115
+ attributes[:working_records] = attributes.fetch(:working_records).map do |record|
116
+ record.is_a?(JournalRecord) ? record : JournalRecord.from_h(record)
117
+ end
118
+ attributes[:llm_calls] = attributes.fetch(:llm_calls).map do |call|
119
+ call.is_a?(LLMCallRecord) ? call : LLMCallRecord.from_h(call)
120
+ end
121
+
122
+ new(**attributes)
123
+ end
95
124
  end
96
125
  end
97
126
  end