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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +30 -0
- data/README.md +2 -0
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/014-unified-persistence-durable-state.md +273 -0
- data/docs/features.md +27 -1
- data/docs/getting-started.md +37 -1
- data/docs/migrations/0.19.md +154 -0
- data/docs/persistence-backends.md +504 -0
- data/docs/runtime-and-concurrency.md +93 -2
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- data/lib/phronomy/agent/base.rb +81 -36
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +420 -249
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/agent/llm_call_record.rb +20 -0
- data/lib/phronomy/configuration.rb +2 -1
- data/lib/phronomy/engine/event_loop.rb +86 -8
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime.rb +7 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +109 -6
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
- data/lib/phronomy/testing/persistence_contract.rb +41 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +361 -95
- data/lib/phronomy.rb +9 -0
- metadata +12 -5
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- 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
|