phronomy 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +7 -1
- data/docs/features.md +2 -0
- data/docs/runtime-and-concurrency.md +159 -0
- data/lib/phronomy/agent/approval_wait.rb +8 -0
- data/lib/phronomy/agent/async_event_api.rb +60 -0
- data/lib/phronomy/agent/base.rb +1 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
- data/lib/phronomy/agent/execution/execution_coordinator.rb +89 -2
- data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
- data/lib/phronomy/agent/store.rb +52 -0
- data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
- data/lib/phronomy/engine/fsm_session.rb +9 -3
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
- data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +9 -0
- data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
- data/scripts/api_snapshot.rb +2 -0
- data/sig/phronomy/agent/execution_contract.rbs +13 -0
- data/sig/phronomy/agent/store.rbs +1 -0
- data/sig/phronomy/agent.rbs +1 -0
- data/sig/phronomy/multi_agent.rbs +2 -0
- data/sig/phronomy/workflow.rbs +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8eeef6dddca310da11fcdcaf7ffc2490191151cfa070384be8d04f84366efb08
|
|
4
|
+
data.tar.gz: 2b43c66ff8c5484360b2a953bcdd4aa754ef99c3b7bc944285993d866ee34007
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9ebee2313bc7d60b27c6e42e0909fbb1102cca3c220f25575d1c755b18f3d1c00b7ea0dfec36646155a7aff325ba47c99af202c9b7fbf226d45c4fbb07b8bff9
|
|
7
|
+
data.tar.gz: 3b089fdc8447a0a56c66d10d64c809eb26ddc31be6800a55c095409e87e649047afbc42fa1872eab8fd68b7939bd6845edc194b5ec4ab03beca602408f320596
|
data/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,70 @@ Release history for 0.14.0 and earlier is archived in
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.32.0] - 2026-11-03
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Add `Agent#start_hosted`, `Agent::HostedInvocation` and immutable
|
|
20
|
+
`Agent::ApprovalWait` for safely detaching an exclusively owned standalone Agent
|
|
21
|
+
at its first committed Tool approval wait. A new incarnation can use the
|
|
22
|
+
existing `load` and `approve` operations to continue the same execution.
|
|
23
|
+
- Add `Agent::Store#standalone_approval_wait` to query the committed approval fact without
|
|
24
|
+
transferring a live worker claim. Delivery deduplication, exclusive claims and
|
|
25
|
+
verified worker termination remain application responsibilities. Standalone
|
|
26
|
+
detachment does not transfer parent Workflows or shared children; supported
|
|
27
|
+
direct-child handoff and saved Workflow events are separate APIs below.
|
|
28
|
+
|
|
29
|
+
- Add `Workflow#send_saved_event` to restore a declared wait by Workflow instance
|
|
30
|
+
ID and expected revision before applying an event. The host can reject a
|
|
31
|
+
delayed delivery even when the same Workflow later returns to an identical wait.
|
|
32
|
+
- Allow an Orchestrator's hosted handle to detach its local parent and direct
|
|
33
|
+
child together at a saved child Tool approval. The host supplies the child's
|
|
34
|
+
execution and approval request IDs and retains the parent's execution ID for
|
|
35
|
+
resumption on another worker. Both workers can remain alive.
|
|
36
|
+
- Add `MultiAgent::Orchestrator#approve_hosted_child` to answer a restored
|
|
37
|
+
direct child's approval with a hosted handle. If the same execution asks for
|
|
38
|
+
another approval, the worker can release the pair at the new wait. A third
|
|
39
|
+
worker can restore that wait without replaying the earlier Tool call.
|
|
40
|
+
- Add `MultiAgent::Orchestrator#unload_with_completed_child!` for a worker
|
|
41
|
+
that has finished a direct child dispatch. It can release the local owners
|
|
42
|
+
while keeping the conversation, execution results and native slot history.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- Bind native subagent dispatch context to each prepared Tool instance so that
|
|
47
|
+
cached Tool methods do not keep completed Orchestrators alive after unload
|
|
48
|
+
(Issue #440). Saved SQLite execution and conversation history is preserved.
|
|
49
|
+
- Keep distinct native Tool call/result pairs when a provider reuses its call ID
|
|
50
|
+
in later responses on the same Agent. A subsequent request can use the saved
|
|
51
|
+
conversation without executing either Tool again to rebuild context.
|
|
52
|
+
- Restore mutable Workflow context fields from durable checkpoints when
|
|
53
|
+
delivering a saved event or starting another invocation of the same instance.
|
|
54
|
+
Actions can update nested strings, arrays and hashes without changing the
|
|
55
|
+
original checkpoint.
|
|
56
|
+
- Continue using an Agent after a human rejects a Tool call. Model input for the
|
|
57
|
+
next turn omits the rejected, incomplete Tool exchange. The original journal
|
|
58
|
+
and public conversation history remain available for inspection.
|
|
59
|
+
|
|
60
|
+
### Compatibility and verification
|
|
61
|
+
|
|
62
|
+
- Worker APIs require 0.32.0 or later; applications using `start_hosted` cannot
|
|
63
|
+
run against 0.31.0. The existing [0.31 storage and Agent Tool migration
|
|
64
|
+
requirements](docs/migrations/0.31.md) still apply. This release does not add a
|
|
65
|
+
persistence mapping migration or change those stored execution formats.
|
|
66
|
+
- Native handoff and completed-owner release cover one direct child in an
|
|
67
|
+
isolated dispatch batch. Delivery deduplication, exclusive claims, ACKs and
|
|
68
|
+
verified worker termination belong to the application. General graph transfer,
|
|
69
|
+
multi-host fencing, supervisor restart and recovery of unknown external Tool
|
|
70
|
+
effects are not established by these examples.
|
|
71
|
+
- Worker acceptance uses fixed HTTP responses, actual Ruby processes and SQLite,
|
|
72
|
+
including Ubuntu 24.04 and 26.04. It does not establish live-model acceptance.
|
|
73
|
+
- LM Studio/gpt-oss PEG parsing remains a known unresolved limitation. The latest
|
|
74
|
+
pre-release standard verifier reported 101 passed, 2 failed and 0 skipped;
|
|
75
|
+
Examples 01 and 22 received provider PEG errors, also reproduced before Worker
|
|
76
|
+
support. Known-error identification and bounded retry do not fix the provider's
|
|
77
|
+
root cause. These two failures are tracked separately from Worker acceptance.
|
|
78
|
+
|
|
15
79
|
## [0.31.0] - 2026-10-25
|
|
16
80
|
|
|
17
81
|
### Added
|
data/README.md
CHANGED
|
@@ -37,7 +37,8 @@ tracking `main` directly.
|
|
|
37
37
|
|
|
38
38
|
See [Features and Application Programming Interface (API) stability](docs/features.md) for the full feature matrix.
|
|
39
39
|
|
|
40
|
-
The following table describes the application APIs available in v0.
|
|
40
|
+
The following table describes the application APIs available in v0.32.0.
|
|
41
|
+
Worker hosting and completed parent/child release require v0.32.0 or later.
|
|
41
42
|
Review the [0.31 migration notes](docs/migrations/0.31.md) before upgrading
|
|
42
43
|
persistent applications or custom Agent Tool subclasses.
|
|
43
44
|
|
|
@@ -48,7 +49,12 @@ persistent applications or custom Agent Tool subclasses.
|
|
|
48
49
|
| Agent `transcript_messages` / `transcript_messages_async` | Beta | Read immutable conversation Message values with the same restoration rules as execution results; [reading conversation state](docs/getting-started.md) |
|
|
49
50
|
| `Phronomy::GeneratorVerifier`, its approval/lifetime operations and immutable `Result` with output-failure diagnostics | Beta | [Generation and structured-output contract](docs/architecture/structured-output-and-scope.md) |
|
|
50
51
|
| `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) |
|
|
52
|
+
| `Agent::Base#start_hosted`, `Agent::HostedInvocation`, `Agent::ApprovalWait` and `Agent::Store#standalone_approval_wait` | Beta | Added in v0.32.0. Detach an exclusively owned standalone Agent at its first committed approval wait, then restore the same execution in another process; delivery claims and worker fencing remain application responsibilities; [contract](docs/runtime-and-concurrency.md#hosting-a-standalone-agent-through-approval) |
|
|
53
|
+
| `Agent::HostedInvocation#detach!(execution_id:, approval_request_id:)` | Beta | Added in v0.32.0. Release an exclusively owned Orchestrator and its single direct subagent at the child's approval wait. A second worker can resume both saved executions while the first worker handles other requests. [Contract](docs/runtime-and-concurrency.md#hosting-an-orchestrator-through-a-child-approval) |
|
|
54
|
+
| `MultiAgent::Orchestrator#approve_hosted_child` | Beta | Added in v0.32.0. Answer the current approval on a restored direct child. The returned handle can release the pair again if that child requests another approval during the same execution. [Contract](docs/runtime-and-concurrency.md#hosting-an-orchestrator-through-a-child-approval) |
|
|
55
|
+
| `MultiAgent::Orchestrator#unload_with_completed_child!` | Beta | Added in v0.32.0. Release local owners after a direct child result has been imported and the parent has finished. The saved executions, transcripts and coordination history remain available. [Contract](docs/runtime-and-concurrency.md#releasing-a-completed-orchestrator-and-child) |
|
|
51
56
|
| `Tools::Agent::CleanupError` | Beta | Added in v0.31.0. Preserves the child, result and failure causes when safe release fails; retry `child_agent.unload_async` for release only; [contract](docs/architecture/agent-tool-child-lifetime.md) |
|
|
57
|
+
| `Workflow#send_saved_event` | Beta | Added in v0.32.0. Deliver an event to a specific revision of a persisted Workflow wait; [contract](docs/runtime-and-concurrency.md#delivering-an-event-to-a-saved-workflow-wait) |
|
|
52
58
|
| `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) |
|
|
53
59
|
| `Phronomy::Integrations::OrderedEventDelivery` | Beta | Optional ordered delivery with bounded admission and explicit drain lifetime; [usage](docs/ordered-event-delivery.md) |
|
|
54
60
|
| `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) |
|
data/docs/features.md
CHANGED
|
@@ -106,6 +106,8 @@ See the [migration guide](migrations/parallel-tool-chat-removal.md).
|
|
|
106
106
|
| **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 |
|
|
107
107
|
| **GeneratorVerifier** — Generator-Verifier loop with application prompts, Agent-validated output schemas and explicit output failures | Beta |
|
|
108
108
|
| **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
|
|
109
|
+
| **Hosted child approvals** — An Orchestrator can release a single direct child and its parent at successive Tool approvals within the same child execution. The host transfers exclusive ownership between workers. [Contract](runtime-and-concurrency.md#hosting-an-orchestrator-through-a-child-approval) | Beta |
|
|
110
|
+
| **Completed native child release** — A host can unload the local Orchestrator and its finished direct child while preserving their saved results, conversation and coordination history. [Contract](runtime-and-concurrency.md#releasing-a-completed-orchestrator-and-child) | Beta |
|
|
109
111
|
| **`Phronomy::MultiAgent::TeamCoordinator`** — LLM coordinator with stateful worker Agents | Beta |
|
|
110
112
|
| **`Phronomy::MultiAgent::SharedState`** — Sequential peer-agent coordination through an invocation-local findings store; see the [namespace migration](migrations/shared-state-multi-agent.md) | Experimental |
|
|
111
113
|
| **Human-in-the-loop approval** — durable suspension publishes `:approval_required` through the Agent listener; `approve` / `approve_async` resumes the same logical `execution_id` with a fresh FSMSession incarnation | Beta |
|
|
@@ -668,6 +668,165 @@ imply durable Agent continuation reconstruction.
|
|
|
668
668
|
`Phronomy.reset_runtime!` exists primarily for test isolation and performs a real
|
|
669
669
|
Runtime shutdown before resetting configuration.
|
|
670
670
|
|
|
671
|
+
## Hosting a standalone Agent through approval
|
|
672
|
+
|
|
673
|
+
A host can release one suspended Agent and continue using its Ruby process for other Agents. Hold one external claim per mutable Agent while loading, executing, detaching and recording delivery. Persist a new request's reserved execution ID before dispatch. A host uses the existing incarnation-level listener to receive the approval request.
|
|
674
|
+
|
|
675
|
+
For example, worker A starts a fresh reservation with `agent.start_hosted(reservation:, input:)`. If the run finishes directly, `handle.wait_result` returns the ordinary execution result. On an approval notification, call `handle.detach!(approval_request_id: request.id)` outside EventLoop. Success returns an immutable `ApprovalWait` with the execution ID, approval request ID and committed revision. The same handle then returns that wait. The released Agent object cannot be reused. Existing `invoke_async` handles keep their terminal-result contract.
|
|
676
|
+
|
|
677
|
+
An incorrect or stale approval, outstanding physical work, concurrent operation, live scope or durable retention prevents transfer. A busy response leaves the host responsible for the claim. A spent reservation is rejected. After a verified worker exit, a host can query `store.standalone_approval_wait(agent_id:, execution_id:)` without loading the Agent. A missing or ineligible wait returns nil. Store errors and owner mismatches propagate. Only reclaim a delivery if the original worker is known to have stopped and its claim still matches.
|
|
678
|
+
|
|
679
|
+
Worker B loads the Agent with an `on_event:` listener, checks the restored request against the accepted answer and calls `approve` with the execution and approval request IDs. The host owns input deduplication and result acknowledgement. For a direct subagent, use the parent's hosted handle as described below.
|
|
680
|
+
|
|
681
|
+
## Hosting an Orchestrator through a child approval
|
|
682
|
+
|
|
683
|
+
A hosted Orchestrator can release its local Agent and one direct subagent when
|
|
684
|
+
the subagent is waiting for Tool approval. The parent execution has one reserved
|
|
685
|
+
child slot and its current Tool batch contains only the dispatch for that slot.
|
|
686
|
+
The host holds exclusive access to both Agents throughout the handoff. It
|
|
687
|
+
delivers work to the next worker only after release succeeds.
|
|
688
|
+
|
|
689
|
+
For example, worker A starts a parent with `start_hosted`. Its listener receives
|
|
690
|
+
the child's approval request. A host thread outside EventLoop passes the IDs
|
|
691
|
+
from that request to the parent handle.
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
wait = handle.detach!(execution_id: request.execution_id, approval_request_id: request.id)
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
The returned `Agent::ApprovalWait` identifies the child execution, its approval
|
|
698
|
+
request and its saved revision. Keep the parent Agent and execution IDs with the
|
|
699
|
+
accepted request. Both old Agent objects reject later execution operations.
|
|
700
|
+
Worker A can keep unrelated Agent instances and process another request.
|
|
701
|
+
The parent's original result handle retains its
|
|
702
|
+
`ExecutionRehydrationRequiredError` outcome at this boundary.
|
|
703
|
+
|
|
704
|
+
Worker B loads the same parent class with its Agent Store, coordination Store
|
|
705
|
+
and listener. `parent.resume_async(parent_execution_id)` restores the pending
|
|
706
|
+
child and emits the same approval request. Compare its execution and request
|
|
707
|
+
IDs with the accepted request. The parent attempt reports
|
|
708
|
+
`ExecutionRehydrationRequiredError` while the child is waiting. Obtain the
|
|
709
|
+
restored child from its registered class by child Agent ID, call
|
|
710
|
+
`child.approve(child_execution_id, approval_request_id:, approved:)`, then
|
|
711
|
+
`parent.resume(parent_execution_id)` to import the result. A rejection is a
|
|
712
|
+
child outcome. The parent continues according to its own dispatch and Agent
|
|
713
|
+
logic.
|
|
714
|
+
|
|
715
|
+
If the child may need another handoff, the host answers the current approval
|
|
716
|
+
through the restored parent on a host thread outside EventLoop. This returns a
|
|
717
|
+
hosted handle for the child's operation. If the child finishes, the handle
|
|
718
|
+
yields the child result and the parent can import it. If the child requests
|
|
719
|
+
another approval, the host can use the handle to release the same pair.
|
|
720
|
+
|
|
721
|
+
For example, B approves the child's `apply_request` and the same child execution
|
|
722
|
+
then asks to call `publish_request`. B uses the first approval's ID to answer and
|
|
723
|
+
the second approval's ID to detach. The queue below receives the child approval
|
|
724
|
+
events from the listener installed when B loaded the parent. This fixture
|
|
725
|
+
requests a second approval within its test timeout.
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
require "timeout"
|
|
729
|
+
|
|
730
|
+
answer = parent.approve_hosted_child(parent_execution_id,
|
|
731
|
+
execution_id: first.execution_id, approval_request_id: first.id, approved: true)
|
|
732
|
+
second = Timeout.timeout(12) { approval_events.pop }
|
|
733
|
+
wait = answer.detach!(execution_id: second.execution_id, approval_request_id: second.id)
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
After release, `answer.wait_result` returns the same child `ApprovalWait`.
|
|
737
|
+
B sends the existing parent and child execution IDs and the **new** approval
|
|
738
|
+
request ID to C. C restores the pair as B did and answers the new request.
|
|
739
|
+
The completed first Tool stays recorded in the child execution. While the hosted
|
|
740
|
+
answer is pending, read the saved execution to report progress. Calling
|
|
741
|
+
`parent.resume` again can advance the parent revision and invalidate the
|
|
742
|
+
handoff. Resume the parent after the child finishes. A host handling
|
|
743
|
+
arbitrary conversations must observe both approval events and handle completion
|
|
744
|
+
(`on_complete`), since the child can finish without requesting another Tool.
|
|
745
|
+
|
|
746
|
+
A later user request can also start a new hosted execution on the same parent
|
|
747
|
+
Agent. Its own child wait can be detached while the earlier terminal
|
|
748
|
+
execution's native slot history is still retained.
|
|
749
|
+
|
|
750
|
+
A wrong approval, another reserved child, overlapping local work, or a pending
|
|
751
|
+
cancellation prevents transfer. On `AgentBusyError` the host keeps ownership.
|
|
752
|
+
The child remains a coordinated execution in storage, including its original
|
|
753
|
+
slot and retention. Once both executions complete in B, these retentions
|
|
754
|
+
continue to guard ordinary `unload!`. This handoff preserves slot history.
|
|
755
|
+
An application that has finished using that history can separately call
|
|
756
|
+
`stores.multi_agent.forget_subagents(agent_id:, execution_id:)` to remove
|
|
757
|
+
that execution's subagent extension and release its holds.
|
|
758
|
+
The host serializes workers and owns request deduplication, claims, crash
|
|
759
|
+
recovery and acknowledgement.
|
|
760
|
+
|
|
761
|
+
## Releasing a completed Orchestrator and child
|
|
762
|
+
|
|
763
|
+
A host can release the local owners once the parent has imported its direct
|
|
764
|
+
child's outcome and both executions have finished. Phronomy keeps the durable
|
|
765
|
+
reservation, execution results and both Agents' transcripts. A later worker
|
|
766
|
+
can load the parent by its Agent ID and use that conversation for another turn.
|
|
767
|
+
|
|
768
|
+
For example, after answering the last approval, the worker first lets the
|
|
769
|
+
parent import the child's result. It then passes both execution IDs to the
|
|
770
|
+
Orchestrator, on a host thread holding exclusive delivery for the pair. Call
|
|
771
|
+
this outside an application persistence transaction so the ownership decision
|
|
772
|
+
uses committed records.
|
|
773
|
+
|
|
774
|
+
```ruby
|
|
775
|
+
result = parent.resume(parent_execution_id)
|
|
776
|
+
parent.unload_with_completed_child!(parent_execution_id,
|
|
777
|
+
execution_id: child_execution_id)
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
The method returns true on release. The parent and any matching resident child
|
|
781
|
+
leave the local runtime registry together. Retrying on the released parent
|
|
782
|
+
object returns false. A parent reloaded on its own can use the saved pair's IDs
|
|
783
|
+
to unload without materializing the child. To display saved results without
|
|
784
|
+
loading either Agent, use the Agent Store.
|
|
785
|
+
|
|
786
|
+
A child may be complete while the parent is still waiting to import its result.
|
|
787
|
+
In that case release is premature. Keep the host claim, finish the parent and
|
|
788
|
+
then release. Active operations, a scoped owner or a conflicting retention can
|
|
789
|
+
also prevent release. The method covers one imported, directly registered child
|
|
790
|
+
of the specified execution. It does not sweep other child instances that the
|
|
791
|
+
application previously left resident.
|
|
792
|
+
|
|
793
|
+
Generic `unload!` and durable deletion continue to honor retention. Use
|
|
794
|
+
`forget_subagents` only when the application intends to remove the stored
|
|
795
|
+
coordination history. Once a host no longer needs an unloaded Agent, it can
|
|
796
|
+
drop its own references to that instance and its operation handles.
|
|
797
|
+
|
|
798
|
+
## Delivering an event to a saved Workflow wait
|
|
799
|
+
|
|
800
|
+
A worker can restore a particular visit to a declared Workflow wait. The host holds
|
|
801
|
+
one claim for the Workflow instance, selects the matching Workflow definition,
|
|
802
|
+
and identifies the saved revision that should receive the event.
|
|
803
|
+
|
|
804
|
+
For example, a parent waiting for a review stores the child's Agent and execution
|
|
805
|
+
IDs in its context. A worker verifies the completed child result against those IDs.
|
|
806
|
+
While it owns the parent claim, it calls the following method with the revision
|
|
807
|
+
recorded for that wait:
|
|
808
|
+
|
|
809
|
+
```ruby
|
|
810
|
+
workflow.send_saved_event(workflow_instance_id: parent_id,
|
|
811
|
+
expected_revision: wait_revision, event: :review_ready, input: {answer: answer})
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
A later visit can have the same phase and fields but a different revision. The
|
|
815
|
+
earlier delivery raises `Persistence::StateConflictError` before its transition
|
|
816
|
+
executes. A transition whose guard rejects the input raises `ArgumentError` and
|
|
817
|
+
leaves the checkpoint available for a corrected event. After an
|
|
818
|
+
acknowledgement is lost, the host checks the durable parent state for evidence
|
|
819
|
+
of this child's result before acknowledging or retrying the message. It must
|
|
820
|
+
retain the original wait revision throughout retries.
|
|
821
|
+
|
|
822
|
+
The host keeps its claim from reading the parent through committing the transition.
|
|
823
|
+
Two workers could otherwise both run application actions before storage detects a
|
|
824
|
+
conflict. The application must also restore the same Workflow definition and
|
|
825
|
+
configuration. A revision identifies a stored state for one durable instance;
|
|
826
|
+
do not delete and reuse its ID while old deliveries might still arrive. The
|
|
827
|
+
revision does not identify deployed code or make an external Tool effect repeatable.
|
|
828
|
+
A pending in-process `task_state` cannot be restored with this operation.
|
|
829
|
+
|
|
671
830
|
## Further design records
|
|
672
831
|
|
|
673
832
|
The `docs/decisions/` directory contains the historical and current Architecture
|
|
@@ -12,6 +12,27 @@ module Phronomy
|
|
|
12
12
|
# - approval notifications share the Agent listener as :approval_required;
|
|
13
13
|
# - Recovery resolution uses resolve/resolve_async.
|
|
14
14
|
module AsyncEventApi
|
|
15
|
+
# Starts a fresh, standalone execution. Only this handle can detach its wait.
|
|
16
|
+
# The caller holds the external exclusive claim for this Agent.
|
|
17
|
+
def start_hosted(reservation:, input:, config: {}, output_schema: nil, schema_delivery: nil)
|
|
18
|
+
__with_operation do
|
|
19
|
+
if Phronomy::WaitPolicy.blocking_forbidden?
|
|
20
|
+
raise Phronomy::EventLoopReentrancyError, "start_hosted requires a host thread"
|
|
21
|
+
end
|
|
22
|
+
unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id && reservation.correlation.nil?
|
|
23
|
+
raise ArgumentError, "expected a standalone reservation for this Agent"
|
|
24
|
+
end
|
|
25
|
+
id = reservation.execution_id
|
|
26
|
+
unless persistence.observe_execution(agent_id: agent_id, execution_id: id).absent?
|
|
27
|
+
raise Phronomy::Persistence::StateConflictError, "Hosted execution #{id} already exists"
|
|
28
|
+
end
|
|
29
|
+
config = _prepare_output_config(config, nil, output_schema, schema_delivery)
|
|
30
|
+
task = _start_agent_operation(input, config: config.merge(phronomy_reserved_execution_id: id),
|
|
31
|
+
mode: :invoke, listener: _phronomy_event_listener)
|
|
32
|
+
build_hosted_invocation(id, task)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
15
36
|
def start_reserved_async(reservation:, input:, config: {}, output_schema: nil, schema_delivery: nil)
|
|
16
37
|
__with_operation(async: true) do
|
|
17
38
|
unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id
|
|
@@ -210,6 +231,45 @@ module Phronomy
|
|
|
210
231
|
|
|
211
232
|
private
|
|
212
233
|
|
|
234
|
+
# An optional composition hook validates the durable parent/child relation.
|
|
235
|
+
def hosted_child_approval_reader = nil
|
|
236
|
+
|
|
237
|
+
def build_hosted_invocation(id, task)
|
|
238
|
+
reader = hosted_child_approval_reader
|
|
239
|
+
return HostedInvocation.new(self, id, task) unless reader
|
|
240
|
+
|
|
241
|
+
HostedInvocation.new(self, id, task) do |execution_id, approval_id|
|
|
242
|
+
owner = __execution_environment.registry.agent_execution_owner(execution_id)
|
|
243
|
+
reader.call(agent_id, id, execution_id, approval_id, live_child: owner.agent) if owner
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def resume_hosted_child_approval(parent_execution_id, execution_id:, approval_request_id:, approved:, config:)
|
|
248
|
+
__with_operation do
|
|
249
|
+
if Phronomy::WaitPolicy.blocking_forbidden?
|
|
250
|
+
raise Phronomy::EventLoopReentrancyError, "approve_hosted_child requires a host thread"
|
|
251
|
+
end
|
|
252
|
+
reader = hosted_child_approval_reader
|
|
253
|
+
parent_wait = execution_coordinator.recovered_coordination_wait
|
|
254
|
+
unless reader && parent_wait && parent_wait.execution_id == parent_execution_id && parent_wait.current?
|
|
255
|
+
raise Phronomy::AgentBusyError, "Parent has no matching recovered coordination wait"
|
|
256
|
+
end
|
|
257
|
+
owner = __execution_environment.registry.agent_execution_owner(execution_id)
|
|
258
|
+
unless owner&.status == :suspended
|
|
259
|
+
raise Phronomy::AgentBusyError, "No matching local child approval wait"
|
|
260
|
+
end
|
|
261
|
+
lookup = lambda do |child_execution_id, approval_id|
|
|
262
|
+
reader.call(agent_id, parent_execution_id, child_execution_id, approval_id,
|
|
263
|
+
parent_revision: parent_wait.revision, live_child: owner.agent)
|
|
264
|
+
end
|
|
265
|
+
wait = lookup.call(execution_id, approval_request_id)
|
|
266
|
+
raise Phronomy::AgentBusyError, "No matching hosted child approval wait" unless wait
|
|
267
|
+
task = owner.coordinator.resume(wait.execution_id, approval_request_id: wait.approval_request_id, approved: approved,
|
|
268
|
+
config: owner.agent._prepare_invocation_config(config, nil), parent_wait: parent_wait)
|
|
269
|
+
HostedInvocation.new(self, parent_execution_id, task, resumed_from: wait, &lookup)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
213
273
|
def _prepare_output_config(config, invocation_context, schema, schema_delivery)
|
|
214
274
|
config = _prepare_invocation_config(config, invocation_context)
|
|
215
275
|
if schema.nil?
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -752,7 +752,7 @@ module Phronomy
|
|
|
752
752
|
true
|
|
753
753
|
end
|
|
754
754
|
|
|
755
|
-
def _prepare_invocation_config(config, invocation_context)
|
|
755
|
+
protected def _prepare_invocation_config(config, invocation_context)
|
|
756
756
|
__assert_live_agent!
|
|
757
757
|
_reject_removed_generic_identity_keys!(config)
|
|
758
758
|
if config.keys.any? { |key| %w[output_schema schema_delivery response_schema phronomy_output_contract].include?(key.to_s) }
|
|
@@ -156,6 +156,10 @@ module Phronomy
|
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
def reconcile(failure)
|
|
159
|
+
# A suspended exact observer may already have released its operation.
|
|
160
|
+
# A logically cancelled observer still needs physical acknowledgement.
|
|
161
|
+
return if @completion.physical_complete?
|
|
162
|
+
|
|
159
163
|
task = @environment.submit(on_full: :raise) do
|
|
160
164
|
execution = read_execution
|
|
161
165
|
if execution&.terminal?
|
|
@@ -21,6 +21,36 @@ module Phronomy
|
|
|
21
21
|
:approved, :config, :result_task
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
+
# A resumed approval must never be adopted by the original start handle.
|
|
25
|
+
# Only a hosted decision owns the right to detach its next suspension.
|
|
26
|
+
class ApprovalOperation < OperationResult
|
|
27
|
+
attr_reader :parent_wait
|
|
28
|
+
|
|
29
|
+
def initialize(agent:, name:, parent_wait:)
|
|
30
|
+
super(agent: agent, name: name)
|
|
31
|
+
@parent_wait = parent_wait
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The recovery runner uses this task for replay of a framework Tool batch.
|
|
36
|
+
# @api private
|
|
37
|
+
class FrameworkRecoveryTask < Phronomy::TaskResult
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Recovery publishes a receipt only after its own coordination commit.
|
|
41
|
+
# The receipt belongs to this incarnation; a new local attempt invalidates it.
|
|
42
|
+
RecoveredCoordinationWait = Data.define(:coordinator, :execution_id, :revision) do
|
|
43
|
+
def current? = coordinator.current_coordination_wait?(self)
|
|
44
|
+
end
|
|
45
|
+
private_constant :ApprovalOperation, :RecoveredCoordinationWait
|
|
46
|
+
attr_reader :recovered_coordination_wait
|
|
47
|
+
|
|
48
|
+
def current_coordination_wait?(wait)
|
|
49
|
+
environment = @agent.__execution_environment
|
|
50
|
+
@recovered_coordination_wait.equal?(wait) && environment.ownership.owned?(@agent) &&
|
|
51
|
+
!environment.registry.agent_execution_admitted?(@agent.agent_id)
|
|
52
|
+
end
|
|
53
|
+
|
|
24
54
|
# Recovery -> execution-owner commands. These stay on EventLoop and do
|
|
25
55
|
# not carry persistence readers or arbitrary private method names.
|
|
26
56
|
RecoverPreparationCommand = Data.define(
|
|
@@ -74,6 +104,7 @@ module Phronomy
|
|
|
74
104
|
TerminalCommitReady = Data.define(
|
|
75
105
|
:coordinator, :operation, :delivery, :outcome, :error
|
|
76
106
|
)
|
|
107
|
+
DetachHostedWait = Data.define(:coordinator, :wait, :task, :parent, :completion)
|
|
77
108
|
SuspensionCancelledCommand = Data.define(:coordinator, :execution_id)
|
|
78
109
|
|
|
79
110
|
DeferredTerminalCommand = Data.define(
|
|
@@ -125,11 +156,11 @@ module Phronomy
|
|
|
125
156
|
execution_id,
|
|
126
157
|
approval_request_id:,
|
|
127
158
|
approved:,
|
|
128
|
-
config: {}
|
|
159
|
+
config: {}, parent_wait: nil
|
|
129
160
|
)
|
|
130
161
|
@agent.send(:__assert_live_agent!)
|
|
131
162
|
@agent.send(:_reject_removed_generic_identity_keys!, config)
|
|
132
|
-
result_task =
|
|
163
|
+
result_task = ApprovalOperation.new(agent: @agent, parent_wait: parent_wait,
|
|
133
164
|
name: "agent-approval-resume:#{execution_id}")
|
|
134
165
|
command = ResumeCommand.new(
|
|
135
166
|
coordinator: self,
|
|
@@ -248,6 +279,8 @@ module Phronomy
|
|
|
248
279
|
apply_provider_dispatch_preparation_on_event_loop(command)
|
|
249
280
|
when ToolDispatchPreparationReady
|
|
250
281
|
apply_tool_dispatch_preparation_on_event_loop(command)
|
|
282
|
+
when DetachHostedWait
|
|
283
|
+
detach_hosted_wait(command, environment)
|
|
251
284
|
when ResumeCommand
|
|
252
285
|
begin_resume_on_event_loop(command)
|
|
253
286
|
when ResumeCommitReady
|
|
@@ -265,6 +298,47 @@ module Phronomy
|
|
|
265
298
|
|
|
266
299
|
private
|
|
267
300
|
|
|
301
|
+
# Called on EventLoop after the host has read the committed wait outside it.
|
|
302
|
+
def detach_hosted_wait(command, environment)
|
|
303
|
+
wait = command.wait
|
|
304
|
+
registry = environment.registry
|
|
305
|
+
state = registry.agent_execution_state(wait.execution_id)
|
|
306
|
+
binding = @suspended_cancellation
|
|
307
|
+
task = command.task || binding&.fetch(2)
|
|
308
|
+
parent = command.parent
|
|
309
|
+
if task.is_a?(ApprovalOperation) && (!command.task.equal?(task) || !task.parent_wait&.current?)
|
|
310
|
+
raise Phronomy::AgentBusyError, "This handle does not own the resumed child wait"
|
|
311
|
+
end
|
|
312
|
+
if parent && registry.agent_execution_admitted?(parent.agent_id)
|
|
313
|
+
raise Phronomy::AgentBusyError, "Hosted parent has another local execution"
|
|
314
|
+
end
|
|
315
|
+
unless state && state.agent.equal?(@agent) && state.execution.status == :suspended &&
|
|
316
|
+
state.execution.execution_revision == wait.execution_revision && state.fsm_session_id.nil? &&
|
|
317
|
+
state.execution.approval_request&.fetch("id", nil) == wait.approval_request_id &&
|
|
318
|
+
binding && task.is_a?(OperationResult) && binding[1].equal?(state.invocation) && binding[2].equal?(task) &&
|
|
319
|
+
!state.invocation.config[:cancellation_token]&.cancelled? && registry.agent_execution_quiescent?(wait.execution_id)
|
|
320
|
+
raise Phronomy::AgentBusyError, "Hosted operation no longer owns this approval wait"
|
|
321
|
+
end
|
|
322
|
+
registry.mark_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id,
|
|
323
|
+
state: :suspended, expected_state: :suspended)
|
|
324
|
+
task.reserve_approval_detach!(command, parent: parent)
|
|
325
|
+
begin
|
|
326
|
+
# release_agent_execution validates all remaining physical work before mutation.
|
|
327
|
+
registry.release_agent_execution(wait.execution_id)
|
|
328
|
+
rescue
|
|
329
|
+
environment.ownership.finish_unload(@agent, command, success: false, parent: parent)
|
|
330
|
+
raise
|
|
331
|
+
end
|
|
332
|
+
clear_suspended_cancellation
|
|
333
|
+
registry.release_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id)
|
|
334
|
+
waiters = registry.take_agent_completion_waiters(wait.execution_id, fallback: task)
|
|
335
|
+
environment.ownership.finish_unload(@agent, command, success: true, parent: parent)
|
|
336
|
+
waiters.each { |waiter| @agent.send(:_complete_result_task, waiter, wait) }
|
|
337
|
+
command.completion.complete(wait)
|
|
338
|
+
rescue => error
|
|
339
|
+
command.completion.fail(error)
|
|
340
|
+
end
|
|
341
|
+
|
|
268
342
|
# ----------------------------------------------------------------------
|
|
269
343
|
# Initial preparation
|
|
270
344
|
# ----------------------------------------------------------------------
|
|
@@ -296,6 +370,7 @@ module Phronomy
|
|
|
296
370
|
# must abort instead of deleting the Agent underneath this start.
|
|
297
371
|
@agent.send(:__assert_live_agent!)
|
|
298
372
|
|
|
373
|
+
@recovered_coordination_wait = nil
|
|
299
374
|
operation = capture_initial_preparation(request, root)
|
|
300
375
|
task = environment.submit(on_full: :raise) do
|
|
301
376
|
@initial_preparation.prepare(operation)
|
|
@@ -901,6 +976,12 @@ module Phronomy
|
|
|
901
976
|
return
|
|
902
977
|
end
|
|
903
978
|
|
|
979
|
+
if request.result_task.is_a?(ApprovalOperation) && (parent_wait = request.result_task.parent_wait)
|
|
980
|
+
unless parent_wait.current? && !@suspended_cancellation
|
|
981
|
+
return fail_task(request.result_task, Phronomy::AgentBusyError.new("Hosted decision requires a restored child wait"))
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
|
|
904
985
|
approval = state.execution.approval_request || {}
|
|
905
986
|
current_request_id = approval["id"] || approval[:id]
|
|
906
987
|
unless current_request_id.to_s == request.approval_request_id
|
|
@@ -1088,6 +1169,7 @@ module Phronomy
|
|
|
1088
1169
|
environment = @agent.__execution_environment
|
|
1089
1170
|
registry = environment.registry
|
|
1090
1171
|
state = recovered_execution_state!(command)
|
|
1172
|
+
@recovered_coordination_wait = nil
|
|
1091
1173
|
validate_recovered_continuation!(state.execution, command.continuation)
|
|
1092
1174
|
invocation = command.invocation
|
|
1093
1175
|
unless invocation && invocation.agent.equal?(@agent) &&
|
|
@@ -1495,6 +1577,11 @@ module Phronomy
|
|
|
1495
1577
|
delivery = ready.delivery
|
|
1496
1578
|
outcome = ready.outcome
|
|
1497
1579
|
release_terminal_ownership(environment, operation.execution_id, state)
|
|
1580
|
+
if delivery.result_task.is_a?(OperationResult)
|
|
1581
|
+
delivery.result_task.coordination_wait_execution_id = operation.execution_id
|
|
1582
|
+
elsif delivery.result_task.is_a?(FrameworkRecoveryTask)
|
|
1583
|
+
@recovered_coordination_wait = RecoveredCoordinationWait.new(self, operation.execution_id, outcome.execution.execution_revision)
|
|
1584
|
+
end
|
|
1498
1585
|
environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| fail_task(task, outcome.error) }
|
|
1499
1586
|
end
|
|
1500
1587
|
|
|
@@ -37,7 +37,12 @@ module Phronomy
|
|
|
37
37
|
# clear_transcript! does not remove Knowledge, while knowledge_cleared and
|
|
38
38
|
# context_reset invalidate earlier Knowledge records by Journal position.
|
|
39
39
|
def context_records
|
|
40
|
-
|
|
40
|
+
rejected_batches = rejected_tool_batches
|
|
41
|
+
messages = transcript_records.reject do |record|
|
|
42
|
+
%i[assistant_message tool_message].include?(record.kind) &&
|
|
43
|
+
record.llm_call_id && rejected_batches[record.execution_id] == record.llm_call_id
|
|
44
|
+
end
|
|
45
|
+
(messages + active_knowledge_records)
|
|
41
46
|
.sort_by { |record| [record.sequence || 0, record.record_id] }
|
|
42
47
|
.freeze
|
|
43
48
|
end
|
|
@@ -52,6 +57,24 @@ module Phronomy
|
|
|
52
57
|
|
|
53
58
|
private
|
|
54
59
|
|
|
60
|
+
# Rejection ends the execution at its final provider Tool batch. That
|
|
61
|
+
# exchange has no complete set of Tool replies to send to a provider.
|
|
62
|
+
# Identify it from existing journal facts, including pre-upgrade histories.
|
|
63
|
+
def rejected_tool_batches
|
|
64
|
+
rejected = {}
|
|
65
|
+
batches = {}
|
|
66
|
+
records.reverse_each do |record|
|
|
67
|
+
if record.kind == :execution_rejected
|
|
68
|
+
rejected[record.execution_id] = true
|
|
69
|
+
elsif record.kind == :assistant_message && rejected.delete(record.execution_id)
|
|
70
|
+
if Array(record.metadata["tool_call_ids"]).any? && record.llm_call_id
|
|
71
|
+
batches[record.execution_id] = record.llm_call_id
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
batches
|
|
76
|
+
end
|
|
77
|
+
|
|
55
78
|
def active_knowledge_records
|
|
56
79
|
reset_sequence = records.filter_map do |record|
|
|
57
80
|
record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
|