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
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require "digest"
4
4
 
5
5
  module Phronomy
6
- module MultiAgent
6
+ module Agent
7
7
  # Application-defined semantic edge for transferring active responsibility
8
8
  # from one live Agent instance to another.
9
9
  class Handoff
@@ -19,14 +19,14 @@ module Phronomy
19
19
  raise ArgumentError, "Handoff source_agent and target_agent must be different instances"
20
20
  end
21
21
  unless policy.is_a?(HandoffPolicy)
22
- raise ArgumentError, "policy must be a Phronomy::MultiAgent::HandoffPolicy"
22
+ raise ArgumentError, "policy must be a Phronomy::Agent::HandoffPolicy"
23
23
  end
24
24
 
25
25
  @source_agent = source_agent
26
26
  @target_agent = target_agent
27
27
  @policy = policy
28
28
  @description = (description || default_description).to_s.freeze
29
- @transport_key = SecureRandom.hex(8).freeze
29
+ @transport_key = Digest::SHA256.hexdigest([source_agent.agent_id, target_agent.agent_id].join("\0"))[0, 32].freeze
30
30
  freeze
31
31
  end
32
32
 
@@ -1,13 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "weakref"
4
-
5
3
  module Phronomy
6
- module MultiAgent
4
+ module Agent
7
5
  class HandoffCapabilityFactory
8
- REGISTRY_MUTEX = Mutex.new
9
- private_constant :REGISTRY_MUTEX
10
-
11
6
  Binding = Data.define(:handoff, :tool_class, :tool_name) do
12
7
  def initialize(handoff:, tool_class:, tool_name:)
13
8
  super(handoff: handoff, tool_class: tool_class, tool_name: tool_name.to_s.freeze)
@@ -17,9 +12,7 @@ module Phronomy
17
12
 
18
13
  def self.build(handoff)
19
14
  key = handoff.send(:transport_key)
20
- source_name = class_slug(handoff.source_agent.class)
21
- target_name = class_slug(handoff.target_agent.class)
22
- tool_name = "phronomy_handoff_#{source_name}_to_#{target_name}_#{key}"
15
+ tool_name = "phronomy_handoff_#{key}"
23
16
  description = handoff.description
24
17
  policy = handoff.policy
25
18
 
@@ -45,43 +38,8 @@ module Phronomy
45
38
  end
46
39
  end
47
40
 
48
- binding = Binding.new(handoff: handoff, tool_class: klass, tool_name: tool_name)
49
- registry_mutex.synchronize { registry[tool_name] = WeakRef.new(binding) }
50
- binding
51
- end
52
-
53
- def self.lookup(tool_name)
54
- key = tool_name.to_s
55
- registry_mutex.synchronize do
56
- reference = registry[key]
57
- return nil unless reference
58
-
59
- begin
60
- reference.__getobj__
61
- rescue WeakRef::RefError
62
- registry.delete(key)
63
- nil
64
- end
65
- end
66
- end
67
-
68
- def self.registry
69
- @registry ||= {}
70
- end
71
- private_class_method :registry
72
-
73
- def self.registry_mutex
74
- REGISTRY_MUTEX
75
- end
76
- private_class_method :registry_mutex
77
-
78
- def self.class_slug(klass)
79
- raw = (klass.name || "agent").gsub("::", "_")
80
- raw.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
81
- .gsub(/[^a-zA-Z0-9_]/, "_")
82
- .downcase
41
+ Binding.new(handoff: handoff, tool_class: klass, tool_name: tool_name)
83
42
  end
84
- private_class_method :class_slug
85
43
  end
86
44
  end
87
45
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module MultiAgent
4
+ module Agent
5
5
  class HandoffContext
6
6
  Provenance = Data.define(
7
7
  :origin_agent_id,
@@ -79,6 +79,31 @@ module Phronomy
79
79
  end
80
80
  end
81
81
 
82
+ def to_h
83
+ {
84
+ "responsibility" => responsibility,
85
+ "items" => items.map do |item|
86
+ item.to_h.transform_keys(&:to_s).merge(
87
+ "candidate_category" => item.candidate_category.to_s,
88
+ "policy_category" => item.policy_category.to_s,
89
+ "role" => item.role&.to_s,
90
+ "content_format" => item.content_format.to_s,
91
+ "provenance" => item.provenance.to_h
92
+ )
93
+ end
94
+ }.freeze
95
+ end
96
+
97
+ def self.from_h(value)
98
+ source = value.transform_keys(&:to_s)
99
+ raise ArgumentError, "HandoffContext schema mismatch" unless source.keys.sort == %w[items responsibility]
100
+ new(responsibility: source.fetch("responsibility"), items: source.fetch("items").map do |raw|
101
+ values = raw.transform_keys(&:to_sym)
102
+ values[:provenance] = Provenance.new(**values.fetch(:provenance).transform_keys(&:to_sym))
103
+ Item.new(**values)
104
+ end)
105
+ end
106
+
82
107
  attr_reader :responsibility, :items
83
108
 
84
109
  def initialize(responsibility:, items: [])
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Phronomy
4
- module MultiAgent
6
+ module Agent
5
7
  # Handoff-aware specialization of the normal Agent execution coordinator.
6
8
  # It changes only the durable terminal semantics of an invocation that
7
9
  # produced a typed HandoffRequest. EventLoop ownership/apply remains entirely
8
10
  # in Agent::ExecutionCoordinator.
9
- class ExecutionCoordinator < Phronomy::Agent::ExecutionCoordinator
11
+ class HandoffExecutionCoordinator < Phronomy::Agent::ExecutionCoordinator
10
12
  private
11
13
 
12
14
  def compute_terminal(operation)
@@ -17,7 +19,9 @@ module Phronomy
17
19
  view.callback_failure.to_stream_callback_error
18
20
  )
19
21
  end
20
- return commit_failed_outcome(operation, view.source_error) if view.source_error
22
+ error = view.source_error || view.block_error || view.invocation_error
23
+ raise error if error.is_a?(Phronomy::ExecutionRehydrationRequiredError)
24
+ return commit_failed_outcome(operation, error) if error
21
25
  return commit_suspended(operation) if view.phase == :suspended
22
26
 
23
27
  raise view.block_error if view.input_blocked || view.output_blocked
@@ -27,7 +31,7 @@ module Phronomy
27
31
 
28
32
  commit_completed(operation)
29
33
  rescue => caught
30
- commit_failed_outcome(operation, caught)
34
+ reconcile_terminal_error(operation, caught)
31
35
  end
32
36
 
33
37
  def commit_handed_off(operation)
@@ -38,6 +42,27 @@ module Phronomy
38
42
  handed_off = next_root = appended = nil
39
43
 
40
44
  @agent.persistence.transaction do |tx|
45
+ coordination = current.metadata.fetch("coordination")
46
+ main_id = coordination.fetch("main_agent_id")
47
+ routing = tx.handoff_states.load(main_id)
48
+ unless routing && routing.active_agent_id == @agent.agent_id && routing.handoff_revision == coordination.fetch("handoff_revision")
49
+ raise Phronomy::Persistence::ConflictError, "Handoff routing changed before Source transfer"
50
+ end
51
+ if Array(routing.metadata["cancelled_execution_ids"]).include?(current.execution_id)
52
+ raise Phronomy::CancellationError, "Handoff Source turn was cancelled"
53
+ end
54
+ manifest = RecoverySupport.manifest_from_ref(@agent, current.metadata.fetch("manifest_ref"))
55
+ context = HandoffProjection.new.build_terminal(view: request, manifest: manifest,
56
+ persistence: tx, source_agent_id: @agent.agent_id)
57
+ context_ref = tx.contents.put_json(context.to_h)
58
+ target_id = "handoff-target-#{Digest::SHA256.hexdigest([current.execution_id, request.target_agent_id].join("\0"))}"
59
+ target_root = tx.agents.load(request.target_agent_id)
60
+ target_definition = {"id" => target_root.agent_definition_id, "version" => target_root.agent_definition_version}
61
+ transfer = routing.with(active_agent_id: request.target_agent_id,
62
+ active_handoff_context_ref: context_ref, phase: "target_pending",
63
+ pending_source_execution_id: current.execution_id, pending_target_execution_id: target_id,
64
+ metadata: routing.metadata.merge("target_definition" => target_definition))
65
+ tx.handoff_states.save(main_id, expected_revision: routing.handoff_revision, state: transfer)
41
66
  encoded_records, call_records = encode_runtime_records(
42
67
  current,
43
68
  tx: tx,
@@ -79,7 +104,9 @@ module Phronomy
79
104
  working_records: [],
80
105
  llm_calls: current.llm_calls + call_records,
81
106
  approval_request: nil,
82
- terminal_reason: "handed_off"
107
+ terminal_reason: "handed_off",
108
+ metadata: current.metadata.merge("handoff_target_agent_id" => request.target_agent_id,
109
+ "handoff_target_execution_id" => target_id, "handoff_context_ref" => context_ref)
83
110
  )
84
111
  tx.executions.save(
85
112
  current.execution_id,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module MultiAgent
4
+ module Agent
5
5
  class HandoffPolicy
6
6
  CATEGORIES = %i[current_request history knowledge tool_exchanges].freeze
7
7
  MODES = %i[required forbidden selectable].freeze
@@ -77,6 +77,12 @@ module Phronomy
77
77
  builder.build
78
78
  end
79
79
 
80
+ # Reconstructs a value-only operation snapshot, not Application code.
81
+ # @api private
82
+ def self.from_h(value)
83
+ new(value.map { |category, rule| Rule.new(category: category, **rule.transform_keys(&:to_sym)) })
84
+ end
85
+
80
86
  def self.default
81
87
  @default ||= define do
82
88
  required :current_request
@@ -1,13 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module MultiAgent
4
+ module Agent
5
5
  class HandoffProjection
6
6
  CONTROL_CATEGORIES = %i[instruction handoff_responsibility].freeze
7
7
  JSON_CATEGORIES = %i[assistant_message tool_message].freeze
8
8
 
9
+ Identity = Data.define(:agent_id)
10
+ Edge = Data.define(:policy, :target_agent)
11
+ Snapshot = Data.define(:handoff, :responsibility, :selection_intent)
12
+ private_constant :Identity, :Edge, :Snapshot
13
+
14
+ # A terminal command contains values only, captured on EventLoop.
15
+ def build_terminal(view:, manifest:, persistence:, source_agent_id:)
16
+ request = Snapshot.new(
17
+ handoff: Edge.new(policy: HandoffPolicy.from_h(view.policy), target_agent: Identity.new(agent_id: view.target_agent_id)),
18
+ responsibility: view.responsibility,
19
+ selection_intent: view.selection_intent.transform_keys(&:to_sym)
20
+ )
21
+ build(request: request, manifest: manifest, persistence: persistence,
22
+ source_agent: Identity.new(agent_id: source_agent_id))
23
+ end
24
+
9
25
  def build(request:, manifest:, persistence:, source_agent:)
10
- unless request.is_a?(HandoffRequest)
26
+ unless request.is_a?(HandoffRequest) || request.is_a?(Snapshot)
11
27
  raise ArgumentError, "request must be a HandoffRequest"
12
28
  end
13
29
  unless manifest.is_a?(Phronomy::Agent::LLMInputManifest)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module MultiAgent
4
+ module Agent
5
5
  # Typed private control request produced from one intercepted Handoff capability.
6
6
  # @api private
7
7
  HandoffRequest = Data.define(
@@ -11,7 +11,7 @@ module Phronomy
11
11
  responsibility = responsibility.to_s.strip
12
12
  raise ArgumentError, "Handoff responsibility must not be empty" if responsibility.empty?
13
13
  unless handoff.is_a?(Handoff)
14
- raise ArgumentError, "handoff must be a Phronomy::MultiAgent::Handoff"
14
+ raise ArgumentError, "handoff must be a Phronomy::Agent::Handoff"
15
15
  end
16
16
 
17
17
  normalized = handoff.policy.selectable_categories.to_h do |category|
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Durable Agent-domain responsibility routing within one Persistence instance.
8
+ # @api public
9
+ class HandoffRunner
10
+ MAX_HANDOFFS = 20
11
+
12
+ # @api public
13
+ attr_reader :main_agent, :handoffs
14
+
15
+ # @api public
16
+ def initialize(main_agent:, handoffs: [])
17
+ @main_agent, @handoffs = main_agent, Array(handoffs).freeze
18
+ @persistence = main_agent.persistence
19
+ raise ArgumentError, "handoffs must contain Agent::Handoff" unless @handoffs.all? { |h| h.is_a?(Handoff) }
20
+ @agents = ([main_agent] + @handoffs.flat_map { |edge| [edge.source_agent, edge.target_agent] }).uniq.to_h { |a| [a.agent_id, a] }.freeze
21
+ unless @agents.values.all? { |a| a.persistence.equal?(@persistence) }
22
+ raise Phronomy::ConfigurationError, "Durable Handoff graph requires one Persistence instance"
23
+ end
24
+ if @handoffs.group_by { |h| [h.source_agent.agent_id, h.target_agent.agent_id] }.any? { |_, edges| edges.size > 1 }
25
+ raise ArgumentError, "Duplicate Source/Target Handoff edges"
26
+ end
27
+ @bindings = @handoffs.group_by { |h| h.source_agent.agent_id }.transform_values { |edges| edges.map { |h| HandoffCapabilityFactory.build(h) }.freeze }.freeze
28
+ @runtime = Phronomy::Runtime.instance
29
+ end
30
+
31
+ # @api public
32
+ def invoke(input, config: {})
33
+ trace_handle = Phronomy::Tracing::Automatic.start("multi_agent.turn",
34
+ input: input, main_agent_id: main_agent.agent_id,
35
+ **main_agent.send(:_build_caller_meta, config))
36
+ operation_error = result = nil
37
+ if Phronomy::Runtime.in_event_loop_context?
38
+ raise Phronomy::EventLoopReentrancyError, "HandoffRunner#invoke cannot block EventLoop"
39
+ end
40
+ unless Phronomy::Runtime.instance.equal?(@runtime)
41
+ raise Phronomy::RuntimeShutdownError, "HandoffRunner belongs to a previous Runtime"
42
+ end
43
+ config = config.merge(cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new)
44
+ @runtime.__admit_multi_agent(main_agent)
45
+ admitted = true
46
+ state = load_state
47
+ count = 0
48
+ loop do
49
+ active = @agents.fetch(state.active_agent_id) do
50
+ raise Phronomy::ExecutionRehydrationRequiredError, "Handoff graph lacks active Agent #{state.active_agent_id}"
51
+ end
52
+ context = state.active_handoff_context_ref && HandoffContext.from_h(@persistence.contents.fetch_json(state.active_handoff_context_ref))
53
+ wiring = config.merge(phronomy_handoff_bindings: @bindings.fetch(active.agent_id, []),
54
+ phronomy_handoff_context: context,
55
+ phronomy_coordination: {"kind" => "handoff", "main_agent_id" => main_agent.agent_id, "handoff_revision" => state.handoff_revision}).freeze
56
+ active.instance_variable_set(:@_phronomy_coordination_config, wiring)
57
+ result = if state.phase == "stable"
58
+ unfinished = @persistence.executions.list_active(active.agent_id)
59
+ if unfinished.empty?
60
+ active.invoke(input, config: wiring)
61
+ else
62
+ exact = unfinished.fetch(0)
63
+ unless unfinished.size == 1 && exact.metadata.dig("coordination", "main_agent_id") == main_agent.agent_id
64
+ raise Phronomy::Persistence::ConflictError, "Active Agent execution belongs to another coordination turn"
65
+ end
66
+ wiring[:cancellation_token].cancel! if Array(state.metadata["cancelled_execution_ids"]).include?(exact.execution_id)
67
+ stored_input = @persistence.contents.fetch_text(exact.metadata.fetch("current_input_ref"))
68
+ ExactExecution.start(agent: active, execution_id: exact.execution_id, input: stored_input, config: wiring).wait_result
69
+ end
70
+ else
71
+ source = @persistence.executions.load(state.pending_source_execution_id)
72
+ unless @handoffs.any? { |edge| edge.source_agent.agent_id == source.agent_id && edge.target_agent.agent_id == state.active_agent_id }
73
+ raise Phronomy::ExecutionRehydrationRequiredError, "Handoff graph lacks committed Source/Target edge"
74
+ end
75
+ definition = state.metadata.fetch("target_definition")
76
+ unless active.class.agent_definition == definition.transform_keys(&:to_sym)
77
+ raise Phronomy::ConfigurationError, "Handoff Target definition mismatch"
78
+ end
79
+ wiring[:cancellation_token].cancel! if Array(state.metadata["cancelled_execution_ids"]).include?(state.pending_target_execution_id)
80
+ ExactExecution.start(agent: active, execution_id: state.pending_target_execution_id,
81
+ input: context.responsibility, config: wiring).wait_result
82
+ end
83
+ execution = @persistence.executions.load(result.fetch(:execution_id))
84
+ if execution.status == :handed_off
85
+ count += 1
86
+ raise Phronomy::HandoffError, "Exceeded maximum Handoffs in one turn" if count > MAX_HANDOFFS
87
+ state = @persistence.handoff_states.load(main_agent.agent_id)
88
+ next
89
+ end
90
+ if execution.active?
91
+ raise Phronomy::ExecutionRehydrationRequiredError, "Handoff execution requires approval or recovery"
92
+ end
93
+ raise RecoverySupport.error_from_failure(result[:error]) if result[:error]
94
+ return result.reject { |key, _| key.to_s.start_with?("_phronomy_") || key == :handoff_request }.merge(agent: active)
95
+ end
96
+ rescue Phronomy::CancellationError => error
97
+ operation_error = error
98
+ cancel(state.pending_source_execution_id) if state && state.phase != "stable"
99
+ raise
100
+ rescue => error
101
+ operation_error = error
102
+ raise
103
+ ensure
104
+ @runtime.__release_multi_agent(main_agent) if admitted
105
+ Phronomy::Tracing::Automatic.finish(trace_handle, output: result && result[:output], error: operation_error)
106
+ end
107
+
108
+ # Durably scopes cancellation to an exact turn, including a transferred
109
+ # Target reservation. A completed older turn never cancels a later turn.
110
+ # @api public
111
+ def cancel(execution_id)
112
+ if Phronomy::Runtime.in_event_loop_context?
113
+ raise Phronomy::EventLoopReentrancyError, "HandoffRunner#cancel cannot block EventLoop"
114
+ end
115
+ intended = leaf = leaf_id = nil
116
+ begin
117
+ @persistence.transaction do |tx|
118
+ first = tx.executions.load(execution_id)
119
+ unless first.metadata.dig("coordination", "main_agent_id") == main_agent.agent_id
120
+ raise Phronomy::Persistence::ConflictError, "Execution does not belong to this Handoff anchor"
121
+ end
122
+ leaf = first
123
+ leaf_id = first.execution_id
124
+ seen = {}
125
+ while leaf&.status == :handed_off
126
+ raise Phronomy::Persistence::SerializationError, "Cyclic Handoff chain" if seen[leaf_id]
127
+ seen[leaf_id] = true
128
+ leaf_id = leaf.metadata.fetch("handoff_target_execution_id")
129
+ begin
130
+ leaf = tx.executions.load(leaf_id)
131
+ rescue Phronomy::Persistence::NotFoundError
132
+ leaf = nil
133
+ end
134
+ end
135
+ next if leaf&.terminal?
136
+ routing = tx.handoff_states.load(main_agent.agent_id)
137
+ unless routing && (leaf ? routing.active_agent_id == leaf.agent_id : routing.pending_target_execution_id == leaf_id)
138
+ raise Phronomy::Persistence::ConflictError, "Handoff routing no longer owns the requested turn"
139
+ end
140
+ ids = (Array(routing.metadata["cancelled_execution_ids"]) + [leaf_id]).uniq
141
+ intended = routing.with(phase: leaf ? routing.phase : "stable",
142
+ metadata: routing.metadata.merge("cancelled_execution_ids" => ids))
143
+ tx.handoff_states.save(main_agent.agent_id, expected_revision: routing.handoff_revision, state: intended)
144
+ end
145
+ rescue => error
146
+ confirmed = @persistence.handoff_states.load(main_agent.agent_id)
147
+ raise error unless intended && confirmed && Array(confirmed.metadata["cancelled_execution_ids"]).include?(leaf_id)
148
+ end
149
+ return @persistence.execution_result(leaf_id) if leaf&.terminal?
150
+ ExecutionCancellation.signal(leaf_id, leaf.agent_id) if leaf&.active?
151
+ {execution_id: leaf_id, cancellation_requested: !leaf&.terminal?}.freeze
152
+ end
153
+
154
+ # Reads a specified source/Target chain without current graph continuation.
155
+ # @api public
156
+ def result(execution_id)
157
+ @persistence.handoff_result(execution_id, main_agent_id: main_agent.agent_id)
158
+ end
159
+
160
+ private
161
+
162
+ def load_state
163
+ state = @persistence.handoff_states.load(main_agent.agent_id)
164
+ return state if state
165
+ now = Time.now.utc.iso8601(6)
166
+ initial = HandoffState.new(main_agent_id: main_agent.agent_id, handoff_revision: 1,
167
+ active_agent_id: main_agent.agent_id, active_handoff_context_ref: nil,
168
+ phase: "stable", pending_source_execution_id: nil, pending_target_execution_id: nil,
169
+ created_at: now, updated_at: now, metadata: {})
170
+ @persistence.transaction { |tx| tx.handoff_states.save(main_agent.agent_id, expected_revision: nil, state: initial) }
171
+ rescue => error
172
+ confirmed = @persistence.handoff_states.load(main_agent.agent_id)
173
+ raise error unless confirmed
174
+ confirmed
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Immutable current-format semantic record; contains no Runtime handles.
8
+ # @api private
9
+ class HandoffState
10
+ ATTRIBUTES = %w[main_agent_id handoff_revision active_agent_id active_handoff_context_ref phase pending_source_execution_id pending_target_execution_id created_at updated_at metadata].freeze
11
+ attr_reader(*ATTRIBUTES)
12
+
13
+ def initialize(**values)
14
+ source = values.transform_keys(&:to_s)
15
+ raise ArgumentError, "HandoffState schema mismatch" unless source.keys.sort == ATTRIBUTES.sort
16
+ canonical = Phronomy::CanonicalJSON.load(Phronomy::CanonicalJSON.dump(source))
17
+ ATTRIBUTES.each { |key| instance_variable_set("@#{key}", Phronomy::Agent::Immutable.copy(canonical.fetch(key))) }
18
+ raise ArgumentError, "missing main_agent_id" if main_agent_id.to_s.empty?
19
+ raise ArgumentError, "invalid handoff_revision" unless handoff_revision.is_a?(Integer) && handoff_revision >= 0
20
+ raise ArgumentError, "invalid metadata" unless metadata.is_a?(Hash)
21
+ raise ArgumentError, "invalid Handoff phase" unless %w[stable target_pending target_active].include?(phase)
22
+ raise ArgumentError, "missing active Agent" if active_agent_id.to_s.empty?
23
+ if phase != "stable" && pending_target_execution_id.to_s.empty?
24
+ raise ArgumentError, "pending Handoff requires exact Target execution"
25
+ end
26
+ freeze
27
+ end
28
+
29
+ def to_h = ATTRIBUTES.to_h { |key| [key, public_send(key)] }.freeze
30
+
31
+ def self.from_h(value)
32
+ new(**value.transform_keys(&:to_sym))
33
+ end
34
+
35
+ def with(**changes)
36
+ values = to_h.merge(changes.transform_keys(&:to_s))
37
+ values["handoff_revision"] = handoff_revision + 1 unless changes.key?(:handoff_revision)
38
+ values["updated_at"] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
39
+ self.class.from_h(values)
40
+ end
41
+ end
42
+ end
43
+ end