phronomy 0.19.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/CONTRIBUTING.md +30 -0
- data/docs/decisions/014-unified-persistence-durable-state.md +5 -0
- data/docs/features.md +21 -1
- data/docs/migrations/0.19.md +12 -6
- data/docs/persistence-backends.md +504 -0
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- data/lib/phronomy/agent/llm_call_record.rb +20 -0
- data/lib/phronomy/persistence.rb +101 -7
- 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.rb +6 -0
- metadata +10 -3
- data/scripts/check_private_enforcement.rb +0 -93
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a9f9f2ab4acdb4e17edeb4e0b7f0b33660b7102e5b345c2bd8d411e6b0760de
|
|
4
|
+
data.tar.gz: 331265c52dfadbe4be8f85b9a48b35de384042664538f4fa74db0d34829e3b62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3c7e0b15ede3129a2ac426ed86ebde36e353c664b5c64fc559878996ab6331b86f88c289e2f47179f0d1c58e91ec23c0c3d23060f066168c1a441e349dd534f
|
|
7
|
+
data.tar.gz: 527e858a300d3e8fb305782cdab29d6195b3836686df5b4d41a7abf8a40b584de8a8f0dcdab8ccd5b7b5dcfbcbf0c0dad45a7be2f092eae69c2c643ff69e9734
|
data/CONTRIBUTING.md
CHANGED
|
@@ -52,6 +52,36 @@ When adding, removing, or renaming a public method or class:
|
|
|
52
52
|
bundle exec ruby scripts/api_snapshot.rb --write
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### `@api` classification vs Ruby visibility
|
|
56
|
+
|
|
57
|
+
Phronomy's YARD `@api` annotation describes the compatibility boundary; it is
|
|
58
|
+
not a synonym for Ruby's `public` / `protected` / `private` keywords.
|
|
59
|
+
|
|
60
|
+
- `@api public` means consumers or extension implementers may rely on the
|
|
61
|
+
documented contract. Ruby visibility still follows the intended calling
|
|
62
|
+
model: ordinary methods may be public, subclass extension helpers may be
|
|
63
|
+
protected, and `initialize` remains Ruby-private while construction is
|
|
64
|
+
exposed through `.new`.
|
|
65
|
+
- `@api private` means the method is internal and carries no public compatibility
|
|
66
|
+
promise. It may still be Ruby-public when Phronomy components need to call it
|
|
67
|
+
through an explicit receiver.
|
|
68
|
+
- Ruby visibility is therefore not inferred from the `@api` annotation in
|
|
69
|
+
either direction.
|
|
70
|
+
|
|
71
|
+
Run the annotation coverage guard when changing documented methods:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ruby scripts/check_api_annotations.rb
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Ruby-public compatibility for the primary Stable/Beta product surface is
|
|
78
|
+
protected by `scripts/api_snapshot.rb` and
|
|
79
|
+
`spec/phronomy/api_compatibility_spec.rb`. Extension contracts whose calling
|
|
80
|
+
model is protected/private should be protected by focused specs for that
|
|
81
|
+
contract rather than by a repository-wide visibility inference rule.
|
|
82
|
+
|
|
83
|
+
Do not change Ruby visibility merely to make it match an `@api` annotation.
|
|
84
|
+
|
|
55
85
|
---
|
|
56
86
|
|
|
57
87
|
## Architecture Decision Records
|
|
@@ -69,6 +69,11 @@ persistence.workflow_states.save(
|
|
|
69
69
|
A stale expected revision raises `Phronomy::Persistence::ConflictError`. Upper
|
|
70
70
|
layers do not automatically reload and merge after such a conflict.
|
|
71
71
|
|
|
72
|
+
The normative contract for custom durable backends, including repository
|
|
73
|
+
semantics, transaction requirements, capabilities, durable codecs, and the Agent
|
|
74
|
+
watermark precondition, is documented in
|
|
75
|
+
[`docs/persistence-backends.md`](../persistence-backends.md).
|
|
76
|
+
|
|
72
77
|
### Activation is transient Runtime state
|
|
73
78
|
|
|
74
79
|
`AgentExecutionActivation` is not part of the Persistence contract. Live
|
data/docs/features.md
CHANGED
|
@@ -19,7 +19,7 @@ for production deployments.
|
|
|
19
19
|
|---|---|
|
|
20
20
|
| **Workflow** — Stateful, branching workflows with `wait_state` and explicit events | Stable |
|
|
21
21
|
| **Agent** — Stateful ReAct-style agents with stable `agent_id`, persistence-backed execution state, canonical history, guardrails, and conversation context | Stable |
|
|
22
|
-
| **Unified Persistence** — One durable backend abstraction for Agent state and Workflow `workflow_states`; live Agent/Workflow state remains owned by the active instance/session between durable commits | Beta |
|
|
22
|
+
| **Unified Persistence** — One durable backend abstraction for Agent state and Workflow `workflow_states`; live Agent/Workflow state remains owned by the active instance/session between durable commits; custom backends implement the documented Backend SPI and repository/transaction semantics | Beta |
|
|
23
23
|
| **Before-Large-Language-Model (LLM) Input Hook** — Three-tier per-call LLM input customization via `before_llm_input` and `LLMInputPatch` | Stable |
|
|
24
24
|
| **Context Management** — Journal + Context Policy + per-LLM-call Manifest with token-budget-aware selection and protocol-safe Tool Call / Tool message dependencies | Stable |
|
|
25
25
|
| **Filters** — Input/output transformation and blocking via `Filter::Base` | Beta |
|
|
@@ -78,6 +78,21 @@ Source declarations marked `@api private`, including most EventLoop/FSMSession a
|
|
|
78
78
|
OffloadPool internals, are implementation details and may change without the same
|
|
79
79
|
compatibility guarantees.
|
|
80
80
|
|
|
81
|
+
The YARD `@api` classification is independent from Ruby language visibility in
|
|
82
|
+
both directions. `@api public` marks a compatibility contract, but the Ruby
|
|
83
|
+
visibility still follows the intended calling model: ordinary APIs may be
|
|
84
|
+
public, subclass extension helpers may be protected, and constructors use
|
|
85
|
+
Ruby-private `initialize` behind `.new`. `@api private` means "internal/no
|
|
86
|
+
compatibility promise" and does not require a Ruby `private` declaration; some
|
|
87
|
+
internal methods remain Ruby-public because Phronomy components call them
|
|
88
|
+
through explicit receivers.
|
|
89
|
+
|
|
90
|
+
Persistence Backend SPI methods are a deliberate exception to the ordinary
|
|
91
|
+
application-facing interpretation of `@api public`: they are public extension
|
|
92
|
+
contracts for backend implementers, but application business logic should usually
|
|
93
|
+
interact with Agents/Workflows instead of calling low-level repository operations.
|
|
94
|
+
See [Persistence backend contract](persistence-backends.md).
|
|
95
|
+
|
|
81
96
|
`Phronomy::StateStore` is no longer a public backend abstraction. Workflow
|
|
82
97
|
durability is provided through `Phronomy::Persistence#workflow_states`; see the
|
|
83
98
|
0.19 migration guide when upgrading code that used `state_store:`.
|
|
@@ -88,6 +103,11 @@ durability is provided through `Phronomy::Persistence#workflow_states`; see the
|
|
|
88
103
|
|---|---|
|
|
89
104
|
| **`Phronomy::Diagnostics`** — Snapshot of EventLoop lag/queue state and OffloadPool activity | Experimental |
|
|
90
105
|
| **`Phronomy::Testing::FakeClock`** — Test-only deterministic clock helper | Beta |
|
|
106
|
+
| **`Phronomy::Testing::PersistenceContract`** — Explicitly loaded RSpec conformance suite for custom Persistence backends | Beta |
|
|
107
|
+
|
|
108
|
+
`Phronomy::Testing::PersistenceContract` is available only after explicit
|
|
109
|
+
`require "phronomy/testing/persistence_contract"`. Ordinary
|
|
110
|
+
`require "phronomy"` and production eager-load do not load RSpec.
|
|
91
111
|
|
|
92
112
|
For runtime ownership and the distinction between public lifecycle APIs and
|
|
93
113
|
private execution machinery, see [Runtime and concurrency](runtime-and-concurrency.md).
|
data/docs/migrations/0.19.md
CHANGED
|
@@ -79,12 +79,18 @@ persistence.workflow_states.save(
|
|
|
79
79
|
A stale revision must raise `Phronomy::Persistence::ConflictError` rather than
|
|
80
80
|
silently overwriting newer durable state.
|
|
81
81
|
|
|
82
|
-
Custom durable backends must also implement the
|
|
83
|
-
`Persistence#assert_agent_watermark!`
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
Custom durable backends must also implement the public Backend SPI operation
|
|
83
|
+
`Persistence#assert_agent_watermark!` used before next-LLM durable barriers. It
|
|
84
|
+
verifies the caller's Agent revision and Journal position without returning
|
|
85
|
+
replacement mutable state; mismatches raise `ConflictError`. SQL backends should
|
|
86
|
+
implement this check inside the same database transaction as the subsequent
|
|
87
|
+
durable write.
|
|
88
|
+
|
|
89
|
+
The complete custom-backend contract is documented in
|
|
90
|
+
[`docs/persistence-backends.md`](../persistence-backends.md). In particular,
|
|
91
|
+
custom backends must provide the required capabilities, transaction atomicity,
|
|
92
|
+
repository compare-and-swap semantics, and durable domain-object reconstruction
|
|
93
|
+
described there.
|
|
88
94
|
|
|
89
95
|
## Agent ownership and approval resume
|
|
90
96
|
|
|
@@ -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).
|
|
@@ -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
|