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
@@ -19,7 +19,7 @@ module Phronomy
19
19
 
20
20
  # EventLoop -> Offload operation-specific immutable snapshots.
21
21
  InitialPreparationCommand = Data.define(
22
- :root, :journal_records, :input, :config
22
+ :root, :journal_records, :input, :config, :preparation_replayable
23
23
  )
24
24
  ProviderDispatchPreparationCommand = Data.define(
25
25
  :execution_id, :fsm_session_id, :expected_execution_revision,
@@ -43,12 +43,12 @@ module Phronomy
43
43
  )
44
44
  HandoffTerminalView = Data.define(
45
45
  :target_agent_id, :responsibility, :selection_intent,
46
- :llm_call_id, :tool_call_id
46
+ :llm_call_id, :tool_call_id, :policy
47
47
  )
48
48
  TerminalView = Data.define(
49
49
  :phase, :output, :usage, :approval_request, :rejected,
50
50
  :input_blocked, :output_blocked, :block_error,
51
- :invocation_error, :handoff, :callback_failure, :source_error
51
+ :invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested
52
52
  )
53
53
  TerminalCommitCommand = Data.define(
54
54
  :execution_id, :fsm_session_id, :expected_execution_revision,
@@ -83,6 +83,10 @@ module Phronomy
83
83
  InitialPreparationReady = Data.define(
84
84
  :coordinator, :request, :result, :error
85
85
  )
86
+ InitialPreparationRecoveryReady = Data.define(
87
+ :coordinator, :execution_id, :expected_execution_revision,
88
+ :result_task, :load_completion, :result, :error
89
+ )
86
90
  ProviderDispatchPreparationReady = Data.define(
87
91
  :coordinator, :operation, :result, :error
88
92
  )
@@ -135,7 +139,7 @@ module Phronomy
135
139
  command = StartCommand.new(
136
140
  coordinator: self,
137
141
  input: input,
138
- config: config.dup.freeze,
142
+ config: @agent.__invocation_config(config).dup.freeze,
139
143
  mode: mode.to_sym,
140
144
  approval_policy: approval_policy,
141
145
  approval_listener: approval_listener,
@@ -361,6 +365,8 @@ module Phronomy
361
365
  begin_start_on_event_loop(command)
362
366
  when InitialPreparationReady
363
367
  apply_initial_preparation_on_event_loop(command)
368
+ when InitialPreparationRecoveryReady
369
+ apply_initial_preparation_recovery_on_event_loop(command)
364
370
  when ProviderDispatchPreparationReady
365
371
  apply_provider_dispatch_preparation_on_event_loop(command)
366
372
  when ToolDispatchPreparationReady
@@ -419,7 +425,12 @@ module Phronomy
419
425
  root: root,
420
426
  journal_records: @agent.send(:_journal_records_snapshot),
421
427
  input: request.input,
422
- config: request.config
428
+ config: request.config,
429
+ preparation_replayable: initial_preparation_replayable?(
430
+ request.input,
431
+ request.config,
432
+ request.approval_policy
433
+ )
423
434
  )
424
435
  task = runtime.offload.submit(on_full: :raise) do
425
436
  perform_initial_preparation(operation)
@@ -472,7 +483,9 @@ module Phronomy
472
483
  execution, active_root = admit_execution(
473
484
  raw_message,
474
485
  root: operation.root,
475
- mode: (operation.config[:phronomy_recovery_mode] || :invoke).to_sym
486
+ mode: (operation.config[:phronomy_recovery_mode] || :invoke).to_sym,
487
+ config: operation.config,
488
+ preparation_replayable: operation.preparation_replayable
476
489
  )
477
490
  rescue Phronomy::AgentBusyError => error
478
491
  # A durable busy conflict proves that another nonterminal logical
@@ -517,15 +530,31 @@ module Phronomy
517
530
  )
518
531
  end
519
532
 
533
+ perform_admitted_initial_preparation(
534
+ input: operation.input,
535
+ config: operation.config,
536
+ execution: execution,
537
+ active_root: active_root,
538
+ journal_records: operation.journal_records
539
+ )
540
+ end
541
+
542
+ def perform_admitted_initial_preparation(
543
+ input:,
544
+ config:,
545
+ execution:,
546
+ active_root:,
547
+ journal_records:
548
+ )
520
549
  current_execution = execution
521
550
  begin
522
- effective_config = operation.config
551
+ effective_config = config
523
552
  @agent.send(
524
553
  :check_cancellation!,
525
554
  effective_config,
526
555
  "invocation cancelled before input filtering"
527
556
  )
528
- filtered_input = @agent.send(:run_input_filters!, operation.input)
557
+ filtered_input = @agent.send(:run_input_filters!, input)
529
558
  @agent.send(
530
559
  :check_cancellation!,
531
560
  effective_config,
@@ -559,7 +588,7 @@ module Phronomy
559
588
  assembler = ContextAssembler.new(
560
589
  agent: @agent,
561
590
  persistence: @agent.persistence,
562
- journal_records: operation.journal_records
591
+ journal_records: journal_records
563
592
  )
564
593
  prepared = assembler.prepare_initial(
565
594
  input: filtered_input,
@@ -625,7 +654,7 @@ module Phronomy
625
654
  root: failure.fetch(:root),
626
655
  runtime_projection: nil,
627
656
  filtered_input: nil,
628
- config: operation.config,
657
+ config: config,
629
658
  appended_records: failure.fetch(:appended_records),
630
659
  error: failure.fetch(:error),
631
660
  admission_outcome: :terminal
@@ -633,7 +662,13 @@ module Phronomy
633
662
  end
634
663
  end
635
664
 
636
- def admit_execution(raw_message, root:, mode: :invoke)
665
+ def admit_execution(
666
+ raw_message,
667
+ root:,
668
+ mode: :invoke,
669
+ config: {},
670
+ preparation_replayable: false
671
+ )
637
672
  if root.lifecycle_status == :closed
638
673
  raise Phronomy::Error,
639
674
  "agent is closed: #{@agent.agent_id}"
@@ -646,6 +681,9 @@ module Phronomy
646
681
 
647
682
  @agent.persistence.transaction do |tx|
648
683
  input_ref = tx.contents.put_text(raw_message)
684
+ durable_context_ref = if config.key?(:durable_context)
685
+ tx.contents.put_json(config.fetch(:durable_context))
686
+ end
649
687
  input_record = JournalRecord.new(
650
688
  agent_id: @agent.agent_id,
651
689
  kind: :input_received,
@@ -658,8 +696,12 @@ module Phronomy
658
696
  execution = AgentExecution.start(
659
697
  agent_root: root,
660
698
  input_record: input_record,
699
+ execution_id: config[:phronomy_reserved_execution_id] || SecureRandom.uuid,
661
700
  metadata: {
701
+ "coordination" => config[:phronomy_coordination],
662
702
  "current_input_ref" => input_ref,
703
+ "durable_context_ref" => durable_context_ref,
704
+ "preparation_replayable" => !!preparation_replayable,
663
705
  RecoverySupport::CONTRACT_VERSION_KEY =>
664
706
  RecoverySupport::CONTRACT_VERSION,
665
707
  RecoverySupport::INVOCATION_MODE_KEY => mode.to_s,
@@ -678,6 +720,7 @@ module Phronomy
678
720
  execution_revision: 0,
679
721
  working_records: [input_record]
680
722
  )
723
+ validate_coordination_admission!(tx, execution)
681
724
  tx.executions.create_active(execution)
682
725
  next_root = root.with(
683
726
  agent_revision: root.agent_revision + 1,
@@ -692,6 +735,68 @@ module Phronomy
692
735
  [execution, next_root]
693
736
  end
694
737
 
738
+ def validate_coordination_admission!(tx, execution)
739
+ owner = execution.metadata["coordination"]
740
+ return unless owner
741
+ case owner.fetch("kind")
742
+ when "team"
743
+ team = tx.team_executions.load(owner.fetch("team_execution_id"))
744
+ unless team.team_id == owner.fetch("team_id") && team.active? && !team.metadata["cancel_requested"]
745
+ raise Phronomy::CancellationError, "Team run is no longer dispatchable"
746
+ end
747
+ slot = if owner.fetch("slot") == "coordinator"
748
+ team.coordinator
749
+ else
750
+ assignment = team.assignments.find { |entry| entry.fetch("task_id") == owner.fetch("slot") }
751
+ worker = assignment && team.workers.fetch(assignment.fetch("worker"))
752
+ worker&.merge("execution_id" => assignment.fetch("execution_id"))
753
+ end
754
+ unless slot && slot.fetch("execution_id") == execution.execution_id && slot.fetch("agent_id") == execution.agent_id
755
+ raise Phronomy::Persistence::ConflictError, "Team reserved child identity mismatch"
756
+ end
757
+ when "subagent"
758
+ parent = tx.executions.load(owner.fetch("parent_execution_id"))
759
+ unless parent.agent_id == owner.fetch("parent_agent_id") && parent.active? && !parent.metadata["coordination_cancel_requested"]
760
+ raise Phronomy::CancellationError, "Parent run is no longer dispatchable"
761
+ end
762
+ snapshot = tx.contents.fetch_json(parent.metadata.fetch("multi_agent_coordination_ref"))
763
+ slot = snapshot.fetch("children").find { |entry| entry.fetch("slot") == owner.fetch("slot") }
764
+ unless slot && slot.fetch("agent_id") == execution.agent_id && slot.fetch("execution_id") == execution.execution_id
765
+ raise Phronomy::Persistence::ConflictError, "Parent reserved child identity mismatch"
766
+ end
767
+ when "handoff"
768
+ routing = tx.handoff_states.load(owner.fetch("main_agent_id"))
769
+ unless routing && routing.active_agent_id == execution.agent_id
770
+ raise Phronomy::Persistence::ConflictError, "Handoff responsibility changed before admission"
771
+ end
772
+ if Array(routing.metadata["cancelled_execution_ids"]).include?(execution.execution_id)
773
+ raise Phronomy::CancellationError, "Handoff reservation was cancelled"
774
+ end
775
+ if routing.phase != "stable" && routing.pending_target_execution_id != execution.execution_id
776
+ raise Phronomy::Persistence::ConflictError, "Handoff reserved Target identity mismatch"
777
+ end
778
+ else
779
+ raise Phronomy::ConfigurationError, "Unknown coordination owner kind"
780
+ end
781
+ end
782
+
783
+ def initial_preparation_replayable?(input, config, approval_policy)
784
+ return false unless input.is_a?(String)
785
+ return false unless approval_policy.nil?
786
+ if config.key?(:phronomy_handoff_bindings) ||
787
+ config.key?(:phronomy_handoff_context)
788
+ return false unless config[:phronomy_coordination]&.fetch("kind", nil) == "handoff"
789
+ end
790
+
791
+ invocation_context = config[:invocation_context]
792
+ return true unless invocation_context
793
+
794
+ %i[approval_policy redaction_policy token_budget].none? do |name|
795
+ invocation_context.respond_to?(name) &&
796
+ !invocation_context.public_send(name).nil?
797
+ end
798
+ end
799
+
695
800
  def commit_preparation_failure(execution, root, error)
696
801
  translated_error = translated(error)
697
802
  failed = next_root = appended = nil
@@ -898,6 +1003,243 @@ module Phronomy
898
1003
  )
899
1004
  end
900
1005
 
1006
+ # Restarts only the replay-safe initial preparation region of an already
1007
+ # durably admitted logical Execution. Runtime-only caller state is not
1008
+ # reconstructed here; the durable preparation inputs are authoritative.
1009
+ # @api private
1010
+ def start_initial_preparation_recovery_on_event_loop(
1011
+ execution,
1012
+ result_task,
1013
+ load_completion:
1014
+ )
1015
+ runtime = Phronomy::Runtime.instance
1016
+ event_loop = runtime.event_loop
1017
+ assert_event_loop!(event_loop)
1018
+ state = event_loop.agent_execution_state(execution.execution_id)
1019
+ unless state && state.agent.equal?(@agent) &&
1020
+ state.execution.execution_revision == execution.execution_revision &&
1021
+ state.execution.status == :preparing &&
1022
+ state.execution.phase.to_sym == :preparing
1023
+ error = Phronomy::ExecutionRehydrationRequiredError.new(
1024
+ "stale :preparing Recovery installation for #{execution.execution_id}"
1025
+ )
1026
+ fail_task(result_task, error)
1027
+ load_completion.fail(error)
1028
+ return
1029
+ end
1030
+
1031
+ root = @agent.agent_root
1032
+ journal_records = @agent.send(:_journal_records_snapshot)
1033
+ task = runtime.offload.submit(on_full: :raise) do
1034
+ perform_initial_preparation_recovery(
1035
+ execution,
1036
+ root: root,
1037
+ journal_records: journal_records
1038
+ )
1039
+ end
1040
+ task.on_complete do |result, error|
1041
+ ready = InitialPreparationRecoveryReady.new(
1042
+ coordinator: self,
1043
+ execution_id: execution.execution_id.to_s.freeze,
1044
+ expected_execution_revision: execution.execution_revision,
1045
+ result_task: result_task,
1046
+ load_completion: load_completion,
1047
+ result: result,
1048
+ error: error
1049
+ )
1050
+ unless post_control(runtime, ready)
1051
+ rejected = runtime_rejected_error(:initial_preparation_recovery)
1052
+ fail_task(result_task, rejected)
1053
+ load_completion.fail(rejected)
1054
+ end
1055
+ end
1056
+ rescue => error
1057
+ # simplecov:disable
1058
+ begin
1059
+ if defined?(event_loop) && event_loop.current?
1060
+ release_initial_preparation_recovery_runtime_state(
1061
+ event_loop,
1062
+ execution.execution_id
1063
+ )
1064
+ end
1065
+ rescue
1066
+ nil
1067
+ end
1068
+ translated_error = translated(error)
1069
+ fail_task(result_task, translated_error)
1070
+ load_completion.fail(translated_error)
1071
+ # simplecov:enable
1072
+ end
1073
+
1074
+ def perform_initial_preparation_recovery(
1075
+ execution,
1076
+ root:,
1077
+ journal_records:
1078
+ )
1079
+ unless execution.metadata["preparation_replayable"] == true
1080
+ raise Phronomy::ExecutionRehydrationRequiredError,
1081
+ "execution #{execution.execution_id} has no replay-safe initial preparation contract"
1082
+ end
1083
+
1084
+ input_ref = execution.metadata.fetch("current_input_ref")
1085
+ input = @agent.persistence.contents.fetch_text(input_ref)
1086
+ mode = (
1087
+ execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] || "invoke"
1088
+ ).to_sym
1089
+ config = {phronomy_recovery_mode: mode}.merge(@agent.__coordination_config)
1090
+
1091
+ if execution.metadata.key?("durable_context_ref")
1092
+ durable_context_ref = execution.metadata.fetch("durable_context_ref")
1093
+ durable_context = @agent.persistence.contents.fetch_json(
1094
+ durable_context_ref
1095
+ )
1096
+ unless durable_context.is_a?(Hash)
1097
+ raise Phronomy::ExecutionRehydrationRequiredError,
1098
+ "execution #{execution.execution_id} has a non-Hash durable_context"
1099
+ end
1100
+ Phronomy::Agent::Immutable.validate_canonical_json!(
1101
+ durable_context,
1102
+ label: "Recovered durable_context"
1103
+ )
1104
+ config[:durable_context] =
1105
+ Phronomy::Agent::Immutable.copy(durable_context)
1106
+ end
1107
+
1108
+ perform_admitted_initial_preparation(
1109
+ input: input,
1110
+ config: config.freeze,
1111
+ execution: execution,
1112
+ active_root: root,
1113
+ journal_records: journal_records
1114
+ )
1115
+ end
1116
+
1117
+ def apply_initial_preparation_recovery_on_event_loop(ready)
1118
+ event_loop = Phronomy::Runtime.instance.event_loop
1119
+ state = event_loop.agent_execution_state(ready.execution_id)
1120
+ unless state && state.agent.equal?(@agent) &&
1121
+ state.execution.execution_revision == ready.expected_execution_revision &&
1122
+ state.execution.status == :preparing &&
1123
+ state.execution.phase.to_sym == :preparing
1124
+ error = Phronomy::ExecutionRehydrationRequiredError.new(
1125
+ "stale initial preparation Recovery result for #{ready.execution_id}"
1126
+ )
1127
+ fail_task(ready.result_task, error)
1128
+ ready.load_completion.fail(error)
1129
+ return
1130
+ end
1131
+
1132
+ if ready.error
1133
+ release_initial_preparation_recovery_runtime_state(
1134
+ event_loop,
1135
+ ready.execution_id
1136
+ )
1137
+ error = translated(ready.error)
1138
+ fail_task(ready.result_task, error)
1139
+ ready.load_completion.fail(error)
1140
+ return
1141
+ end
1142
+
1143
+ result = ready.result
1144
+ case result.admission_outcome
1145
+ when :terminal
1146
+ apply_agent_live_state(
1147
+ root: result.root,
1148
+ appended_records: result.appended_records
1149
+ )
1150
+ event_loop.replace_agent_execution(
1151
+ ready.execution_id,
1152
+ execution: result.execution
1153
+ )
1154
+ event_loop.release_agent_execution(ready.execution_id)
1155
+ event_loop.release_agent_execution_admission(
1156
+ @agent.agent_id,
1157
+ execution_id: ready.execution_id
1158
+ )
1159
+ event_type = terminal_event_type(result.error)
1160
+ callback_error = deliver_terminal(
1161
+ @agent.send(:_phronomy_event_listener),
1162
+ event_type,
1163
+ error: result.error
1164
+ )
1165
+ settle_after_terminal(
1166
+ ready.result_task,
1167
+ callback_error,
1168
+ event_type,
1169
+ nil,
1170
+ result.error
1171
+ )
1172
+ ready.load_completion.complete(@agent)
1173
+ when :active
1174
+ apply_agent_live_state(
1175
+ root: result.root,
1176
+ appended_records: result.appended_records
1177
+ )
1178
+ event_loop.release_agent_execution(ready.execution_id)
1179
+ mode = (
1180
+ result.execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] ||
1181
+ "invoke"
1182
+ ).to_sym
1183
+ request = StartCommand.new(
1184
+ coordinator: self,
1185
+ input: result.filtered_input,
1186
+ config: result.config,
1187
+ mode: mode,
1188
+ approval_policy: nil,
1189
+ approval_listener: nil,
1190
+ on_event: @agent.send(:_phronomy_event_listener),
1191
+ result_task: ready.result_task,
1192
+ admission_token: Object.new.freeze
1193
+ )
1194
+ begin
1195
+ register_initial_session_on_event_loop(request, result)
1196
+ rescue => error
1197
+ begin_terminal_without_session_on_event_loop(
1198
+ request: request,
1199
+ prepared: result,
1200
+ error: error
1201
+ )
1202
+ end
1203
+ ready.load_completion.complete(@agent)
1204
+ else
1205
+ error = Phronomy::ExecutionRehydrationRequiredError.new(
1206
+ "unexpected initial preparation Recovery outcome: #{result.admission_outcome.inspect}"
1207
+ )
1208
+ release_initial_preparation_recovery_runtime_state(
1209
+ event_loop,
1210
+ ready.execution_id
1211
+ )
1212
+ fail_task(ready.result_task, error)
1213
+ ready.load_completion.fail(error)
1214
+ end
1215
+ rescue => error
1216
+ # simplecov:disable
1217
+ begin
1218
+ release_initial_preparation_recovery_runtime_state(
1219
+ event_loop,
1220
+ ready.execution_id
1221
+ )
1222
+ rescue
1223
+ nil
1224
+ end
1225
+ translated_error = translated(error)
1226
+ fail_task(ready.result_task, translated_error)
1227
+ ready.load_completion.fail(translated_error)
1228
+ # simplecov:enable
1229
+ end
1230
+
1231
+ def release_initial_preparation_recovery_runtime_state(
1232
+ event_loop,
1233
+ execution_id
1234
+ )
1235
+ state = event_loop.agent_execution_state(execution_id)
1236
+ event_loop.release_agent_execution(execution_id) if state
1237
+ event_loop.release_agent_execution_admission(
1238
+ @agent.agent_id,
1239
+ execution_id: execution_id
1240
+ )
1241
+ end
1242
+
901
1243
  # ----------------------------------------------------------------------
902
1244
  # External-operation causal durable barriers
903
1245
  # ----------------------------------------------------------------------
@@ -973,6 +1315,7 @@ module Phronomy
973
1315
  "manifest_refs" => refs
974
1316
  )
975
1317
  )
1318
+ updated = @agent.__prepare_coordination_record(updated, tx: tx)
976
1319
  tx.executions.save(
977
1320
  operation.execution.execution_id,
978
1321
  expected_revision: operation.execution.execution_revision,
@@ -1022,6 +1365,7 @@ module Phronomy
1022
1365
  metadata.delete(RecoverySupport::PENDING_LLM_ID_KEY)
1023
1366
  metadata.delete(RecoverySupport::PENDING_LLM_STARTED_AT_KEY)
1024
1367
  metadata.delete(RecoverySupport::RECOVERY_METADATA_KEY)
1368
+ metadata.delete("framework_calls_pending")
1025
1369
  metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY] =
1026
1370
  RecoverySupport.canonical_copy(operation.tool_batch_snapshot)
1027
1371
  metadata[RecoverySupport::CONTRACT_VERSION_KEY] =
@@ -1032,6 +1376,7 @@ module Phronomy
1032
1376
  llm_calls: operation.execution.llm_calls + call_records,
1033
1377
  metadata: metadata
1034
1378
  )
1379
+ updated = @agent.__prepare_coordination_record(updated, tx: tx)
1035
1380
  tx.executions.save(
1036
1381
  operation.execution.execution_id,
1037
1382
  expected_revision: operation.execution.execution_revision,
@@ -1615,6 +1960,27 @@ module Phronomy
1615
1960
  end
1616
1961
  end
1617
1962
 
1963
+ def start_framework_tools_on_event_loop(execution_id, result_task)
1964
+ runtime = Phronomy::Runtime.instance
1965
+ event_loop = runtime.event_loop
1966
+ state = event_loop.agent_execution_state(execution_id)
1967
+ invocation = state.invocation
1968
+ session = AgentInvocationSessionBuilder.build_for_resume(agent_invocation: invocation,
1969
+ resume_event: :resume, resume_phase: :suspended, runtime: runtime)
1970
+ event_loop.replace_agent_execution(execution_id, invocation: invocation, fsm_session_id: session.id)
1971
+ event_loop.mark_agent_execution_admission(@agent.agent_id, execution_id: execution_id, state: :executing)
1972
+ source = Phronomy::Task.deferred(name: "framework-tool-recovery-source")
1973
+ source.on_complete do |result, error|
1974
+ finish_on_event_loop(execution_id, result_task, result || session.context, error, fsm_session_id: session.id)
1975
+ end
1976
+ event_loop.register(session, completion: source)
1977
+ invocation.tool_invocations.select(&:authorized?).each do |child|
1978
+ child_session = ToolInvocationSessionBuilder.build_for_resume(tool_invocation: child,
1979
+ parent_event_sink: session.event_sink, resume_event: :dispatch, resume_phase: :authorized, runtime: runtime)
1980
+ register_child(event_loop, child, child_session, session.event_sink)
1981
+ end
1982
+ end
1983
+
1618
1984
  def register_child(event_loop, child, session, parent_event_sink)
1619
1985
  completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
1620
1986
  completion.on_complete do |_result, error|
@@ -1849,7 +2215,8 @@ module Phronomy
1849
2215
  invocation_error: invocation&.error,
1850
2216
  handoff: handoff_terminal_view(invocation&.handoff_request),
1851
2217
  callback_failure: invocation&.callback_failure,
1852
- source_error: source_error
2218
+ source_error: source_error,
2219
+ cancel_requested: invocation&.config&.fetch(:cancellation_token, nil)&.cancelled? || false
1853
2220
  )
1854
2221
  end
1855
2222
 
@@ -1863,7 +2230,8 @@ module Phronomy
1863
2230
  [category.to_s.freeze, !!included]
1864
2231
  end.freeze,
1865
2232
  llm_call_id: request.llm_call_id&.to_s&.freeze,
1866
- tool_call_id: request.tool_call_id&.to_s&.freeze
2233
+ tool_call_id: request.tool_call_id&.to_s&.freeze,
2234
+ policy: Immutable.copy(request.handoff.policy.to_h)
1867
2235
  )
1868
2236
  end
1869
2237
 
@@ -1873,22 +2241,77 @@ module Phronomy
1873
2241
 
1874
2242
  def compute_terminal(operation)
1875
2243
  view = operation.terminal_view
1876
- if view.callback_failure
1877
- return commit_failed_outcome(
1878
- operation,
1879
- view.callback_failure.to_stream_callback_error
1880
- )
1881
- end
1882
- return commit_failed_outcome(operation, view.source_error) if view.source_error
1883
- if view.phase == :suspended
1884
- return commit_suspended(operation)
1885
- end
1886
- raise view.block_error if view.input_blocked || view.output_blocked
1887
- raise view.invocation_error if view.invocation_error
1888
-
2244
+ error = view.callback_failure&.to_stream_callback_error || view.source_error ||
2245
+ view.block_error || view.invocation_error
2246
+ if operation.execution.metadata["multi_agent_coordination_ref"] &&
2247
+ (error.is_a?(Phronomy::ExecutionRehydrationRequiredError) || view.cancel_requested)
2248
+ waiting = commit_coordination_wait(operation, error)
2249
+ return waiting if waiting
2250
+ end
2251
+ raise error if error.is_a?(Phronomy::ExecutionRehydrationRequiredError)
2252
+ return commit_failed_outcome(operation, error) if error
2253
+ return commit_suspended(operation) if view.phase == :suspended
1889
2254
  commit_completed(operation)
1890
2255
  rescue => caught
1891
- commit_failed_outcome(operation, caught)
2256
+ reconcile_terminal_error(operation, caught)
2257
+ end
2258
+
2259
+ # Reuse the Agent barrier to retain unfinished owned children. This is an
2260
+ # active AgentExecution snapshot, not a second scheduler or terminal state.
2261
+ def commit_coordination_wait(operation, error)
2262
+ current = operation.execution
2263
+ waiting = nil
2264
+ @agent.persistence.transaction do |tx|
2265
+ refreshed = @agent.__prepare_coordination_record(current, tx: tx)
2266
+ snapshot = tx.contents.fetch_json(refreshed.metadata.fetch("multi_agent_coordination_ref"))
2267
+ unresolved = snapshot.fetch("children").any? do |child|
2268
+ !%w[completed failed cancelled rejected blocked handed_off].include?(child.fetch("state")) &&
2269
+ !(operation.terminal_view.cancel_requested && child.fetch("state") == "reserved")
2270
+ end
2271
+ next unless unresolved
2272
+ waiting = current.with(metadata: refreshed.metadata.merge(
2273
+ "coordination_cancel_requested" => operation.terminal_view.cancel_requested || current.metadata["coordination_cancel_requested"] == true
2274
+ ))
2275
+ tx.executions.save(current.execution_id, expected_revision: current.execution_revision, execution: waiting)
2276
+ end
2277
+ return unless waiting
2278
+ coordination_wait_outcome(operation, waiting, error)
2279
+ rescue => failure
2280
+ confirmed = @agent.persistence.executions.load(current.execution_id)
2281
+ raise failure unless waiting && confirmed.to_h == waiting.to_h
2282
+ coordination_wait_outcome(operation, confirmed, error)
2283
+ end
2284
+
2285
+ def coordination_wait_outcome(operation, execution, error)
2286
+ TerminalOutcome.new(type: :coordination_wait, execution: execution, root: operation.root,
2287
+ appended_records: [].freeze, result: nil,
2288
+ error: error.is_a?(Phronomy::ExecutionRehydrationRequiredError) ? error :
2289
+ Phronomy::ExecutionRehydrationRequiredError.new("Execution #{execution.execution_id} retains unfinished children for cancellation/recovery"),
2290
+ approval_request: nil)
2291
+ end
2292
+
2293
+ # F1: terminal atomicity alone does not establish commit outcome certainty.
2294
+ # Reuse an exact committed outcome; an absent/active/read-failed result is
2295
+ # never permission to write a second terminal transition.
2296
+ def reconcile_terminal_error(operation, error)
2297
+ confirmed = @agent.persistence.executions.load(operation.execution_id)
2298
+ raise error unless confirmed.terminal? &&
2299
+ confirmed.agent_id == @agent.agent_id &&
2300
+ confirmed.execution_revision == operation.expected_execution_revision + 1
2301
+ root = @agent.persistence.agents.load(@agent.agent_id)
2302
+ records = @agent.persistence.journals.read(@agent.agent_id,
2303
+ after: operation.root.journal_position, limit: root.journal_position - operation.root.journal_position)
2304
+ result = @agent.persistence.execution_result(confirmed.execution_id)
2305
+ failure = result[:error] && RecoverySupport.error_from_failure(result[:error])
2306
+ type = if failure
2307
+ :failed
2308
+ else
2309
+ ((confirmed.status == :handed_off) ? :handed_off : :completed)
2310
+ end
2311
+ TerminalOutcome.new(type: type, execution: confirmed, root: root,
2312
+ appended_records: records.freeze,
2313
+ result: failure ? nil : result_base(confirmed, root).merge(output: result[:result]).freeze,
2314
+ error: failure, approval_request: nil)
1892
2315
  end
1893
2316
 
1894
2317
  def commit_suspended(operation)
@@ -1953,6 +2376,18 @@ module Phronomy
1953
2376
  )
1954
2377
  end
1955
2378
 
2379
+ def synchronize_handoff_target(tx, execution)
2380
+ coordination = execution.metadata["coordination"]
2381
+ return unless coordination && coordination["kind"] == "handoff"
2382
+ routing = tx.handoff_states.load(coordination.fetch("main_agent_id"))
2383
+ return unless routing && routing.pending_target_execution_id == execution.execution_id && routing.phase != "stable"
2384
+ unless routing.active_agent_id == execution.agent_id
2385
+ raise Phronomy::Persistence::ConflictError, "Handoff Target owner mismatch"
2386
+ end
2387
+ tx.handoff_states.save(routing.main_agent_id, expected_revision: routing.handoff_revision,
2388
+ state: routing.with(phase: "stable"))
2389
+ end
2390
+
1956
2391
  def commit_completed(operation)
1957
2392
  current = operation.execution
1958
2393
  root = operation.root
@@ -2004,6 +2439,8 @@ module Phronomy
2004
2439
  result_ref: output_ref,
2005
2440
  terminal_reason: view.rejected ? "rejected" : "completed"
2006
2441
  )
2442
+ completed = @agent.__prepare_coordination_record(completed, tx: tx)
2443
+ synchronize_handoff_target(tx, completed)
2007
2444
  tx.executions.save(
2008
2445
  current.execution_id,
2009
2446
  expected_revision: current.execution_revision,
@@ -2097,6 +2534,8 @@ module Phronomy
2097
2534
  error_ref: error_ref,
2098
2535
  terminal_reason: translated_error.class.name
2099
2536
  )
2537
+ failed = @agent.__prepare_coordination_record(failed, tx: tx)
2538
+ synchronize_handoff_target(tx, failed)
2100
2539
  tx.executions.save(
2101
2540
  current.execution_id,
2102
2541
  expected_revision: current.execution_revision,
@@ -2142,6 +2581,14 @@ module Phronomy
2142
2581
  end
2143
2582
 
2144
2583
  if ready.error
2584
+ if operation.execution.metadata["coordination"] || operation.execution.metadata["multi_agent_coordination_ref"]
2585
+ event_loop.release_agent_execution(operation.execution_id) if state
2586
+ event_loop.release_agent_execution_admission(@agent.agent_id, execution_id: operation.execution_id)
2587
+ event_loop.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each do |task|
2588
+ task.fail(ready.error)
2589
+ end
2590
+ return
2591
+ end
2145
2592
  event_loop.mark_agent_execution_admission(
2146
2593
  @agent.agent_id,
2147
2594
  execution_id: operation.execution_id,
@@ -2169,6 +2616,10 @@ module Phronomy
2169
2616
  end
2170
2617
 
2171
2618
  case outcome.type
2619
+ when :coordination_wait
2620
+ event_loop.release_agent_execution(operation.execution_id) if state
2621
+ event_loop.release_agent_execution_admission(@agent.agent_id, execution_id: operation.execution_id)
2622
+ event_loop.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| task.fail(outcome.error) }
2172
2623
  when :suspended
2173
2624
  event_loop.mark_agent_execution_admission(
2174
2625
  @agent.agent_id,
@@ -2186,6 +2637,9 @@ module Phronomy
2186
2637
  :approval_required,
2187
2638
  outcome.result
2188
2639
  )
2640
+ Array(state&.invocation&.config&.fetch(:phronomy_exact_observers, [])).each do |task|
2641
+ task.fail(Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
2642
+ end
2189
2643
  when :completed
2190
2644
  event_loop.release_agent_execution(operation.execution_id) if state
2191
2645
  event_loop.release_agent_execution_admission(
@@ -2350,6 +2804,9 @@ module Phronomy
2350
2804
  llm_call_id = payload[:llm_call_id] || payload["llm_call_id"]
2351
2805
  tool_call_id = payload.fetch(:tool_call_id).to_s
2352
2806
  tool_name = payload.fetch(:tool_name).to_s
2807
+ next if execution.working_records.any? do |record|
2808
+ record.kind == :tool_message && record.causation_id.to_s == tool_call_id && record.llm_call_id.to_s == llm_call_id.to_s
2809
+ end
2353
2810
  result_ref = put_runtime_content(tx, payload.fetch(:tool_result))
2354
2811
  records << JournalRecord.new(
2355
2812
  agent_id: @agent.agent_id,