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
@@ -0,0 +1,245 @@
1
+ # Error API review (DX05-A / EA01–EA06)
2
+
3
+ Applications should branch on the meaning of a failed public operation without
4
+ knowing its backend or reconstructing it from an error message. The review uses
5
+ core `5b9d1047cc1351a935a7871ae7705c65166ea971` and examples
6
+ `5f4d58dc9432a84cb4421d3cc7dc2da92aee3c72` as its baseline.
7
+
8
+ The original DX05-A change implements the error API part of the follow-up design. DX05-B
9
+ (notification wiring), C (review result collection), D (`task_state`) and E
10
+ (Agent scope/unload) remain separate work. Their proposed APIs are not provided
11
+ by this patch. EA06's integration with D/E cannot be accepted before those APIs
12
+ exist. Live PostgreSQL, Redis, provider services and complete Rails execution
13
+ are separate acceptance gates; see the delivery's verification record.
14
+
15
+ The subsequent, unreleased DX05-D change now provides `task_state`; see
16
+ [its design and acceptance boundary](dx05-workflow-task-state.md).
17
+ Its error decision is included below. Agent scope/unload (DX05-E) remains
18
+ separate work. This does not change the historical DX05-A baseline above.
19
+
20
+ ## Rules and ownership
21
+
22
+ * The owning Contract defines the failure category. A class is added only when
23
+ a caller has a distinct, stable decision to make. Detailed reasons use an
24
+ optional String `code`; they do not imply retry safety, rollback or worker exit.
25
+ * H00 owns the base Error and generic diagnostic projection/reconstruction.
26
+ It does not identify Agents, choose a retry policy or interpret domain codes.
27
+ * C07 Persistence and C12 Storage retain independent error hierarchies.
28
+ StorageBoundary translates only its existing known categories with an explicit
29
+ cause. A storage reason is not automatically a persistence reason.
30
+ * C13 transports the exception. It does not classify Agent, Team or Workflow
31
+ failures. `:cancelled` and a failed task carrying CancellationError remain
32
+ different facts. Partial `outcomes` remain on their existing execution errors.
33
+ * ArgumentError/TypeError describe Ruby/API input or mapping errors where already
34
+ documented. NotImplementedError marks an abstract extension point; it is not
35
+ converted into a provider failure.
36
+ * Existing message-only constructors remain valid. Seven public specialized
37
+ constructors now accept optional `code:` without changing their attributes.
38
+ New target-absence/busy constructors own fixed codes and object identities.
39
+ * Sync/async operation failures keep their category, reason and original cause.
40
+ Existing entry-time validation and submission rejection may still raise before
41
+ a TaskResult is returned. A TaskResult is not a promise of rollback.
42
+
43
+ ## Operation decisions and acceptance
44
+
45
+ Each row names the operation family reviewed, its observed channel, the retained
46
+ asymmetry and the action. This is an evaluation of these operations, not a claim
47
+ that every possible backend failure has been injected.
48
+
49
+ | Owner | Public operations / failure meaning / channel | Decision and application effect | Acceptance evidence |
50
+ |---|---|---|---|
51
+ | C01 | TeamCoordinator.create/load: root absence, duplicate identity, uncertain create; direct raise | Add TeamNotFoundError only around the requested root load. Duplicate create retains Persistence conflict: a separate AlreadyExists class adds no necessary recovery choice here. A failed create readback keeps the original failure. | team_coordinator_spec; durable_coordination_spec |
52
+ | C01 | invoke/admission, TeamExecutionRepository.create_active/save/assert_idle!: active Team run | TeamBusyError with team_id and team.busy replaces AgentBusyError only for Team ownership. Child-Agent failures retain their own classes. No AgentBusy alias or inheritance. | execution_constraint_translation_spec; shared SQL coordination contract |
53
+ | C01 | HandoffRunner.invoke/cancel, Orchestrator fan-out, operation rejection | Retain ConfigurationError, HandoffError, StateConflictError, recovery requirements and on_error policy. Missing retained children are not missing Teams. Readback reconciliation may itself fail and does not prove absence. OperationRejected stays private; public code team.enqueue_after_finalize is unchanged. | durable_coordination_spec; integration/team_coordinator_spec; SQL reason diagnostics |
54
+ | C02 | Workflow definition/action, WorkflowContext writes, state CAS, waiting/resume | Retain ConfigurationError, WorkflowContextOwnershipError, WorkflowAsyncActionError and persistence conflict. Workflow has no equivalent target-root load API to justify another NotFound class. DX05-D adds WorkflowTaskResumeError with workflow.task_resume_unsupported only for unsupported restoration of a task-state phase, before any start side effect. Start/source/apply failures keep their original errors, and task cancellation remains distinct from a failed CancellationError. Agent scope is separate. | workflow and execution unit/integration suites; task_state_spec; shared SQL task-state contract (live PostgreSQL acceptance pending) |
55
+ | C03 | GeneratorVerifier.invoke; parse fallback and confidence decision | Preserve ParseError-only fallback and LowConfidenceError.result. Add optional constructor code; do not absorb provider/callback errors into a GenerationError. | generator_verifier specs; error_api_diagnostics_spec |
56
+ | C04 | Base.load, create/get, invoke/resume/purge | Add AgentNotFoundError only at the requested root. Journal/content/execution absence remains persistence failure. Preserve foreign AgentNotFound identity. AlreadyExists/Busy/Purged, uncertain purge and recovery-required retain their meaning. | error_api_boundaries_spec; Agent lifecycle/recovery suites |
57
+ | C04 | stream listener policy; result/failure persistence | StreamCallbackError keeps event_type/original_error/result, with optional code. Listener :fail_task versus :log_and_continue remains explicit; no unconditional callback swallowing. | stream_callback_error_policy_spec; error_api_diagnostics_spec |
58
+ | C05 | Assembly.prepare and policy invocation; plan/budget validation | Keep BudgetExceededError and ManifestError. Reuse C15 public trace_with, metadata only; a finish failure cannot mask policy failure or export the returned context. | error_api_boundaries_spec; Context suites |
59
+ | C06 | Tool.call / native async operation; schema, execution, async handle validity | Keep ToolError class; supply tool.invalid_arguments / tool.execution_failed / tool.invalid_async_result at owned conversion sites. Explicit async cause fixes delayed-failure information loss. Existing ToolError/cancellation pass through unchanged. :suppress survives logger failure. | tool/async_operation_spec; tool/base_spec; schema_error_spec |
60
+ | C07 | StorageBoundary, transaction, SaveOutcome, SnapshotComparison | Retain known-category mapping and original cause. Unknown I/O/application failures pass through. Keep committed/not_committed/unknown separately from exception class; no code-based reconciliation. | storage_boundary_spec; failure_contract_spec; snapshot_comparison_spec; SQL suites |
61
+ | C08 | fetch, fetch_text, fetch_json and content identity | Invalid requested JSON is IntegrityError with content.invalid_json and original ParserError; invalid UTF-8 is content.invalid_encoding. Raw fetch failures pass through; no dependency on OutputParser. Requesting JSON for plain text is also invalid representation, not proof of physical corruption. | error_api_boundaries_spec; content conformance; SQLite |
62
+ | C09 | LLM complete/stream, request/result checks, RubyLLM conversion | Keep Authentication/RateLimit/ContextLength as TransportError children and InvalidResult separate. Unknown exceptions, cancellation and callbacks retain identity. Existing SDK conversion is not an automatic retry policy. | LLMAdapter unit/integration suites |
63
+ | C10 | VectorStore add/search/remove/clear/size and AsyncClient | Keep InvalidResultError for invalid response and driver communication errors unchanged. Driver-specific transaction/effect uncertainty is not normalized without a backend-specific contract. No universal VectorStore TransportError or sibling-contract borrowing. | VectorStore and async-client suites; live PG/Redis pending |
64
+ | C11 | Embeddings.embed / AsyncClient; RubyLLM errors | Add owner-local AuthenticationError and RateLimitError under existing TransportError. Callers can request credentials versus surface quota refusal. Other SDK errors retain TransportError; arbitrary I/O failures, cancellation and programming errors are not guessed to be SDK failures. | error_api_boundaries_spec; embeddings/async_client_spec; RubyLLMEmbeddings specs |
65
+ | C12 | Records.read/fetch, conditions/unique constraints, streams/blobs, atomic views | Keep nil read versus raising fetch; preserve condition/resource/constraint. Add optional code to specialized constructors; SPI 2 and storage format unchanged. No Agent or Persistence semantics in the backend. | storage SPI snapshot; backend conformance; SQLite |
66
+ | C13 | TaskResult completion, map/flat_map/all_settled, wait/timeout/cancel | Transport errors unchanged. Observer failure leaves settled result intact and continues later observers even if logging fails. Transformation failure settles the derived task. Outcome attributes and physical completion remain separate. | task_result_spec; error_api_boundaries_spec; async composition suites |
67
+ | C15 | Base.trace, trace_with, OpenTelemetry.trace, Automatic | Explicit tracing propagates start/finish-only failure. After a primary business failure finish is attempted once and secondary report failure is suppressed. No finish after start failure. OpenTelemetry keeps with_span context. Automatic retains best-effort observation policy; Langfuse keeps its adapter-specific network suppression. | tracing/base_spec; open_telemetry_tracer_spec; automatic and extension specs |
68
+ | C16 | Filter.call/block!, PromptInjectionFilter | Keep explicit FilterBlockError and filter attribute; add optional constructor code. Implementation errors are not transformed into policy refusal. No unnecessary common rejection hierarchy. | filter specs; error_api_diagnostics_spec |
69
+ | C17 | JsonParser.parse, StructuredParser.parse | Keep ParseError for JSON and documented ArgumentError/TypeError schema-instantiation rejection. Other schema bugs pass through. User schemas should raise domain-specific errors when they must not be treated as mapping rejection. No blanket StandardError rescue added. | output_parser specs; schema integration |
70
+ | M91 | OrderedEventDelivery.publish/open/drain | ClosedError/OverflowError now inherit Phronomy::Error and report delivery.closed / delivery.capacity_exceeded. StandardError rescue still works; Phronomy::Error rescue now includes them. Reporting failure cannot replace an application error. No automatic resend. | ordered_event_delivery specs; error_api_boundaries_spec |
71
+ | I05/I06/I07/I09 | Concrete binding, AsyncClients, runtime admission/shutdown | Preserve backend-specific failures and execution admission/timeout categories. No new reverse references into domain contracts. | architecture gate; backend/execution integration |
72
+ | Other concrete helpers | Documents loaders, tools, evaluation and configuration | Preserve documented external operation errors and configuration checks; do not manufacture an application-wide ErrorTranslator. Generic Tool conversion applies when called through its public contract. | concrete tool/document/evaluation suites |
73
+
74
+ ## Diagnostic format and recovery
75
+
76
+ Top-level `class`, `message` and optional `code` remain unchanged. An optional
77
+ `cause` object transports the same neutral fields. New cause data is bounded to
78
+ three ancestors and 2,048 characters per field. A further cause is represented
79
+ by `{ "truncated": true }`; an oversized code is omitted, never shortened into
80
+ another valid-looking code. Old top-level message behavior is unchanged.
81
+ Backtraces, arbitrary exception attributes and live objects are not serialized.
82
+
83
+ Agent and Team recovery use the same H00 decoder. It constructs only generic
84
+ Phronomy::Error values, including their optional causes, and never constantizes
85
+ saved names. Unknown/nil codes are retained. A restored cause is diagnostic data,
86
+ not the original exception object or its domain-specific attributes. Applications
87
+ may inspect `error.cause&.code` without depending on a storage exception class.
88
+ The live exception still retains its exact original cause.
89
+
90
+ ## Migration and before/after use
91
+
92
+ Before, a broad Persistence/Storage NotFound rescue could mistake a broken
93
+ transcript for a missing Agent. After, limit creation to the requested identity:
94
+
95
+ ```ruby
96
+ begin
97
+ agent = ConversationAgent.load(agent_id, persistence: store)
98
+ rescue Phronomy::AgentNotFoundError => error
99
+ raise unless error.agent_id == agent_id.to_s
100
+ agent = ConversationAgent.create(agent_id: agent_id, persistence: store)
101
+ end
102
+ # A later transcript failure is not handled as Agent absence.
103
+ messages = PhronomyExamples::TranscriptMessages.read(agent)
104
+ ```
105
+
106
+ Before, an application handled a busy Team by rescuing AgentBusyError. After:
107
+
108
+ ```ruby
109
+ begin
110
+ team.invoke(input)
111
+ rescue Phronomy::MultiAgent::TeamBusyError => error
112
+ # Application decides whether to expose an existing run or defer new work.
113
+ show_active_team(error.team_id)
114
+ end
115
+ ```
116
+
117
+ Before, Embeddings::TransportError alone required SDK knowledge to distinguish
118
+ credentials and quota. After, both remain catchable through TransportError:
119
+
120
+ ```ruby
121
+ begin
122
+ vector = embeddings.embed(text)
123
+ rescue Phronomy::Embeddings::AuthenticationError
124
+ request_credentials
125
+ rescue Phronomy::Embeddings::RateLimitError
126
+ display_limit
127
+ rescue Phronomy::Embeddings::TransportError => error
128
+ report_provider_failure(error)
129
+ end
130
+ ```
131
+
132
+ These are application choices, not core-provided UI or automatic retry helpers.
133
+ For Tool failures the application code need not change: `error.cause` now works
134
+ for late async failures too. New reason codes are additive; custom ToolError
135
+ instances pass through with their original nil/unknown code.
136
+
137
+ | Existing consumer | Migration / compatibility |
138
+ |---|---|
139
+ | Persistence::NotFoundError rescue around Agent/Team load | Still catches the new subclass; narrow it to the domain type and target ID for create-on-absence |
140
+ | Storage::NotFoundError rescue around Agent load | Already crossed the wrong boundary; update as the examples do |
141
+ | AgentBusyError rescue for Team's own admission | Change to MultiAgent::TeamBusyError; this is an intentional rescue-set change |
142
+ | JSON::ParserError rescue around content.fetch_json | Change to ContentStore::IntegrityError and inspect code; original ParserError is cause |
143
+ | Embeddings::TransportError rescue | Still catches both new subclasses |
144
+ | Phronomy::Error rescue around optional delivery | Now includes ClosedError/OverflowError; evaluate the application's handling |
145
+ | Exact equality of saved diagnostic hashes | Allow/test the optional cause object. Existing keys/codes and old JSON remain readable |
146
+ | Error message parsing or class-name constantization | Use public categories/codes for live failures; generic decoded diagnostics for recovery |
147
+ | Generic error logger retrying any error | Review separately; code/type does not prove rollback or idempotence |
148
+
149
+ ## Dependency acceptance
150
+
151
+ No new Contract is introduced. New types live with C01/C04/C11; M91 remains an
152
+ optional consumer. C04/C01 specialize C07's absence category using existing
153
+ forward dependencies. H00 has no new domain reference. C05 uses C15's public
154
+ SPI wrapper rather than a private helper. C09/C10/C11 do not borrow each other's
155
+ error types. Ruby and RBS dependency graphs, not just Contract counts, are checked
156
+ by the existing architecture tool. See the generated audit for candidate tree
157
+ and exact deltas; the separate Contract diagram must describe the candidate as
158
+ unmerged until adoption is verified.
159
+
160
+ ### Correction to the earlier Contract count
161
+
162
+ The baseline and candidate both have **32**, not 30, static Contract pairs.
163
+ The previous report omitted the existing RBS-only C01 → C08 references in
164
+ `sig/phronomy/multi_agent/store.rbs` (Store/_Records.contents) and C05 → C08
165
+ in `sig/phronomy/context.rbs` (Assembly.store). Both name the C08-owned
166
+ `_ContentRepository` interface. These are valid forward public-contract
167
+ references, not implementation dependencies introduced by this patch.
168
+ The candidate diagram restores these two edges and labels the correction.
169
+
170
+ At directory level the union still has 273 pairs, but two entries change:
171
+ M91 integrations → H00 common is added; multi_agent/persistence → agent is
172
+ removed because Team admission now owns its BusyError. Equal totals alone
173
+ would conceal this responsibility improvement. The 32 Contract pairs are
174
+ unchanged and acyclic; Ruby+RBS architecture validation passes.
175
+
176
+ ## Static exception inventory
177
+
178
+ Ripper inspected 339 Ruby files. The following 63 definitions include base, private and optional-integration types; this is not a count of public APIs. Ruby declarations establish ownership. Existing RBS definitions and new owning-domain signatures are checked together.
179
+
180
+ | Class | Parent | Declaration |
181
+ |---|---|---|
182
+ | `Phronomy::AgentAlreadyExistsError` | `Phronomy::Error` | `lib/phronomy/agent/agent_already_exists_error.rb` |
183
+ | `Phronomy::AgentBusyError` | `Phronomy::Error` | `lib/phronomy/agent/agent_busy_error.rb` |
184
+ | `Phronomy::AgentNotFoundError` | `Phronomy::Persistence::NotFoundError` | `lib/phronomy/agent/agent_not_found_error.rb` |
185
+ | `Phronomy::AgentPurgedError` | `Phronomy::Error` | `lib/phronomy/agent/agent_purged_error.rb` |
186
+ | `Phronomy::ExecutionRehydrationRequiredError` | `Phronomy::Error` | `lib/phronomy/agent/api/execution_rehydration_required_error.rb` |
187
+ | `Phronomy::HandoffError` | `Phronomy::Error` | `lib/phronomy/agent/handoff_error.rb` |
188
+ | `Phronomy::Agent::RecoveryCoordinator::ResolutionOutcomeUnknownError` | `Phronomy::Error` | `lib/phronomy/agent/recovery/recovery_coordinator.rb` |
189
+ | `Phronomy::StreamCallbackError` | `Phronomy::Error` | `lib/phronomy/agent/stream_callback_error.rb` |
190
+ | `Phronomy::ConfigurationError` | `Phronomy::Error` | `lib/phronomy/common/configuration_error.rb` |
191
+ | `Phronomy::Error` | `StandardError` | `lib/phronomy/common/error.rb` |
192
+ | `Phronomy::ContentStore::IntegrityError` | `Phronomy::Error` | `lib/phronomy/content_store/base.rb` |
193
+ | `Phronomy::Context::BudgetExceededError` | `Phronomy::Error` | `lib/phronomy/context/budget_exceeded_error.rb` |
194
+ | `Phronomy::Context::ManifestError` | `Phronomy::Error` | `lib/phronomy/context/manifest_error.rb` |
195
+ | `Phronomy::Embeddings::AuthenticationError` | `Phronomy::Embeddings::TransportError` | `lib/phronomy/embeddings/authentication_error.rb` |
196
+ | `Phronomy::Embeddings::InvalidResultError` | `Phronomy::Error` | `lib/phronomy/embeddings/invalid_result_error.rb` |
197
+ | `Phronomy::Embeddings::RateLimitError` | `Phronomy::Embeddings::TransportError` | `lib/phronomy/embeddings/rate_limit_error.rb` |
198
+ | `Phronomy::Embeddings::TransportError` | `Phronomy::Error` | `lib/phronomy/embeddings/transport_error.rb` |
199
+ | `Phronomy::BackpressureError` | `Phronomy::Error` | `lib/phronomy/execution/backpressure_error.rb` |
200
+ | `Phronomy::CancellationError` | `Phronomy::Error` | `lib/phronomy/execution/cancellation_error.rb` |
201
+ | `Phronomy::EventLoopReentrancyError` | `Phronomy::Error` | `lib/phronomy/execution/event_loop_reentrancy_error.rb` |
202
+ | `Phronomy::ExecutionCancellationError` | `Phronomy::CancellationError` | `lib/phronomy/execution/execution_cancellation_error.rb` |
203
+ | `Phronomy::ExecutionTimeoutError` | `Phronomy::TimeoutError` | `lib/phronomy/execution/execution_timeout_error.rb` |
204
+ | `Phronomy::InvalidAsyncEntryActionError` | `Phronomy::InvalidAsyncWorkflowActionError` | `lib/phronomy/execution/invalid_async_entry_action_error.rb` |
205
+ | `Phronomy::InvalidAsyncTransitionActionError` | `Phronomy::InvalidAsyncWorkflowActionError` | `lib/phronomy/execution/invalid_async_transition_action_error.rb` |
206
+ | `Phronomy::InvalidAsyncWorkflowActionError` | `Phronomy::Error` | `lib/phronomy/execution/invalid_async_workflow_action_error.rb` |
207
+ | `Phronomy::PoolShutdownError` | `Phronomy::Error` | `lib/phronomy/execution/pool_shutdown_error.rb` |
208
+ | `Phronomy::RecursionLimitError` | `Phronomy::Error` | `lib/phronomy/execution/recursion_limit_error.rb` |
209
+ | `Phronomy::RuntimeShutdownError` | `Phronomy::Error` | `lib/phronomy/execution/runtime_shutdown_error.rb` |
210
+ | `Phronomy::RuntimeShutdownReentrancyError` | `Phronomy::RuntimeShutdownError` | `lib/phronomy/execution/runtime_shutdown_reentrancy_error.rb` |
211
+ | `Phronomy::SchedulerReentrancyError` | `Phronomy::EventLoopReentrancyError` | `lib/phronomy/execution/scheduler_reentrancy_error.rb` |
212
+ | `Phronomy::TimeoutError` | `Phronomy::Error` | `lib/phronomy/execution/timeout_error.rb` |
213
+ | `Phronomy::FilterBlockError` | `Phronomy::Error` | `lib/phronomy/filter/filter_block_error.rb` |
214
+ | `Phronomy::LowConfidenceError` | `Phronomy::Error` | `lib/phronomy/generation/low_confidence_error.rb` |
215
+ | `Phronomy::Integrations::OrderedEventDelivery::ClosedError` | `Phronomy::Error` | `lib/phronomy/integrations/ordered_event_delivery.rb` |
216
+ | `Phronomy::Integrations::OrderedEventDelivery::OverflowError` | `Phronomy::Error` | `lib/phronomy/integrations/ordered_event_delivery.rb` |
217
+ | `Phronomy::LLMAdapter::AuthenticationError` | `Phronomy::LLMAdapter::TransportError` | `lib/phronomy/llm_adapter/authentication_error.rb` |
218
+ | `Phronomy::LLMAdapter::RubyLLM::ToolsRequested` | `StandardError` | `lib/phronomy/llm_adapter/backends/ruby_llm.rb` |
219
+ | `Phronomy::LLMAdapter::ContextLengthError` | `Phronomy::Error` | `lib/phronomy/llm_adapter/context_length_error.rb` |
220
+ | `Phronomy::LLMAdapter::InvalidResultError` | `Phronomy::Error` | `lib/phronomy/llm_adapter/invalid_result_error.rb` |
221
+ | `Phronomy::LLMAdapter::RateLimitError` | `Phronomy::LLMAdapter::TransportError` | `lib/phronomy/llm_adapter/rate_limit_error.rb` |
222
+ | `Phronomy::LLMAdapter::TransportError` | `Phronomy::Error` | `lib/phronomy/llm_adapter/transport_error.rb` |
223
+ | `Phronomy::MultiAgent::TeamBusyError` | `Phronomy::Error` | `lib/phronomy/multi_agent/team_busy_error.rb` |
224
+ | `Phronomy::MultiAgent::TeamCoordinator::OperationRejected` | `Phronomy::ConfigurationError` | `lib/phronomy/multi_agent/team_coordinator.rb` |
225
+ | `Phronomy::MultiAgent::TeamNotFoundError` | `Phronomy::Persistence::NotFoundError` | `lib/phronomy/multi_agent/team_not_found_error.rb` |
226
+ | `Phronomy::ParseError` | `Phronomy::Error` | `lib/phronomy/output_parser/parse_error.rb` |
227
+ | `Phronomy::Persistence::Error` | `Phronomy::Error` | `lib/phronomy/persistence/errors.rb` |
228
+ | `Phronomy::Persistence::ConflictError` | `Phronomy::Persistence::Error` | `lib/phronomy/persistence/errors.rb` |
229
+ | `Phronomy::Persistence::StateConflictError` | `Phronomy::Persistence::ConflictError` | `lib/phronomy/persistence/errors.rb` |
230
+ | `Phronomy::Persistence::NotFoundError` | `Phronomy::Persistence::Error` | `lib/phronomy/persistence/errors.rb` |
231
+ | `Phronomy::Persistence::SerializationError` | `Phronomy::Persistence::Error` | `lib/phronomy/persistence/errors.rb` |
232
+ | `Phronomy::Persistence::UnsupportedBackendError` | `Phronomy::Persistence::Error` | `lib/phronomy/persistence/errors.rb` |
233
+ | `Phronomy::Persistence::TransactionError` | `Phronomy::Persistence::Error` | `lib/phronomy/persistence/errors.rb` |
234
+ | `Phronomy::Storage::BlobConflictError` | `Phronomy::Storage::ConflictError` | `lib/phronomy/storage/blob_conflict_error.rb` |
235
+ | `Phronomy::Storage::ConditionFailedError` | `Phronomy::Storage::ConflictError` | `lib/phronomy/storage/condition_failed_error.rb` |
236
+ | `Phronomy::Storage::ConflictError` | `Phronomy::Error` | `lib/phronomy/storage/conflict_error.rb` |
237
+ | `Phronomy::Storage::NotFoundError` | `Phronomy::Error` | `lib/phronomy/storage/not_found_error.rb` |
238
+ | `Phronomy::Storage::SerializationError` | `Phronomy::Error` | `lib/phronomy/storage/serialization_error.rb` |
239
+ | `Phronomy::Storage::TransactionError` | `Phronomy::Error` | `lib/phronomy/storage/transaction_error.rb` |
240
+ | `Phronomy::Storage::UniqueConstraintError` | `Phronomy::Storage::ConflictError` | `lib/phronomy/storage/unique_constraint_error.rb` |
241
+ | `Phronomy::Storage::UnsupportedBackendError` | `Phronomy::Error` | `lib/phronomy/storage/unsupported_backend_error.rb` |
242
+ | `Phronomy::ToolError` | `Phronomy::Error` | `lib/phronomy/tool/tool_error.rb` |
243
+ | `Phronomy::VectorStore::InvalidResultError` | `Phronomy::Error` | `lib/phronomy/vector_store/invalid_result_error.rb` |
244
+ | `Phronomy::WorkflowContextOwnershipError` | `Phronomy::Error` | `lib/phronomy/workflow/execution/workflow_context_ownership_error.rb` |
245
+ | `Phronomy::WorkflowTaskResumeError` (DX05-D) | `Phronomy::Error` | `lib/phronomy/workflow/execution/workflow_task_resume_error.rb` |
@@ -0,0 +1,45 @@
1
+ # Failure reason codes
2
+
3
+ `Phronomy::Error` and its subclasses accept an optional `code:` String and expose
4
+ it through `error.code`. Unspecified codes are nil. Supplied strings are copied
5
+ and frozen. Existing message, class, cause and backtrace behavior is retained.
6
+ No change is made to StandardError or third-party exceptions.
7
+
8
+ This is an additive public-contract extension. `class` and `message` remain in
9
+ saved failure diagnostics. When a Phronomy exception has a code, its diagnostic
10
+ also contains `"code"`. The same generic field is retained through Agent failure
11
+ persistence, Team results, and terminal resume/reboot. A code is not an execution
12
+ status and is not a retry permission.
13
+
14
+ | Code | Owner and meaning |
15
+ |---|---|
16
+ | `team.enqueue_after_finalize` | MultiAgent: a new enqueue was conclusively rejected after finalization, following the existing readback checks |
17
+
18
+ The operation wrapper remains catchable as ConfigurationError. A Team's aggregate
19
+ failure remains a Phronomy::Error and now carries the stored code. An operation
20
+ that already succeeded with the same identity still replays its original result.
21
+ Unknown persistence outcomes and readback failures remain recovery requirements;
22
+ an inner code is not blindly copied onto that outer uncertainty.
23
+
24
+ The application may choose to retry only a durably failed planning attempt:
25
+
26
+ ```ruby
27
+ outcome && outcome[:status] == "failed" &&
28
+ outcome.dig(:error, "code") == "team.enqueue_after_finalize"
29
+ ```
30
+
31
+ Example 21 retains its four-section/content-length validator and existing retry
32
+ limit. That application policy is not part of the reason code contract.
33
+
34
+ ## Compatibility
35
+
36
+ No SQL columns or record-format versions change. Error diagnostics are content
37
+ JSON; the existing content codec carries the optional field without interpreting
38
+ it. Old diagnostics without codes remain readable. Unknown string codes are
39
+ preserved, and no code is inferred from a message.
40
+
41
+ A v0.28.0 reader can inspect the extra JSON field through stored result data.
42
+ Its reconstructed exceptions do not expose the new code, and paths that rebuild
43
+ a failure from class/message can lose it. Mixed-version writes and code-based
44
+ exception handling on an old reader are therefore unsupported. Upgrade consumers
45
+ before relying on codes; there is no automatic historical backfill.
data/docs/features.md CHANGED
@@ -30,6 +30,7 @@ for production deployments.
30
30
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
31
31
  | **Tracing** — Pluggable logical-operation span observability with bounded automatic coverage | Stable |
32
32
  | **Error Taxonomy** — Provider errors translated to Phronomy transport/authentication/rate-limit/context errors | Beta |
33
+ | **Optional failure reason codes** — `Phronomy::Error#code` and `code:` retain domain-supplied reasons in saved Agent/Team diagnostics; [meaning and compatibility](failure-reason-codes.md) | Beta |
33
34
 
34
35
  Agent definition lineage is separate from Agent instance identity. A named
35
36
  concrete Agent may declare `agent_definition version: N`; when `id:` is omitted,
@@ -51,6 +52,7 @@ rather than implicitly inheriting the parent revision. The Stable
51
52
  | **VectorStore::Embeddings::AsyncClient** — `embed_async` offloads synchronous `embed` through Phronomy and returns `TaskResult` | Beta |
52
53
  | **Model Context Protocol (MCP) Tool** — `Phronomy::Tools::Mcp` integration through the official `mcp` gem | Beta |
53
54
  | **Agent Tool** — `Phronomy::Tools::Agent.from_agent` exposes a child Agent as a Tool without occupying a worker while waiting | Beta |
55
+ | **Ordered event delivery** — Explicitly require `Phronomy::Integrations::OrderedEventDelivery` for bounded ordered delivery outside lifecycle callbacks; [lifetime and failure rules](ordered-event-delivery.md) | Beta |
54
56
  | **Vector Search Tool** — `Phronomy::Tools::VectorSearch` wraps VectorStore and Embeddings adapters | Beta |
55
57
 
56
58
  Applications previously calling async methods on backend objects must
@@ -63,6 +65,7 @@ Backend authors continue to implement the synchronous SPI only.
63
65
  |---|---|
64
66
  | **EventLoop** — Runtime-owned event-driven execution core shared by Agent, ToolInvocation, Workflow, and MultiAgent sessions; single writer for Phronomy-managed live Agent execution state | Beta |
65
67
  | **Agent execution result authority** — Session-local routing plus current FSM state and purpose-specific semantic IDs (`llm_call_id`, `tool_invocation_id`) reject stale async results | Beta |
68
+ | **Agent scope and unload** — `Agent.open_scope` owns newly acquired Agents; `unload_async` / `unload!` release idle incarnations without changing saved state, with typed invalidation and cleanup failures; [contract](architecture/dx05-agent-scope.md) | Beta |
66
69
  | **Agent live ownership/admission** — Runtime owns one mutable live Agent per `agent_id`; EventLoop rejects competing same-Agent top-level executions while suspension retains the logical execution slot; Persistence admission remains durable defense | Beta |
67
70
  | **Workflow durable admission** — Same-process admission is keyed by durable `workflow_instance_id` from pre-hydration admission through the durable terminal/halted save barrier; admission-owner representation is Runtime-internal | Beta |
68
71
  | **`invoke` / `invoke_async`** — Blocking and non-blocking Agent/Workflow entry points | Stable |
@@ -92,7 +95,7 @@ See the [migration guide](migrations/parallel-tool-chat-removal.md).
92
95
 
93
96
  | Feature | Stability |
94
97
  |---|---|
95
- | **Workflow asynchronous pattern** — Start async work, return immediately, and continue through `Workflow#signal` | Beta |
98
+ | **Workflow asynchronous pattern** — `task_state` starts a `TaskResult`, applies its successful value once to the current state entry, and then evaluates ordinary transitions; `Workflow#signal` remains the application event API | Beta |
96
99
  | **Durable Handoff** — `MultiAgent::Handoff` and `MultiAgent::HandoffRunner` persist responsibility, immutable Context and exact Target reservation in one Persistence domain; current graph wiring is required for continuation | Beta |
97
100
  | **GeneratorVerifier** — Generator-Verifier loop with injectable prompts/parsers | Beta |
98
101
  | **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
@@ -4,6 +4,11 @@ This guide contains the setup and introductory examples that were previously
4
4
  embedded in the repository README. The README remains the project entry point;
5
5
  this document carries the longer operational examples.
6
6
 
7
+ Blocks marked `ruby runnable` are complete after `require "phronomy"` and provider
8
+ configuration. Other blocks are setup or partial examples that reuse definitions
9
+ from the surrounding section. See [application recipes](application-recipes.md)
10
+ for independent examples and explicit failure handling.
11
+
7
12
  ## Install
8
13
 
9
14
  Add Phronomy to your Gemfile:
@@ -49,11 +54,9 @@ Install only the backend gems required by your application:
49
54
 
50
55
  ## Define a Tool and Agent
51
56
 
52
- Use `Phronomy::Tool::Base` as the application-facing authoring API. It is an
53
- exact alias of the existing `Phronomy::Tool::Base`, so
54
- existing Tool definitions using the longer namespace remain compatible.
57
+ Use `Phronomy::Tool::Base` as the application-facing authoring API.
55
58
 
56
- ```ruby
59
+ ```ruby runnable
57
60
  class WebSearch < Phronomy::Tool::Base
58
61
  description "Search the web"
59
62
  param :query, type: :string, desc: "Search query"
@@ -115,7 +118,7 @@ A Runtime has at most one mutable live Agent object for an `agent_id`. If the
115
118
  Agent is already live, repeated `load` returns that exact Ruby object and does
116
119
  not reload Persistence. If it is not live, `load` hydrates the durable Agent
117
120
  once. Loading an ID that does not exist durably raises
118
- `Storage::NotFoundError`.
121
+ `Phronomy::Persistence::NotFoundError`.
119
122
 
120
123
  Use `get` when only a process-local lookup is wanted:
121
124
 
@@ -131,7 +134,7 @@ While the Agent is live, it owns its current AgentRoot/Journal view and
131
134
  Runtime/EventLoop owns process-local identity/admission/execution authority.
132
135
  Phronomy persists snapshots at defined durability boundaries but does not reload
133
136
  mutable Agent/Execution/Journal state before every LLM or Tool step. A conflicting
134
- external durable write is surfaced as `Storage::ConflictError` rather than
137
+ external durable write is surfaced as `Phronomy::Persistence::StateConflictError` rather than
135
138
  silently merged into the live instance.
136
139
 
137
140
  The active transcript and Knowledge views can be advanced independently without
@@ -165,7 +168,7 @@ the listener when the Agent is materialized, not on each invocation:
165
168
 
166
169
  ```ruby
167
170
  agent = ResearchAgent.load(
168
- "research-session-42",
171
+ "another-session-not-live-in-this-runtime",
169
172
  persistence: persistence,
170
173
  on_event: ->(event) {
171
174
  puts event.payload[:output] if event.type == :done
@@ -175,6 +178,10 @@ agent = ResearchAgent.load(
175
178
  task = agent.invoke_async("Hello")
176
179
  ```
177
180
 
181
+ This load assumes that the other Agent exists in Persistence but has not been
182
+ materialized in this Runtime. To listen to `research-session-42`, register its
183
+ listener in the original `create` call.
184
+
178
185
  `new`, `create`, and `load` also accept an equivalent listener block.
179
186
  Supplying both `on_event:` and a construction block is an error. If `load`
180
187
  resolves an already-live same-process Agent, supplying any new listener/block
@@ -299,18 +306,16 @@ not inject an explicit `persistence:`. Workflow durability is fixed at the
299
306
  application/Workflow-definition boundary; there is no per-invocation backend
300
307
  switch.
301
308
 
302
- Workflow entry and transition actions are synchronous Run-to-Completion
303
- callbacks. If a Workflow needs an Agent or another asynchronous lifecycle, start
304
- it asynchronously, return the Workflow context immediately, and deliver its
305
- completion later with `Workflow#signal`.
309
+ Use `task_state` when a state starts an asynchronous operation and needs its
310
+ terminal result before proceeding. `start` returns the TaskResult immediately;
311
+ `apply` updates the current Workflow context on EventLoop. Guards run after
312
+ successful application. Ordinary entry and transition actions remain synchronous.
306
313
 
307
- ```ruby
314
+ ```ruby runnable
308
315
  class AnswerContext
309
316
  include Phronomy::WorkflowContext
310
-
311
317
  field :question, type: :replace, default: ""
312
- field :answer, type: :replace, default: nil
313
- # workflow_instance_id is framework-owned metadata; do not declare it as a field.
318
+ field :answer, type: :replace, default: nil
314
319
  end
315
320
 
316
321
  class ResearchAgent < Phronomy::Agent::Base
@@ -320,40 +325,35 @@ class ResearchAgent < Phronomy::Agent::Base
320
325
  end
321
326
 
322
327
  my_agent = ResearchAgent.new
323
- workflow = nil
324
-
325
328
  workflow = Phronomy::Workflow.define(AnswerContext) do
326
329
  initial :asking
327
- state :asking
328
- state :done
329
-
330
- entry :asking, ->(ctx) {
331
- workflow_instance_id = ctx.workflow_instance_id
330
+ task_state :asking,
331
+ start: ->(state) { my_agent.invoke_async(state.question) },
332
+ apply: ->(state, result) { state.merge(answer: result.fetch(:output)) }
333
+ transition from: :asking, to: :__finish__
334
+ end
332
335
 
333
- my_agent.invoke_async(ctx.question) do |event|
334
- next unless event.type == :done
336
+ answer = workflow.invoke(
337
+ {question: "What is an asynchronous completion handle?"},
338
+ config: {workflow_instance_id: "answer-example"}
339
+ )
340
+ puts answer.answer
341
+ ```
335
342
 
336
- workflow.signal(
337
- workflow_instance_id: workflow_instance_id,
338
- event: :answer_ready,
339
- payload: {answer: event.payload[:output]}
340
- )
341
- end
343
+ Phronomy correlates completion to this execution and state entry, ignores stale
344
+ results after exit or reentry, and propagates start/source/apply failures to the
345
+ caller. A cancelled source cancels the Workflow observation; a failed source
346
+ retains its failure classification. External business events still use
347
+ `Workflow#signal` or `send_event`.
342
348
 
343
- ctx
344
- }
345
-
346
- transition(
347
- from: :asking,
348
- on: :answer_ready,
349
- to: :done,
350
- action: ->(ctx, event) { ctx.merge(answer: event.payload[:answer]) }
351
- )
352
- end
353
- ```
349
+ `task_state` is a live wait, while `wait_state` halts the current execution segment.
350
+ Pending Tasks are not persisted: attempting to restore a task-state snapshot
351
+ raises `WorkflowTaskResumeError`. Normal wait-state halt/resume remains supported.
352
+ Parent cancellation stops application of the result; cancelling the child itself
353
+ requires explicit controls passed by the application. See the
354
+ [task-state contract](architecture/dx05-workflow-task-state.md) for these boundaries.
354
355
 
355
- Returning a `Phronomy::TaskResult` from a Workflow entry/transition action is not an
356
- implicit await mechanism and is rejected.
356
+ Returning a TaskResult from an ordinary entry/transition action remains an error.
357
357
 
358
358
  ## Agent as Tool
359
359
 
@@ -435,6 +435,7 @@ contracts.
435
435
  ## Next steps
436
436
 
437
437
  - [Features and API stability](features.md)
438
+ - [Application recipes](application-recipes.md)
438
439
  - [Runtime and concurrency](runtime-and-concurrency.md)
439
440
  - [Architecture decisions](decisions/)
440
441
  - [Migration guides](migrations/)
@@ -0,0 +1,54 @@
1
+ # Ordered event delivery
2
+
3
+ Load the application integration explicitly:
4
+
5
+ ```ruby
6
+ require "phronomy"
7
+ require "phronomy/integrations/ordered_event_delivery"
8
+
9
+ Phronomy::Integrations::OrderedEventDelivery.open(
10
+ capacity: 256, batch_size: 32, flush_timeout: 30,
11
+ deliver: ->(payload) { send_to_application_transport(payload) }
12
+ ) do |delivery|
13
+ agent = MyAgent.new(on_event: ->(event) {
14
+ payload = application_payload(event)
15
+ delivery.publish(payload) if payload
16
+ })
17
+ agent.stream("Hello")
18
+ end
19
+ ```
20
+
21
+ The block runs on the external caller, and its result is returned after drain.
22
+ `open` rejects EventLoop callers before executing their block. Publication only
23
+ snapshots and queues plain data; it does not wait for capacity or send network
24
+ traffic. The existing Blocking API supplies a worker only while draining. The
25
+ integration creates no scheduler and never shuts down the shared Runtime/pool.
26
+
27
+ The app owns authentication, destination, authorization, Rails executor wrapping,
28
+ event-to-payload conversion, and any token coalescing. `prepare_batch:` optionally
29
+ receives an immutable Array of snapshots on the drain worker and returns an Array
30
+ of payloads to send in order. Its default is no transformation. Example 18 passes
31
+ its own TokenEventBatch, preserving adjacent-token coalescing with equal metadata.
32
+
33
+ Capacity limits the waiting queue, excluding an already extracted batch. Multiple
34
+ publishers are ordered by queue admission, not by thread start time. Hashes,
35
+ Arrays, Strings and scalar plain values are copied into immutable snapshots;
36
+ cycles, non-finite numbers and arbitrary objects fail publication.
37
+
38
+ On overflow/invalid publication, acceptance closes and the failure remains visible
39
+ to final drain even if an Agent listener merely logs its exception. Previously
40
+ accepted work drains if transport is healthy. A failed batch conversion, delivery
41
+ or worker admission also closes acceptance; remaining work may not be delivered.
42
+ There is no silent drop-as-success, automatic retry or durable delivery guarantee.
43
+
44
+ `close_and_wait` can be called by multiple external threads. It waits for physical
45
+ drain completion, not just TaskResult settlement. A timeout ends only the wait;
46
+ a network operation can still complete afterwards. Configure transport timeouts
47
+ in the transport itself. Network failure does not prove that nothing was sent.
48
+
49
+ If application execution fails, its original exception, message and backtrace
50
+ remain primary after cleanup. A simultaneous delivery failure is reported through
51
+ the configured Phronomy logger. Without a logger there is no durable diagnostic
52
+ guarantee; logger failure falls back to stderr without replacing the primary error.
53
+ If only delivery fails, that failure is raised. The small final-answer-only Job
54
+ needs no queue and is unchanged.
@@ -0,0 +1,92 @@
1
+ # A bounded Workflow completion recipe
2
+
3
+ This is a **legacy manual-signal recipe**, retained for compatibility and its
4
+ bounded-lifetime tests. New code should use
5
+ [`task_state`](architecture/dx05-workflow-task-state.md), which owns completion
6
+ correlation and failure propagation for operations started by a Workflow state.
7
+ The helper below is application code and is not a Phronomy integration API.
8
+ Its limitations do not describe the new task-state API.
9
+
10
+ Use the application-local function below only for **one live waiting stage, with
11
+ no restart, reentry or replacement while its operation is pending**. It separates
12
+ result mapping (application logic) from the repetitive notification plumbing.
13
+ For repeated/resumed stages, the app must put an operation ID in every success
14
+ AND error payload and check it with a receiver-side transition `guard:` before
15
+ applying the payload. Signal admission alone is not that check. The example
16
+ below intentionally does not claim that wider guarantee.
17
+
18
+ ```ruby runnable
19
+ def forward_completion(task, workflow:, workflow_instance_id:, event:, on_delivery_error:)
20
+ task.on_complete do |payload, operation_error|
21
+ delivery_error = nil
22
+ begin
23
+ accepted = workflow.signal(
24
+ workflow_instance_id: workflow_instance_id, event: event,
25
+ payload: operation_error ? {error: operation_error} : payload
26
+ )
27
+ delivery_error = Phronomy::Error.new("Workflow did not accept #{event}") unless accepted
28
+ rescue => error
29
+ delivery_error = error
30
+ end
31
+ on_delivery_error.call(delivery_error) if delivery_error
32
+ end
33
+ nil
34
+ end
35
+
36
+ class RecipeAnswerAgent < Phronomy::Agent::Base
37
+ agent_definition id: "bounded-completion-recipe", version: 1
38
+ model "gpt-4o-mini"
39
+ instructions "Answer briefly."
40
+ end
41
+ class CompletionState
42
+ include Phronomy::WorkflowContext
43
+ field :question, type: :replace
44
+ field :answer, type: :replace
45
+ field :error_message, type: :replace
46
+ end
47
+
48
+ agent = RecipeAnswerAgent.new
49
+ errors = Queue.new
50
+ workflow = nil
51
+ workflow = Phronomy::Workflow.define(CompletionState) do
52
+ initial :asking
53
+ state :asking
54
+ state :done
55
+ entry :asking, ->(state) {
56
+ task = agent.invoke_async(state.question).map { |result| {answer: result.fetch(:output)} }
57
+ forward_completion(task, workflow: workflow,
58
+ workflow_instance_id: state.workflow_instance_id, event: :answer_ready,
59
+ on_delivery_error: ->(error) { errors << error })
60
+ state
61
+ }
62
+ transition from: :asking, on: :answer_ready, to: :done,
63
+ action: ->(state, event) {
64
+ error = event.payload[:error]
65
+ error ? state.merge(error_message: error.message) : state.merge(answer: event.payload.fetch(:answer))
66
+ }
67
+ transition from: :done, to: :__finish__
68
+ end
69
+ final = workflow.invoke({question: "Explain completion handles"},
70
+ config: {workflow_instance_id: "bounded-recipe"})
71
+ puts final.error_message || final.answer
72
+ raise errors.pop unless errors.empty?
73
+ ```
74
+
75
+ `forward_completion` returns nil after registering one callback. Already-completed
76
+ results are notified immediately. Two registrations are two subscriptions; the
77
+ application avoids duplicates. No task state is changed, no retry is attempted,
78
+ and no callback waits for execution, storage or network delivery.
79
+
80
+ Operation failures and cancellation notify the Workflow with `{error: error}`.
81
+ A false signal result and a raised signal exception each notify the required
82
+ `on_delivery_error` callback once. A failure of that callback follows TaskResult's
83
+ existing logger policy; it is not retried and does not rewrite the task result.
84
+ The application observes delivery errors and chooses cancellation/recovery.
85
+
86
+ Signal acceptance does not guarantee transition completion or a durable save.
87
+ Incomplete tasks retain their subscriptions until settlement under the existing
88
+ TaskResult lifecycle. The caller still owns operation cancellation and lifetime;
89
+ this recipe installs no Runtime shutdown hook or global subscription registry.
90
+
91
+ See [the full application recipe](application-recipes.md#workflow-completion-and-failures)
92
+ for the same failure behavior without extracting this application-local function.