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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +2 -2
  3. data/CHANGELOG.md +15 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +1 -1
  6. data/docs/architecture/multi-agent-handoff.md +35 -40
  7. data/docs/architecture/persistence.md +19 -8
  8. data/docs/architecture.md +8 -1
  9. data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
  10. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  11. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  12. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  13. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  14. data/docs/decisions/README.md +5 -1
  15. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  16. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  17. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  18. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  19. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  20. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  21. data/docs/features.md +35 -1
  22. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  23. data/docs/persistence-backends.md +42 -3
  24. data/lib/phronomy/agent/agent_execution.rb +2 -2
  25. data/lib/phronomy/agent/agent_invocation.rb +1 -1
  26. data/lib/phronomy/agent/async_event_api.rb +18 -1
  27. data/lib/phronomy/agent/base.rb +28 -0
  28. data/lib/phronomy/agent/context_assembler.rb +1 -1
  29. data/lib/phronomy/agent/exact_execution.rb +153 -0
  30. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  31. data/lib/phronomy/agent/execution_coordinator.rb +484 -27
  32. data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
  33. data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
  34. data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
  35. data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
  36. data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
  37. data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
  38. data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
  39. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  40. data/lib/phronomy/agent/handoff_state.rb +43 -0
  41. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
  42. data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
  43. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
  44. data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
  45. data/lib/phronomy/agent/recovery_support.rb +15 -23
  46. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
  47. data/lib/phronomy/agent/tool_invocation.rb +4 -2
  48. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  49. data/lib/phronomy/engine/runtime.rb +16 -1
  50. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  51. data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  53. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  54. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  55. data/lib/phronomy/persistence/durable_codec.rb +60 -0
  56. data/lib/phronomy/persistence/in_memory.rb +264 -2
  57. data/lib/phronomy/persistence/repository_facades.rb +221 -2
  58. data/lib/phronomy/persistence.rb +95 -1
  59. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
  60. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  61. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  62. data/lib/phronomy/tools/agent.rb +1 -1
  63. data/lib/phronomy/version.rb +1 -1
  64. data/scripts/api_snapshot.rb +3 -3
  65. data/sig/phronomy/handoff.rbs +41 -0
  66. data/sig/phronomy/multi_agent.rbs +28 -32
  67. data/sig/phronomy/persistence.rbs +64 -3
  68. metadata +30 -12
  69. data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
  70. data/lib/phronomy/multi_agent/coordinator.rb +0 -154
  71. 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 five durable repositories:
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 five repository accessors and
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
- and `workflow_states` and then either commit all changes or roll them all back.
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: SecureRandom.uuid,
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::MultiAgent::HandoffRequest.new(
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::MultiAgent::ExecutionCoordinator.new(self)
229
+ Phronomy::Agent::HandoffExecutionCoordinator.new(self)
213
230
  end
214
231
  end
215
232
  end
@@ -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::MultiAgent::HandoffContext)
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