phronomy 0.28.0 → 0.29.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -5
  3. data/docs/application-recipes.md +229 -0
  4. data/docs/architecture/dx05-agent-scope.md +119 -0
  5. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  6. data/docs/architecture/error-api-review.md +245 -0
  7. data/docs/failure-reason-codes.md +45 -0
  8. data/docs/features.md +4 -1
  9. data/docs/getting-started.md +44 -43
  10. data/docs/ordered-event-delivery.md +54 -0
  11. data/docs/workflow-completion.md +92 -0
  12. data/gemfiles/http_minimum.gemfile +5 -0
  13. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  14. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  15. data/lib/phronomy/agent/api/agent.rb +13 -0
  16. data/lib/phronomy/agent/async_event_api.rb +111 -87
  17. data/lib/phronomy/agent/base.rb +74 -43
  18. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  19. data/lib/phronomy/agent/execution/exact_execution.rb +11 -9
  20. data/lib/phronomy/agent/execution/execution_coordinator.rb +16 -13
  21. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -1
  22. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -4
  23. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +1 -4
  24. data/lib/phronomy/agent/filterable.rb +21 -15
  25. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  26. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  27. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  28. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  29. data/lib/phronomy/agent/lifecycle/scope.rb +198 -0
  30. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +19 -0
  31. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +7 -7
  34. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  35. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +5 -9
  36. data/lib/phronomy/agent/recovery/recovery_support.rb +2 -8
  37. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  38. data/lib/phronomy/common/error.rb +65 -1
  39. data/lib/phronomy/content_store/base.rb +7 -2
  40. data/lib/phronomy/context/assembly.rb +11 -18
  41. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  42. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +5 -1
  43. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  44. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  45. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  46. data/lib/phronomy/execution/task_result.rb +7 -2
  47. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  48. data/lib/phronomy/generation/low_confidence_error.rb +2 -2
  49. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  50. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  51. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  53. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  54. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  55. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  56. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  57. data/lib/phronomy/tool/base.rb +25 -23
  58. data/lib/phronomy/tracing/base.rb +35 -6
  59. data/lib/phronomy/tracing/open_telemetry_tracer.rb +4 -29
  60. data/lib/phronomy/version.rb +1 -1
  61. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  62. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  63. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  64. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  65. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  66. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  67. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  68. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  69. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  70. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  71. data/lib/phronomy.rb +6 -0
  72. data/scripts/api_snapshot.rb +12 -0
  73. data/scripts/check_readme_ruby.rb +24 -29
  74. data/scripts/check_readme_runnable.rb +92 -81
  75. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  76. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  77. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  78. data/sig/phronomy/agent.rbs +18 -0
  79. data/sig/phronomy/embeddings/base.rbs +6 -0
  80. data/sig/phronomy/execution/execution.rbs +2 -2
  81. data/sig/phronomy/extensions.rbs +1 -0
  82. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  83. data/sig/phronomy/generation/low_confidence_error.rbs +6 -0
  84. data/sig/phronomy/integrations.rbs +14 -0
  85. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  86. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  87. data/sig/phronomy/storage/contracts.rbs +2 -2
  88. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  89. data/sig/phronomy/workflow.rbs +4 -0
  90. data/sig/phronomy.rbs +4 -0
  91. metadata +49 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f097c81530a33bd631974866ff26a0d2ec8b4793286c6dfa0bb0facaac4fdd83
4
- data.tar.gz: 80b9980737c80446f660d9a058aa1b898bdcdec452e517764261c386c32a1e78
3
+ metadata.gz: 6ee0a6e2cc02a9014a1f6b4b0587474255697960b0e1589070a7846d038452ad
4
+ data.tar.gz: eb866c788b4dbaf5c560ec0a6a9fe1ef641d630932c7f4309c5ca0741dce2ad0
5
5
  SHA512:
6
- metadata.gz: 2cacf04ecea168021b36835f12901723380a7a908d0173f23b564e9bb67d4f85cf46bf958949fd4257043393234ef5acc7e8d9cecdcf68beac61b0c7ce454084
7
- data.tar.gz: c7f7b1d9cbcd54d0d0d9bb7583e5587ecadfafa987788f44e8c8abc0f9fa75241092d63a543669b55a6837fc1c2c09c102cf514578b6c266b2c85ff17d2ba2cc
6
+ metadata.gz: a4970e936adca3d87ee8a8a18469630a563df8fb8a07f06796f4a4bfcae3b1e97b67a51869fdbb68760892201deb12f5f765c082cdfe8559c5f6d0c5ad10dd1a
7
+ data.tar.gz: 10c461ef9e86a476cc73020dc1167d6b80564f02f47c3da0161a7da8a34953be96efa236ebcfdf938c7b8a5bfeb2ea6ad3fdaca137d6f70cd5238a71bd6cb829
data/README.md CHANGED
@@ -21,7 +21,7 @@ tracking `main` directly.
21
21
 
22
22
  - **Agent** — stateful, persistence-backed LLM agent with canonical execution history.
23
23
  - **Persistence** — shared atomic scopes with domain-owned Agent/Team stores and Workflow checkpoints.
24
- - **Workflow** — state-machine-driven application workflow with explicit events and wait states.
24
+ - **Workflow** — state-machine-driven application workflow with task states, explicit events and wait states.
25
25
  - **Tool / Capability** — callable application capability exposed to an Agent; application-defined Tools subclass `Phronomy::Tool::Base`.
26
26
  - **Multi-Agent Handoff** — semantic Source-to-Target responsibility transfer with policy-bounded Context projection and persisted active responsibility and exact Target recovery within one Persistence domain.
27
27
  - **EventLoop + FSMSession** — the framework control plane for logical lifecycle coordination.
@@ -35,6 +35,19 @@ tracking `main` directly.
35
35
 
36
36
  See [Features and Application Programming Interface (API) stability](docs/features.md) for the full feature matrix.
37
37
 
38
+ The following application APIs are part of unreleased development:
39
+
40
+ | API | Stability | Contract |
41
+ |---|---|---|
42
+ | `Agent.open_scope`, `Agent::Scope`, `Agent::Base#unload_async / #unload!`, `AgentUnloadedError` and `Agent::ScopeCleanupError` | Beta | Runtime-bound ownership and safe release of idle Agent incarnations without changing saved history or closing the Store; [contract](docs/architecture/dx05-agent-scope.md) |
43
+ | `Workflow::Builder#task_state` and `WorkflowTaskResumeError` | Beta | State-entry-scoped Task completion, live cancellation and explicit restart rejection; [contract](docs/architecture/dx05-workflow-task-state.md) |
44
+ | `Phronomy::Integrations::OrderedEventDelivery` | Beta | Optional ordered delivery with bounded admission and explicit drain lifetime; [usage](docs/ordered-event-delivery.md) |
45
+ | `Phronomy::Error#code` and error constructor `code:` | Beta | Optional reason codes and bounded causes retained in saved failure diagnostics; [meaning and compatibility](docs/architecture/error-api-review.md) |
46
+ | `Phronomy::AgentNotFoundError#agent_id` | Beta | Identifies absence of the requested Agent root; referenced history and Content failures retain their own errors; [migration](docs/architecture/error-api-review.md) |
47
+ | `Phronomy::MultiAgent::TeamNotFoundError` and `TeamBusyError`, with `team_id` | Beta | Team-owned absence and busy errors; child Agent failures retain their Agent classification; [migration](docs/architecture/error-api-review.md) |
48
+ | `Phronomy::Embeddings::AuthenticationError` and `RateLimitError` | Beta | Embeddings-owned transport error subclasses retaining the provider cause; [migration](docs/architecture/error-api-review.md) |
49
+ | `Phronomy::Tracing::Base.trace_with` | Beta | Uses the tracer start/finish SPI, attempts finish once, and preserves a primary application error; [contract](docs/architecture/error-api-review.md) |
50
+
38
51
  ## Installation
39
52
 
40
53
  Add Phronomy to your Gemfile:
@@ -49,7 +62,10 @@ Then run:
49
62
  bundle install
50
63
  ```
51
64
 
52
- This refactoring branch requires RubyLLM 2.0.x. See the
65
+ Phronomy 0.28 requires RubyLLM 2.0.x. Faraday 2.1.0 is the tested minimum
66
+ supported version; the `~> 2.1` requirement allows versions from 2.1 up to,
67
+ but excluding, 3.0. CI checks fresh dependency resolution and the exact minimum
68
+ with MCP 1.0.0. See the
53
69
  [RubyLLM 2 and token-ownership migration](docs/architecture/rubyllm-2-token-ownership.md)
54
70
  for removed input-budget overrides and the in-flight Tool-manifest upgrade boundary.
55
71
 
@@ -91,9 +107,7 @@ result = ResearchAgent.new.invoke("What happened in AI research this week?")
91
107
  puts result[:output]
92
108
  ```
93
109
 
94
- `Phronomy::Tool::Base` is the public authoring name for the existing Tool base
95
- class. The legacy `Phronomy::Tool::Base` constant remains
96
- valid for compatibility.
110
+ `Phronomy::Tool::Base` is the public base class for application-defined Tools.
97
111
 
98
112
  For non-blocking top-level use, call `invoke_async` and keep the returned
99
113
  `Phronomy::TaskResult`. Inside Phronomy lifecycle callbacks, do not block waiting for
@@ -152,6 +166,7 @@ contracts, timeout/cancellation semantics, metrics, and callback rules.
152
166
  ## Documentation
153
167
 
154
168
  - [Getting started](docs/getting-started.md) — installation, RubyLLM setup, Agent/Workflow basics, persistence, streaming.
169
+ - [Application recipes](docs/application-recipes.md) — complete persistence, streaming, and Workflow examples, including explicit failure handling.
155
170
  - [Features and API stability](docs/features.md) — public feature matrix and stability labels.
156
171
  - [Architecture](docs/architecture.md) — canonical current explanatory architecture entry and authority navigation.
157
172
  - [Runtime and concurrency](docs/runtime-and-concurrency.md) — EventLoop, FSMSession, TaskResult, OffloadPool, cancellation, observability.
@@ -0,0 +1,229 @@
1
+ # Application recipes
2
+
3
+ Start with [Getting started](getting-started.md). Every `ruby runnable` block
4
+ below is a complete example after `require "phronomy"` and provider configuration.
5
+ CI substitutes only the LLM adapter; Agent, Tool and Workflow operations are real.
6
+
7
+ ## Choose the completion channel
8
+
9
+ | Need | Public API | Caller responsibility |
10
+ |---|---|---|
11
+ | One final answer | `agent.invoke(input)` | Call from an external thread |
12
+ | Nonblocking final answer | `agent.invoke_async(input)` | Observe the returned TaskResult |
13
+ | Intermediate events | Construction-time `on_event:` | Keep the callback short |
14
+ | Streamed tokens | `stream` / `stream_async` with a listener | Move network delivery out of the callback |
15
+ | Durable approval | `approve_async` with the request IDs | Apply the application's authorization decision |
16
+ | State-owned asynchronous result | `task_state(name, start:, apply:)` | Framework correlates completion to state entry |
17
+ | Live Workflow notification | `workflow.signal(...)` | Check admission; correlate the waiting operation |
18
+
19
+ `TaskResult#wait_result` waits at an external application boundary. Completion
20
+ callbacks and Agent listeners must not block EventLoop. A wait timeout does not
21
+ cancel the underlying operation. The caller owns cancellation and cleanup.
22
+
23
+ ## Create, load and get
24
+
25
+ ```ruby runnable
26
+ class ConversationAgent < Phronomy::Agent::Base
27
+ agent_definition id: "recipe-conversation", version: 1
28
+ model "gpt-4o-mini"
29
+ instructions "Answer briefly."
30
+ end
31
+
32
+ store = Phronomy::PersistenceComposition.in_memory.agent
33
+ agent = ConversationAgent.create(agent_id: "recipe-session", persistence: store)
34
+ puts agent.invoke("Hello").fetch(:output)
35
+
36
+ # A live lookup is not a database read. Loading a live identity returns its owner.
37
+ raise "Unexpected owner" unless ConversationAgent.get("recipe-session").equal?(agent)
38
+ raise "Unexpected load" unless ConversationAgent.load("recipe-session", persistence: store).equal?(agent)
39
+
40
+ def load_existing_agent(agent_class:, agent_id:, persistence:)
41
+ agent_class.load(agent_id, persistence: persistence)
42
+ rescue Phronomy::AgentNotFoundError => error
43
+ raise unless error.agent_id == agent_id.to_s
44
+ nil
45
+ end
46
+
47
+ missing = load_existing_agent(
48
+ agent_class: ConversationAgent, agent_id: "missing-session", persistence: store
49
+ )
50
+ raise "Expected a missing Agent" unless missing.nil?
51
+ ```
52
+
53
+ This application treats only absence as `nil`. Storage unavailability, state
54
+ conflicts and recovery requirements remain errors. `create` is not upsert; do
55
+ not catch every persistence failure and create a replacement conversation.
56
+ The in-memory store is for this example; a durable backend must outlive Runtime
57
+ to support process restart. See [persistence failures](migrations/persistence-failure-contracts.md).
58
+
59
+ ## Streaming and final results
60
+
61
+ ```ruby runnable
62
+ class StreamingAgent < Phronomy::Agent::Base
63
+ agent_definition id: "recipe-streaming", version: 1
64
+ model "gpt-4o-mini"
65
+ instructions "Explain briefly."
66
+ end
67
+
68
+ events = Queue.new
69
+ agent = StreamingAgent.new(on_event: ->(event) {
70
+ events << event if event.type == :token
71
+ })
72
+
73
+ task = agent.stream_async("Explain asynchronous results")
74
+ result = task.wait_result
75
+ puts result.fetch(:output)
76
+ puts "Received #{events.size} token events"
77
+ ```
78
+
79
+ The unbounded Queue is an in-process teaching example. A Web application needs
80
+ bounded admission and ordered delivery outside the listener, such as Example 18.
81
+ Register the listener at `new`/`create`/first `load`; per-invocation listener
82
+ arguments or blocks are rejected. Do not replace a listener by loading an
83
+ already-live Agent. See [approval](getting-started.md#human-in-the-loop-approval)
84
+ for the separate durable approval identifiers and application decision.
85
+
86
+ ## Workflow task states
87
+
88
+ When a Workflow owns the start of an operation, use `task_state` to keep the
89
+ application focused on its operation, result fields and routing decisions.
90
+ The example below returns the original error to its caller if either invocation
91
+ fails; it does not silently retry or convert a failed invocation into an answer.
92
+
93
+ ```ruby runnable
94
+ class TwoAnswersState
95
+ include Phronomy::WorkflowContext
96
+ field :question, type: :replace
97
+ field :answers, type: :replace, default: -> { [] }
98
+ end
99
+ class TwoAnswersAgent < Phronomy::Agent::Base
100
+ agent_definition id: "recipe-two-answers", version: 1
101
+ model "gpt-4o-mini"
102
+ instructions "Answer briefly."
103
+ end
104
+ agent = TwoAnswersAgent.new
105
+ workflow = Phronomy::Workflow.define(TwoAnswersState) do
106
+ initial :asking
107
+ task_state :asking,
108
+ start: ->(state) { agent.invoke_async(state.question) },
109
+ apply: ->(state, result) { state.merge(answers: state.answers + [result.fetch(:output)]) }
110
+ state :decide
111
+ transition from: :asking, to: :decide
112
+ transition from: :decide, to: :__finish__, guard: ->(state) { state.answers.length >= 2 }
113
+ transition from: :decide, to: :asking
114
+ end
115
+ puts workflow.invoke({question: "Explain completion handles"}).answers
116
+ ```
117
+
118
+ Expected business failures can be converted into application values before
119
+ returning the source Task, for example using `TaskResult.all_settled` and `map`.
120
+ Phronomy does not select a fallback or retry policy. See the
121
+ [full contract](architecture/dx05-workflow-task-state.md).
122
+
123
+ ## Workflow completion and failures
124
+
125
+ The following **legacy low-level signal recipe** is retained for existing users
126
+ and its compatibility tests. New state-owned asynchronous operations should use
127
+ `task_state` above. The restrictions below apply to this manual recipe, not to
128
+ the new task-state API.
129
+
130
+ This recipe targets **one live waiting stage**. Do not restart, reenter or replace
131
+ that stage while a result is pending. A logical `workflow_instance_id` alone does
132
+ not identify a particular execution or waiting operation. For those wider cases,
133
+ application operation IDs and receiver-side guards are necessary.
134
+
135
+ ```ruby runnable
136
+ def request_answer(agent:, workflow:, workflow_instance_id:, question:,
137
+ on_delivery_error:)
138
+ task = agent.invoke_async(question).map do |result|
139
+ {answer: result.fetch(:output)}
140
+ end
141
+
142
+ task.on_complete do |payload, operation_error|
143
+ delivery_error = nil
144
+ begin
145
+ accepted = workflow.signal(
146
+ workflow_instance_id: workflow_instance_id,
147
+ event: :answer_ready,
148
+ payload: operation_error ? {error: operation_error} : payload
149
+ )
150
+ unless accepted
151
+ delivery_error = Phronomy::Error.new("Workflow did not accept answer_ready")
152
+ end
153
+ rescue => error
154
+ delivery_error = error
155
+ end
156
+ on_delivery_error.call(delivery_error) if delivery_error
157
+ end
158
+
159
+ nil
160
+ end
161
+
162
+ class AnswerAgent < Phronomy::Agent::Base
163
+ agent_definition id: "recipe-answer", version: 1
164
+ model "gpt-4o-mini"
165
+ instructions "Answer briefly."
166
+ end
167
+
168
+ class AnswerState
169
+ include Phronomy::WorkflowContext
170
+ field :question, type: :replace
171
+ field :answer, type: :replace
172
+ field :error_message, type: :replace
173
+ end
174
+
175
+ agent = AnswerAgent.new
176
+ delivery_errors = Queue.new
177
+ workflow = nil
178
+ workflow = Phronomy::Workflow.define(AnswerState) do
179
+ initial :asking
180
+ state :asking
181
+ state :done
182
+ entry :asking, ->(state) {
183
+ request_answer(
184
+ agent: agent, workflow: workflow,
185
+ workflow_instance_id: state.workflow_instance_id, question: state.question,
186
+ on_delivery_error: ->(error) { delivery_errors << error }
187
+ )
188
+ state
189
+ }
190
+ transition from: :asking, on: :answer_ready, to: :done,
191
+ action: ->(state, event) {
192
+ error = event.payload[:error]
193
+ error ? state.merge(error_message: error.message) : state.merge(answer: event.payload.fetch(:answer))
194
+ }
195
+ transition from: :done, to: :__finish__
196
+ end
197
+
198
+ final = workflow.invoke(
199
+ {question: "What is a completion handle?"},
200
+ config: {workflow_instance_id: "recipe-answer"}
201
+ )
202
+ puts final.error_message || final.answer
203
+ raise delivery_errors.pop unless delivery_errors.empty?
204
+ ```
205
+
206
+ The receiver handles both answer and error; changing only the sender would be
207
+ incomplete. Store a serializable diagnostic, not the exception object, in the
208
+ Workflow context. Result mapping failures (such as a missing `:output`) also
209
+ become operation failures. Synchronous submission errors still propagate to
210
+ the caller of `request_answer`.
211
+
212
+ `signal == true` means admission, not transition completion or durable success.
213
+ `false` and signal exceptions reach `on_delivery_error` once without automatic
214
+ retry. This callback must be short and nonblocking. Its own exceptions follow
215
+ TaskResult's existing callback logging policy; they do not change the settled
216
+ operation result. Logging depends on the configured logger.
217
+
218
+ Delivery rejection does not itself settle a waiting Workflow. The application
219
+ must observe its diagnostic channel and decide whether to cancel or recover.
220
+ The synchronous invocation above illustrates successful delivery. Production
221
+ callers needing a wait limit should use `invoke_async` and manage both the
222
+ Workflow TaskResult and the diagnostic channel at their external boundary.
223
+
224
+ No generic Workflow forwarding helper is required by these recipes. A helper
225
+ must first establish how an old result is prevented from reaching a new waiting
226
+ stage; internal execution generations or automatic recovery are not implied.
227
+
228
+ For an application-local extraction with the same limited lifetime, see the
229
+ [bounded completion recipe](workflow-completion.md). It is not a new library API.
@@ -0,0 +1,119 @@
1
+ # Agent scope and incarnation lifetime (DX05-E core)
2
+
3
+ An application decides how long it needs an Agent. C04 owns the mechanics for
4
+ acquiring that incarnation and releasing it safely. A scope does not delete saved
5
+ Agents, stop other Agents, close an application-owned Store, or infer whether a
6
+ business operation should be retried.
7
+
8
+ ## Application change
9
+
10
+ Before, a long-running application could create Agents for each request, but
11
+ the Runtime retained their mutable incarnations until shutdown. `close!` changes
12
+ the durable Agent lifecycle; `purge!` deletes saved state. Neither is a substitute
13
+ for releasing a process-local incarnation.
14
+
15
+ ```ruby
16
+ agent = ReviewAgent.create(persistence: store)
17
+ agent.invoke(text) # the Runtime still owns this incarnation afterwards
18
+ ```
19
+
20
+ After, the application declares the ownership boundary once:
21
+
22
+ ```ruby
23
+ Phronomy::Agent.open_scope do |agents|
24
+ agent = agents.create(ReviewAgent, persistence: store)
25
+ agent.invoke_async(text)
26
+ end
27
+ ```
28
+
29
+ `open_scope` waits for a returned TaskResult and returns its resolved value (or
30
+ raises the original exception). A plain block value is returned unchanged.
31
+ It then attempts to unload every Agent it acquired. A Workflow can use
32
+ `agents.create_async` or `agents.load_async` from EventLoop and compose their
33
+ TaskResults. The outer application keeps the scope alive until the Workflow has
34
+ finished. A scope is not serializable Workflow context.
35
+
36
+ An existing application-owned Agent cannot be adopted by `scope.load`: that
37
+ would give the scope permission to release someone else's object. The attempt
38
+ raises `AgentBusyError`. Repeated load in the same scope returns its live instance,
39
+ after checking class and Store compatibility. A listener cannot be replaced on
40
+ an already live instance. Existing `Agent.load`/`get` lookup semantics remain;
41
+ obtaining a reference does not extend its owning scope's lifetime.
42
+
43
+ ## Release contract
44
+
45
+ `agent.unload_async` returns `TaskResult<Boolean>` and is EventLoop-safe.
46
+ `agent.unload!(timeout: nil)` waits externally. Both preserve all durable state:
47
+ root and context revisions, journal position, contents, history, metadata and
48
+ lifecycle status. A closed Agent remains closed after reload.
49
+
50
+ Release requires an exact live owner, no accepted operations or execution
51
+ admission (including approval and recovery waits), and an idle durable execution
52
+ ledger with no retention references. C04 interprets only the presence of opaque
53
+ retention references; their owning domain decides when to remove them.
54
+
55
+ Concurrent release requests share the pending handle. A successful release
56
+ returns `true`; an already unloaded reference returns `false`. A wait timeout
57
+ does not cancel the release. Store check failure rolls back the reservation and
58
+ preserves the original error. Runtime shutdown closes admission and drains
59
+ already accepted transition completion without creating a replacement Runtime.
60
+
61
+ New operations cannot enter during release. Old Agent operations fail with
62
+ `AgentUnloadedError` (`code: "agent.unloaded"`, `agent_id`); asynchronous methods
63
+ return failed TaskResults. Immutable snapshots and `agent_id` remain usable.
64
+ A later load creates a new instance and can bind a new listener. The old
65
+ reference cannot mutate or release that new owner. Purged Agents keep their
66
+ existing `AgentPurgedError` semantics.
67
+
68
+ A concurrent `get` or `load` during release raises `AgentBusyError` immediately;
69
+ it does not wait or create a second owner. Accepted operations make unload fail
70
+ with Busy at its admission gate. In particular, unload inside an Agent terminal
71
+ listener is Busy while that listener is executing. Exit the outer application
72
+ scope after result delivery to release it. Existing immutable local-state reads
73
+ remain available while Runtime shutdown is draining an incomplete transition;
74
+ new execution and mutation admission stays closed.
75
+
76
+ ## Failure and cancellation
77
+
78
+ Scope tracks materialization receipts before starting work and registers acquired
79
+ Agents before returning them. Closure stops new acquisition and waits for the
80
+ physical completion of accepted materialization, even when its handle was
81
+ cancelled. Indeterminate durable create keeps the C04 recovery reservation and
82
+ logs the Agent ID; it is never silently made creatable again.
83
+
84
+ Scope does not cancel or wait for unjoined child execution as an implicit
85
+ cleanup policy. An active Agent is retained and cleanup reports `AgentBusyError`.
86
+ If the main operation failed, its same exception is preserved and secondary
87
+ cleanup failures are logged with Agent IDs. Logger failure cannot replace it.
88
+ If the main operation succeeded, failed cleanup raises `Agent::ScopeCleanupError`
89
+ (`code: "agent.scope_cleanup_failed"`). `result` preserves the successful value;
90
+ `failures` is an array of `{agent_id:, error:}` with the original exceptions.
91
+ All acquired Agents are attempted. No automatic retry, purge, or retention
92
+ removal is performed.
93
+
94
+ ## Responsibility and ordering
95
+
96
+ - C04 owns scope receipts, exact incarnation identity, operation admission,
97
+ busy/retention rules, invalidation and cleanup errors.
98
+ - C07 supplies the existing transaction boundary. C04's Agent Store supplies
99
+ `assert_idle!`, `guard_agent!` and retention reads; neither Persistence nor
100
+ physical Storage gains incarnation release policy.
101
+ - C13 supplies existing TaskResult and physical-completion contracts. It gains
102
+ no scope membership, Agent busy rules or unload states.
103
+ - Engine supplies existing worker execution, serial EventLoop delivery and
104
+ shutdown participation. It gains no Agent-specific states.
105
+ - The operation lease and release reservation share the C04 ownership mutex.
106
+ No Store transaction, Runtime call, application callback or blocking wait runs
107
+ under that mutex. Accepted runner continuations use C04 internal snapshots;
108
+ callbacks initiating a new public operation must acquire a new lease.
109
+ - Agent operation completion is distinct from cancelling its public handle.
110
+ C04 acknowledges release after domain terminal/listener delivery and physical
111
+ worker completion. A queued or physically running cancelled operation still
112
+ blocks release.
113
+
114
+ For notifications, finish child processing, drain notifications, then exit the
115
+ Agent scope. `run_once` retains its existing Store and lifetime behavior; merely
116
+ calling it does not opt into scope cleanup.
117
+
118
+ This is P5 (core). P6 migrates application examples and performs cross-repository
119
+ acceptance against the published or pinned candidate core.
@@ -0,0 +1,134 @@
1
+ # DX05-D: Workflow-owned asynchronous task states
2
+
3
+ Status: unreleased Beta implementation. Based on DX05-A core `c673c958c`.
4
+
5
+ ## Problem and intended application boundary
6
+
7
+ A Workflow application describes which operation a stage starts, how its result
8
+ changes business state, and where that state goes next. Repeating callbacks,
9
+ completion events, error envelopes and state-entry identifiers obscures those
10
+ choices. Workflow already owns the live execution and state-entry lifetime, so it
11
+ can own this correlation without knowing how an Agent, Tool or LLM runs.
12
+
13
+ The business flow is preserved. Failure, cancellation, stale-result and delivery
14
+ semantics become explicit and stronger; this is not a claim that every failure
15
+ path of the previous manual recipe was equivalent.
16
+
17
+ ## Before and after
18
+
19
+ Previously, each state manually arranged delivery (simplified successful path):
20
+
21
+ ```ruby
22
+ workflow = nil
23
+ workflow = Phronomy::Workflow.define(AnswerState) do
24
+ initial :asking
25
+ state :asking
26
+ entry :asking, ->(state) {
27
+ id = state.workflow_instance_id
28
+ agent.invoke_async(state.question).on_complete do |value, error|
29
+ workflow.signal(workflow_instance_id: id, event: :answer_ready,
30
+ payload: {value: value, error: error})
31
+ end
32
+ state
33
+ }
34
+ transition from: :asking, on: :answer_ready, to: :__finish__,
35
+ action: ->(state, event) {
36
+ raise event.payload[:error] if event.payload[:error]
37
+ state.merge(answer: event.payload.fetch(:value).fetch(:output))
38
+ }
39
+ end
40
+ ```
41
+
42
+ That snippet also needs rejection handling and entry-specific guards for safe
43
+ reentry. The application now writes only the operation and successful update:
44
+
45
+ ```ruby
46
+ workflow = Phronomy::Workflow.define(AnswerState) do
47
+ initial :asking
48
+ task_state :asking,
49
+ start: ->(state) { agent.invoke_async(state.question) },
50
+ apply: ->(state, result) { state.merge(answer: result.fetch(:output)) }
51
+ transition from: :asking, to: :__finish__
52
+ end
53
+ ```
54
+
55
+ `start` receives an isolated context snapshot, including current phase and
56
+ Workflow identity. It must return a `Phronomy::TaskResult` promptly. It must not
57
+ perform blocking I/O or wait for another Task. Use `Blocking.call_async` for real
58
+ blocking work. `apply` receives the current context and successful value, runs
59
+ briefly on EventLoop, and returns the same context type or nil. Normal transition
60
+ guards run after `apply`. Nil means no context replacement, not failure.
61
+
62
+ A task state may have ordinary entry/exit actions and external transitions.
63
+ Leaving it abandons observation of its source. Declaring the same state as both
64
+ `state`/`wait_state` and `task_state` is rejected. Ordinary entry/transition actions
65
+ still cannot return a TaskResult. Business signals keep their existing API.
66
+
67
+ ## Contract ownership
68
+
69
+ | Owner | Responsibility |
70
+ |---|---|
71
+ | C02 Workflow | Task-state definition; live execution and state-entry reception; once-only consumption; success application, failure/cancellation classification; stale-result and queued-progression rejection; restart policy |
72
+ | C13 Execution | Thread-safe TaskResult notification and cancellation subscription mechanisms |
73
+ | I07 Workflow runtime binding | Connect C02 decisions to the existing FSM, owner EventLoop and owner Runtime timer/shutdown services |
74
+ | Engine | Generic event/session delivery and lifecycle; no task-state or Agent policy is added |
75
+ | C04 Agent and application | Agent execution, business transformation/fallback, child cancellation policy and producer lifetime |
76
+
77
+ C02 uses its execution-environment port and receives an injected session
78
+ connection. It does not select Runtime or depend on I07. `WorkflowTaskSession`
79
+ in I07 adapts the generic FSMSession. Engine source is unchanged.
80
+
81
+ A revocable C02 reception is created before `start`. Source callbacks retain only
82
+ that reception. Closing it drops source and registry references. Completion is
83
+ sent to the originating registry under an opaque execution owner token, never
84
+ rerouted by a durable Workflow ID. Receiver-side validation checks the current
85
+ entry and consumes once. Exits, reentry, cancellation and terminal decisions
86
+ revoke it. Queued automatic progression also carries an entry-specific key:
87
+ an external transition between result application and automatic dispatch cannot
88
+ advance a later entry. These private messages are not public business signals.
89
+
90
+ ## Outcomes and lifetime
91
+
92
+ | Condition | Caller-visible result |
93
+ |---|---|
94
+ | Start throws, source fails, or apply throws | Workflow fails with the same error object |
95
+ | Source status is `cancelled` | Workflow TaskResult is cancelled with the source error |
96
+ | Source status is `failed` with a CancellationError | Remains failed; class alone does not imply cancellation |
97
+ | Explicit parent token cancellation/deadline | Cancel Workflow observation; do not implicitly cancel the source |
98
+ | Old or duplicated source notification | Ignore; no application or retry |
99
+ | Current completion delivery rejects/throws | Fail on the owner; if owner delivery is wholly unavailable, settle observation only and retain admission until shutdown cleanup |
100
+ | Runtime shutdown while waiting | Settle observation and revoke subscriptions; no claim that child work physically stopped |
101
+ | Known durable terminal result | Use existing save/readback classification and release rules |
102
+ | Unknown durable terminal result | Fail the observation while retaining recovery-required ownership |
103
+
104
+ Cancellation is serialized on the owning EventLoop. Once terminal persistence
105
+ has begun, its existing durable barrier remains authoritative; later cancellation
106
+ does not reverse a committed or uncertain terminal decision. A source passed to
107
+ the state has an independent lifetime. Applications explicitly pass cancellation
108
+ controls to children when desired; they own Agent release and side effects.
109
+
110
+ This API creates no thread/worker for logical waits. Actual I/O and existing
111
+ tracing or checkpoint work may still use the bounded pool. Expected business
112
+ failures may be transformed into ordinary values through TaskResult composition.
113
+ The API does not infer retry, fallback, durable child execution or delivery ACKs.
114
+
115
+ ## Persistence and restart
116
+
117
+ Normal completion and ordinary `wait_state` halts use existing Workflow
118
+ checkpoints. Resuming an ordinary wait into a new task state is supported. Pending
119
+ TaskResults, callbacks and entry keys are process-local and are never serialized.
120
+ Restoring a snapshot whose phase is a task state raises
121
+ `Phronomy::WorkflowTaskResumeError` (C02, subclass of `Phronomy::Error`, code
122
+ `workflow.task_resume_unsupported`) before running its start callback. The
123
+ application must reconcile that operation explicitly instead of automatically
124
+ repeating external side effects. F01 durable child execution remains separate.
125
+
126
+ ## Verification
127
+
128
+ The new task-state regression suite covers immediate and delayed results,
129
+ repeated entry, old success/failure/cancellation, duplicate callbacks, external
130
+ transition races, rejected guards, delivery failure, shutdown, source/parent
131
+ cancellation, owner Runtime selection, retained-reference revocation, strict
132
+ callback result types, normal durable halt/resume, restart rejection and unknown
133
+ durable outcomes. Existing Workflow, TaskResult, API and executable-documentation
134
+ tests remain active. Examples 01/03/04/07/14/20/25/26 use the new API.