phronomy 0.23.0 → 0.24.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/.mutant.yml +2 -2
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +2 -2
- data/README.md +1 -1
- data/docs/architecture/multi-agent-handoff.md +35 -40
- data/docs/architecture/persistence.md +19 -8
- data/docs/architecture.md +8 -1
- data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
- data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +5 -1
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +35 -1
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +42 -3
- data/lib/phronomy/agent/agent_execution.rb +2 -2
- data/lib/phronomy/agent/agent_invocation.rb +1 -1
- data/lib/phronomy/agent/async_event_api.rb +18 -1
- data/lib/phronomy/agent/base.rb +28 -0
- data/lib/phronomy/agent/context_assembler.rb +1 -1
- data/lib/phronomy/agent/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +484 -27
- data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
- data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
- data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
- data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
- data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
- data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
- data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
- data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
- data/lib/phronomy/agent/recovery_support.rb +15 -23
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
- data/lib/phronomy/agent/tool_invocation.rb +4 -2
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +16 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +60 -0
- data/lib/phronomy/persistence/in_memory.rb +264 -2
- data/lib/phronomy/persistence/repository_facades.rb +221 -2
- data/lib/phronomy/persistence.rb +95 -1
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- data/lib/phronomy/version.rb +1 -1
- data/scripts/api_snapshot.rb +3 -3
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +28 -32
- data/sig/phronomy/persistence.rbs +64 -3
- metadata +30 -12
- data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
- data/lib/phronomy/multi_agent/coordinator.rb +0 -154
- data/lib/phronomy/multi_agent/runner.rb +0 -98
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Durable semantic coordination V2 migration
|
|
2
|
+
|
|
3
|
+
This Beta clean break applies to baseline `5472116cd99a63ec27875024c955ea82be612d6b`.
|
|
4
|
+
|
|
5
|
+
| Previous surface | Current surface |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `MultiAgent::Handoff`, `HandoffPolicy`, `Runner` | `Agent::Handoff`, `HandoffPolicy`, `HandoffRunner` (no aliases) |
|
|
8
|
+
| Runtime-local Handoff routing | HandoffState at original main Agent ID; same Persistence domain required |
|
|
9
|
+
| Anonymous Team/new Runtime queue | Explicit `team_definition id:, version:` and stable `team_id`; TeamRoot/TeamExecution |
|
|
10
|
+
| Arbitrary aggregate object/error exception result | Pure replay-safe aggregate, canonical JSON result/error data |
|
|
11
|
+
| Five backend repositories | Eight mandatory repositories; extend root and transaction views |
|
|
12
|
+
| Direct standalone fan-out | Remains Runtime-only; no synthetic parent AgentExecution |
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
class WorkTeam < Phronomy::MultiAgent::TeamCoordinator
|
|
16
|
+
team_definition id: "work-team", version: 1
|
|
17
|
+
coordinator_model "gpt-4o-mini"
|
|
18
|
+
coordinator_provider :openai
|
|
19
|
+
pool size: 2, agent: WorkerAgent
|
|
20
|
+
aggregate { |assignments| assignments.map { |a| a[:result] }.compact.join("\n") }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
team = WorkTeam.create(team_id: "team-42", persistence: store)
|
|
24
|
+
value = team.invoke("Prepare the report")
|
|
25
|
+
runs = store.list_team_executions("team-42")
|
|
26
|
+
run_id = runs.first.team_execution_id
|
|
27
|
+
retained = store.team_execution_result(run_id)
|
|
28
|
+
# After a restart with the same declared Team and worker definitions:
|
|
29
|
+
team = WorkTeam.load("team-42", persistence: store, on_event: recovery_listener)
|
|
30
|
+
value = team.resume(run_id)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`resume` consumes a stored terminal outcome without running the aggregator again.
|
|
34
|
+
For unfinished runs, stored input/context/assignments and child reservations win.
|
|
35
|
+
Compatible current Agent/Team classes, static registrations and Handoff graph are
|
|
36
|
+
execution wiring, not serialized objects. Version matching checks declarations;
|
|
37
|
+
Application must change versions for incompatible behavior and retain compatible
|
|
38
|
+
wiring for old runs. It is not a source-code hash or automatic migration system.
|
|
39
|
+
|
|
40
|
+
Use `Persistence#execution_result(id)` and owner-scoped `list_executions` for Agent
|
|
41
|
+
results without hydration/callbacks. `Persistence#handoff_result(source_id)`
|
|
42
|
+
follows the exact transfer chain without constructing a graph or Agent owners. `Orchestrator#resume(id)` continues existing
|
|
43
|
+
static subagent coordination. There is no global generic class registry.
|
|
44
|
+
|
|
45
|
+
Observer Task wait timeouts and shutdown do not request cancellation. Team's
|
|
46
|
+
`cancel(run_id)` persists a run-scoped request before forwarding its live token;
|
|
47
|
+
resume settles exact children or returns the existing rehydration error. Handoff's
|
|
48
|
+
`cancel(execution_id)` follows only that turn. A cancellation token passed to
|
|
49
|
+
invoke remains the existing Agent execution cancellation mechanism; it does not
|
|
50
|
+
promise rollback of external effects. Persisted pending child IDs remain
|
|
51
|
+
available for recovery when external factual resolution is required.
|
|
52
|
+
|
|
53
|
+
Callbacks and streaming progress may be lost after Runtime/process loss.
|
|
54
|
+
Application notifications, outboxes, retries and external-effect deduplication
|
|
55
|
+
belong to Application. No callback delivery ACK or restart replay is added.
|
|
56
|
+
InMemory is useful for tests but a durable backend is required for actual F4
|
|
57
|
+
storage retention. See ADR-018 and the V2 RC contract for guarantee limits.
|
|
58
|
+
|
|
59
|
+
The existing `Agent#purge!` removes its own Handoff anchor atomically with its
|
|
60
|
+
Agent records, after the anchor has no pending/active turn. Referenced outcomes
|
|
61
|
+
deleted through retention/purge are unavailable, not evidence of a new reservation.
|
|
62
|
+
Do not purge participating child/Target owners while their coordination is active.
|
|
63
|
+
Team `stream` observes committed assignment progress; the construction `on_event`
|
|
64
|
+
listener is passed to hidden Agents for their current Runtime events and Recovery.
|
|
65
|
+
There is no additional Team callback delivery subsystem.
|
|
@@ -44,7 +44,7 @@ contract.
|
|
|
44
44
|
|
|
45
45
|
## Required root surface
|
|
46
46
|
|
|
47
|
-
A Persistence backend exposes
|
|
47
|
+
A Persistence backend exposes eight durable repositories:
|
|
48
48
|
|
|
49
49
|
```text
|
|
50
50
|
contents
|
|
@@ -52,6 +52,9 @@ agents
|
|
|
52
52
|
journals
|
|
53
53
|
executions
|
|
54
54
|
workflow_states
|
|
55
|
+
handoff_states
|
|
56
|
+
teams
|
|
57
|
+
team_executions
|
|
55
58
|
```
|
|
56
59
|
|
|
57
60
|
and two root operations:
|
|
@@ -66,7 +69,7 @@ persistence.assert_agent_watermark!(
|
|
|
66
69
|
```
|
|
67
70
|
|
|
68
71
|
The object yielded by `transaction` is a transaction-scoped Persistence view. It
|
|
69
|
-
must respond to all
|
|
72
|
+
must respond to all eight repository accessors and
|
|
70
73
|
`assert_agent_watermark!`. It may be the Persistence instance itself, but SQL
|
|
71
74
|
backends may instead yield an object bound to a checked-out connection or
|
|
72
75
|
transaction session.
|
|
@@ -90,7 +93,7 @@ this requirement.
|
|
|
90
93
|
|
|
91
94
|
All durable repositories must be able to participate in one atomic transaction
|
|
92
95
|
domain. A transaction may change `contents`, `agents`, `journals`, `executions`,
|
|
93
|
-
|
|
96
|
+
`workflow_states`, `handoff_states`, `teams`, and `team_executions`, and then either commit all changes or roll them all back.
|
|
94
97
|
|
|
95
98
|
This requirement deliberately does not claim exactly-once semantics after an
|
|
96
99
|
indeterminate database/network failure. If the underlying database cannot tell
|
|
@@ -269,6 +272,7 @@ def create_active(execution)
|
|
|
269
272
|
def load(execution_id)
|
|
270
273
|
def save(execution_id, expected_revision:, execution:)
|
|
271
274
|
def list_active(agent_id)
|
|
275
|
+
def list(agent_id, after: nil, limit: 100)
|
|
272
276
|
def delete(execution_id)
|
|
273
277
|
def delete_for_agent(agent_id)
|
|
274
278
|
def assert_idle!(agent_id)
|
|
@@ -521,6 +525,9 @@ RSpec.describe MyPersistenceBackend do
|
|
|
521
525
|
it_behaves_like "a Journal repository"
|
|
522
526
|
it_behaves_like "an Execution repository"
|
|
523
527
|
it_behaves_like "a workflow state repository"
|
|
528
|
+
it_behaves_like "a Handoff state repository"
|
|
529
|
+
it_behaves_like "a Team repository"
|
|
530
|
+
it_behaves_like "a Team execution repository"
|
|
524
531
|
it_behaves_like "a Persistence backend"
|
|
525
532
|
end
|
|
526
533
|
```
|
|
@@ -552,3 +559,35 @@ may use combinations of:
|
|
|
552
559
|
|
|
553
560
|
Backend-specific database exceptions should be translated to the Phronomy error
|
|
554
561
|
contract where their meaning is known.
|
|
562
|
+
|
|
563
|
+
## V2 coordination Backend SPI (clean break)
|
|
564
|
+
|
|
565
|
+
Both `Persistence.new` and `build_transaction_view` require all eight raw
|
|
566
|
+
repositories. Five-repository fallback is removed. Existing record formats remain
|
|
567
|
+
`0.1`; three additional record types are `phronomy.handoff_state`,
|
|
568
|
+
`phronomy.team_root`, and `phronomy.team_execution`. Backend index metadata is
|
|
569
|
+
explicit and MUST NOT be reconstructed by parsing DurableRecord payloads.
|
|
570
|
+
|
|
571
|
+
| Raw repository | Required operations and explicit metadata |
|
|
572
|
+
|---|---|
|
|
573
|
+
| `handoff_states` | `load(main_agent_id)`; `save(main_agent_id, expected_revision:, next_revision:, active_agent_id:, record:)`; `delete(main_agent_id, expected_revision:)` |
|
|
574
|
+
| `teams` | `create(team_id:, team_revision:, record:)`; `load(team_id)`; `save(team_id, expected_revision:, next_revision:, record:)`; `delete(team_id)` |
|
|
575
|
+
| `team_executions` | `create_active(team_execution_id:, team_id:, execution_revision:, record:)`; `load(id)`; `save(id, expected_revision:, next_revision:, team_id:, active:, record:)`; `list_active(team_id)`; `list(team_id, after: nil, limit: 100)`; `delete(id)`; `delete_for_team(team_id)`; `assert_idle!(team_id)` |
|
|
576
|
+
| `executions` extension | `list(agent_id, after: nil, limit: 100)` for retained active and terminal records |
|
|
577
|
+
|
|
578
|
+
Handoff starts at revision 1 with expected revision nil; later saves advance
|
|
579
|
+
exactly one revision. Team roots and executions begin at revision 0. Team
|
|
580
|
+
admission has the existing `AgentBusyError` contract for an already active owner;
|
|
581
|
+
stale CAS/duplicate identity uses `ConflictError`. Missing Team/execution loads
|
|
582
|
+
raise `NotFoundError`; absent Handoff state returns nil. Unavailable reads and
|
|
583
|
+
codec errors must propagate separately.
|
|
584
|
+
|
|
585
|
+
Lists sort IDs lexically, use an exclusive ID cursor, return at most the positive
|
|
586
|
+
integer limit, include only the requested owner, and return immutable copies.
|
|
587
|
+
Backends define retention; enumeration does not implement input correlation or
|
|
588
|
+
request deduplication. Terminal-to-active rewrites are invalid.
|
|
589
|
+
|
|
590
|
+
For F1, atomic commit does not imply a known response. Phronomy reads back the
|
|
591
|
+
same intended ID/fact before advancing. A backend must provide authoritative
|
|
592
|
+
reads/CAS; failure of readback is returned without new semantic work. No callback
|
|
593
|
+
ACK/index is part of the SPI. Existing cross-process exclusion limitations remain.
|
|
@@ -28,10 +28,10 @@ module Phronomy
|
|
|
28
28
|
].freeze
|
|
29
29
|
attr_reader(*ATTRIBUTES)
|
|
30
30
|
|
|
31
|
-
def self.start(agent_root:, input_record:, metadata: {})
|
|
31
|
+
def self.start(agent_root:, input_record:, metadata: {}, execution_id: SecureRandom.uuid)
|
|
32
32
|
now = Time.now.utc.iso8601(6)
|
|
33
33
|
new(
|
|
34
|
-
execution_id:
|
|
34
|
+
execution_id: execution_id.to_s,
|
|
35
35
|
agent_id: agent_root.agent_id,
|
|
36
36
|
execution_revision: 0,
|
|
37
37
|
status: :preparing,
|
|
@@ -458,7 +458,7 @@ module Phronomy
|
|
|
458
458
|
key = :"include_#{category}"
|
|
459
459
|
result[category] = args[key] if args.key?(key)
|
|
460
460
|
end
|
|
461
|
-
Phronomy::
|
|
461
|
+
Phronomy::Agent::HandoffRequest.new(
|
|
462
462
|
handoff: handoff,
|
|
463
463
|
responsibility: args.fetch(:responsibility),
|
|
464
464
|
selection_intent: selection,
|
|
@@ -168,6 +168,7 @@ module Phronomy
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
def _start_agent_operation(input, config:, mode:, listener:)
|
|
171
|
+
config = _snapshot_durable_context(config)
|
|
171
172
|
approval = _approval_configuration_snapshot(nil)
|
|
172
173
|
execution_coordinator_for(config).start(
|
|
173
174
|
input,
|
|
@@ -179,6 +180,22 @@ module Phronomy
|
|
|
179
180
|
)
|
|
180
181
|
end
|
|
181
182
|
|
|
183
|
+
def _snapshot_durable_context(config)
|
|
184
|
+
return config unless config.key?(:durable_context)
|
|
185
|
+
|
|
186
|
+
value = config[:durable_context]
|
|
187
|
+
unless value.is_a?(Hash)
|
|
188
|
+
raise ArgumentError,
|
|
189
|
+
"config[:durable_context] must be a canonical JSON-compatible Hash"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
bytes = Phronomy::CanonicalJSON.dump(value)
|
|
193
|
+
snapshot = Phronomy::Agent::Immutable.copy(
|
|
194
|
+
Phronomy::CanonicalJSON.load(bytes)
|
|
195
|
+
)
|
|
196
|
+
config.merge(durable_context: snapshot)
|
|
197
|
+
end
|
|
198
|
+
|
|
182
199
|
def _required_stream_listener!(method_name)
|
|
183
200
|
listener = _phronomy_event_listener
|
|
184
201
|
return listener if listener
|
|
@@ -209,7 +226,7 @@ module Phronomy
|
|
|
209
226
|
return execution_coordinator unless multi_agent
|
|
210
227
|
|
|
211
228
|
@multi_agent_execution_coordinator ||=
|
|
212
|
-
Phronomy::
|
|
229
|
+
Phronomy::Agent::HandoffExecutionCoordinator.new(self)
|
|
213
230
|
end
|
|
214
231
|
end
|
|
215
232
|
end
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -267,6 +267,28 @@ module Phronomy
|
|
|
267
267
|
end
|
|
268
268
|
end
|
|
269
269
|
|
|
270
|
+
# @api private
|
|
271
|
+
def __coordination_config
|
|
272
|
+
@_phronomy_coordination_config || {}.freeze
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Framework-owned idempotent Tool operations opt into exact replay.
|
|
276
|
+
# @api private
|
|
277
|
+
def __framework_tool_replayable?(_name) = false
|
|
278
|
+
|
|
279
|
+
# @api private
|
|
280
|
+
def __framework_call?(name)
|
|
281
|
+
__framework_tool_replayable?(name) || Array(__coordination_config[:phronomy_handoff_bindings]).any? { |binding| binding.tool_name == name }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Captured Tool wiring is supplied explicitly; this hook only writes semantic values.
|
|
285
|
+
# @api private
|
|
286
|
+
def __prepare_coordination_record(execution, tx:) = execution
|
|
287
|
+
|
|
288
|
+
# Runtime configuration only; no value returned here is serialized.
|
|
289
|
+
# @api private
|
|
290
|
+
def __invocation_config(config) = config
|
|
291
|
+
|
|
270
292
|
attr_reader :agent_id, :persistence
|
|
271
293
|
|
|
272
294
|
def initialize(
|
|
@@ -438,6 +460,12 @@ module Phronomy
|
|
|
438
460
|
begin
|
|
439
461
|
persistence.transaction do |tx|
|
|
440
462
|
tx.executions.assert_idle!(agent_id)
|
|
463
|
+
if (routing = tx.handoff_states.load(agent_id))
|
|
464
|
+
unless routing.phase == "stable" && tx.executions.list_active(routing.active_agent_id).empty?
|
|
465
|
+
raise Phronomy::AgentBusyError, "Handoff anchor #{agent_id} owns an unfinished turn"
|
|
466
|
+
end
|
|
467
|
+
tx.handoff_states.delete(agent_id, expected_revision: routing.handoff_revision)
|
|
468
|
+
end
|
|
441
469
|
tx.journals.delete(agent_id)
|
|
442
470
|
tx.executions.delete_for_agent(agent_id)
|
|
443
471
|
tx.agents.delete(agent_id)
|
|
@@ -552,7 +552,7 @@ module Phronomy
|
|
|
552
552
|
def merge_handoff_candidates(candidates, handoff_context, execution:)
|
|
553
553
|
return Array(candidates).freeze unless handoff_context
|
|
554
554
|
|
|
555
|
-
unless handoff_context.is_a?(Phronomy::
|
|
555
|
+
unless handoff_context.is_a?(Phronomy::Agent::HandoffContext)
|
|
556
556
|
raise ArgumentError, "phronomy_handoff_context must be a HandoffContext"
|
|
557
557
|
end
|
|
558
558
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Observes a reserved execution using ordinary Agent admission and Recovery.
|
|
6
|
+
# Durable reads/materialization run off EventLoop. Workers never wait for a
|
|
7
|
+
# child lifecycle: the existing EventLoop completion waiter settles Task.
|
|
8
|
+
# @api private
|
|
9
|
+
class ExactExecution
|
|
10
|
+
Wait = Data.define(:coordinator, :agent, :execution_id)
|
|
11
|
+
private_constant :Wait
|
|
12
|
+
|
|
13
|
+
def self.start(agent:, execution_id:, input:, config: {})
|
|
14
|
+
new(agent, execution_id, input, config).start
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(agent, execution_id, input, config)
|
|
18
|
+
@agent, @id, @input, @config = agent, execution_id.to_s.freeze, input, config.freeze
|
|
19
|
+
@runtime = Phronomy::Runtime.instance
|
|
20
|
+
@completion = Phronomy::Task.deferred(name: "exact-execution:#{@id}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
preparation = @runtime.offload.submit(on_full: :raise) do
|
|
25
|
+
execution = read_execution
|
|
26
|
+
if execution&.terminal?
|
|
27
|
+
[:terminal, materialize(execution)]
|
|
28
|
+
elsif execution
|
|
29
|
+
unless @runtime.__agent_execution_owner(@id)
|
|
30
|
+
@agent.instance_variable_set(:@_phronomy_coordination_config, @config)
|
|
31
|
+
begin
|
|
32
|
+
RecoveryCoordinator.new(@agent).recover_on_load!
|
|
33
|
+
rescue Phronomy::ConfigurationError => error
|
|
34
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
35
|
+
"Execution #{@id} needs current recovery wiring: #{error.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
[:active, nil]
|
|
39
|
+
else
|
|
40
|
+
@config[:cancellation_token]&.raise_if_cancelled!
|
|
41
|
+
[:absent, nil]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
preparation.on_complete do |prepared, error|
|
|
45
|
+
if error
|
|
46
|
+
@completion.fail(error)
|
|
47
|
+
else
|
|
48
|
+
disposition, result = prepared
|
|
49
|
+
case disposition
|
|
50
|
+
when :terminal then @completion.complete(result)
|
|
51
|
+
when :absent
|
|
52
|
+
source = @agent.send(:_start_agent_operation, @input,
|
|
53
|
+
config: @config.merge(phronomy_reserved_execution_id: @id, phronomy_exact_observers: [@completion].freeze),
|
|
54
|
+
mode: :invoke, listener: @agent.send(:_phronomy_event_listener))
|
|
55
|
+
source.on_complete { |_value, failure| reconcile(failure) }
|
|
56
|
+
when :active
|
|
57
|
+
command = Wait.new(coordinator: self, agent: @agent, execution_id: @id)
|
|
58
|
+
posted = @runtime.event_loop.post(Phronomy::Event.new(type: :agent_terminal_ready,
|
|
59
|
+
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {command: command}))
|
|
60
|
+
@completion.fail(Phronomy::RuntimeShutdownError.new("Exact execution observer rejected")) unless posted
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
rescue => failure
|
|
64
|
+
@completion.fail(failure)
|
|
65
|
+
end
|
|
66
|
+
@completion
|
|
67
|
+
rescue => failure
|
|
68
|
+
@completion.fail(failure)
|
|
69
|
+
@completion
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deliver_on_event_loop(command)
|
|
73
|
+
state = @runtime.event_loop.agent_execution_state(command.execution_id)
|
|
74
|
+
unless state
|
|
75
|
+
# Ownership can be released between the durable read and this command.
|
|
76
|
+
# Reinstall once with the caller's current wiring/cancellation request.
|
|
77
|
+
if !@reinstall_attempted
|
|
78
|
+
@reinstall_attempted = true
|
|
79
|
+
return start
|
|
80
|
+
end
|
|
81
|
+
return reconcile(nil)
|
|
82
|
+
end
|
|
83
|
+
unless state.agent.equal?(command.agent)
|
|
84
|
+
raise Phronomy::Persistence::ConflictError, "Exact execution #{@id} owner mismatch"
|
|
85
|
+
end
|
|
86
|
+
if @config[:cancellation_token]&.cancelled?
|
|
87
|
+
state.invocation&.config&.fetch(:cancellation_token, nil)&.cancel!
|
|
88
|
+
end
|
|
89
|
+
if state.execution.status == :suspended || state.fsm_session_id.nil?
|
|
90
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
91
|
+
"Execution #{@id} requires approval, factual resolution or current recovery wiring"
|
|
92
|
+
end
|
|
93
|
+
observers = Array(state.invocation.config[:phronomy_exact_observers])
|
|
94
|
+
state.invocation.merge_config!(phronomy_exact_observers: (observers + [@completion]).uniq.freeze)
|
|
95
|
+
waiter = Phronomy::Task.deferred(name: "exact-wait:#{@id}")
|
|
96
|
+
waiter.on_complete { |_result, failure| reconcile(failure) }
|
|
97
|
+
@runtime.event_loop.register_agent_completion_waiter(@id, waiter)
|
|
98
|
+
rescue => failure
|
|
99
|
+
@completion.fail(failure)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def read_execution
|
|
105
|
+
begin
|
|
106
|
+
execution = @agent.persistence.executions.load(@id)
|
|
107
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
108
|
+
return nil
|
|
109
|
+
end
|
|
110
|
+
unless execution.agent_id == @agent.agent_id
|
|
111
|
+
raise Phronomy::Persistence::ConflictError, "Reserved execution #{@id} belongs to another Agent"
|
|
112
|
+
end
|
|
113
|
+
expected = @config[:phronomy_coordination]
|
|
114
|
+
stored = execution.metadata["coordination"]
|
|
115
|
+
if expected && stored != expected
|
|
116
|
+
actual_identity = stored&.except("handoff_revision")
|
|
117
|
+
expected_identity = expected.except("handoff_revision")
|
|
118
|
+
unless actual_identity == expected_identity
|
|
119
|
+
raise Phronomy::Persistence::ConflictError, "Reserved execution #{@id} coordination mismatch"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
ref = execution.metadata["current_input_ref"]
|
|
123
|
+
if ref && @agent.persistence.contents.fetch_text(ref) != @input
|
|
124
|
+
raise Phronomy::Persistence::ConflictError, "Reserved execution #{@id} input mismatch"
|
|
125
|
+
end
|
|
126
|
+
execution
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reconcile(failure)
|
|
130
|
+
task = @runtime.offload.submit(on_full: :raise) do
|
|
131
|
+
execution = read_execution
|
|
132
|
+
if execution&.terminal?
|
|
133
|
+
materialize(execution)
|
|
134
|
+
else
|
|
135
|
+
raise(failure || Phronomy::ExecutionRehydrationRequiredError.new(
|
|
136
|
+
"Exact execution #{@id} is unfinished; resume with current wiring"
|
|
137
|
+
))
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
task.on_complete do |result, error|
|
|
141
|
+
error ? @completion.fail(error) : @completion.complete(result)
|
|
142
|
+
end
|
|
143
|
+
rescue => error
|
|
144
|
+
@completion.fail(error)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def materialize(execution)
|
|
148
|
+
result = @agent.persistence.execution_result(execution.execution_id)
|
|
149
|
+
result.merge(output: result[:result]).freeze
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Wakes the existing execution cancellation token. Durable owners record the
|
|
6
|
+
# request first; this notification is replaceable after Runtime loss.
|
|
7
|
+
# @api private
|
|
8
|
+
class ExecutionCancellation
|
|
9
|
+
Command = Data.define(:coordinator, :execution_id, :agent_id)
|
|
10
|
+
private_constant :Command
|
|
11
|
+
|
|
12
|
+
def self.signal(execution_id, agent_id)
|
|
13
|
+
command = Command.new(coordinator: new, execution_id: execution_id, agent_id: agent_id)
|
|
14
|
+
Phronomy::Runtime.instance.event_loop.post(Phronomy::Event.new(type: :agent_terminal_ready,
|
|
15
|
+
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {command: command}))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deliver_on_event_loop(command)
|
|
19
|
+
state = Phronomy::Runtime.instance.event_loop.agent_execution_state(command.execution_id)
|
|
20
|
+
return unless state && state.agent.agent_id == command.agent_id
|
|
21
|
+
state.invocation&.config&.fetch(:cancellation_token, nil)&.cancel!
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|