phronomy 0.22.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ RSpec.shared_context "coordination repository values" do
7
+ let(:coordination_team) do
8
+ now = Time.now.utc.iso8601(6)
9
+ Phronomy::MultiAgent::TeamRoot.new(team_id: SecureRandom.uuid,
10
+ team_definition_id: "contract-team", team_definition_version: 1,
11
+ team_revision: 0, lifecycle_status: "idle", created_at: now, updated_at: now, metadata: {})
12
+ end
13
+ let(:coordination_run) do
14
+ now = Time.now.utc.iso8601(6)
15
+ Phronomy::MultiAgent::TeamExecution.new(team_execution_id: SecureRandom.uuid,
16
+ team_id: coordination_team.team_id, execution_revision: 0, status: "active", phase: "coordinator",
17
+ input_ref: nil, coordinator: {}, tasks: [], workers: [], assignments: [], result_ref: nil,
18
+ error_ref: nil, created_at: now, updated_at: now, metadata: {})
19
+ end
20
+ let(:coordination_handoff) do
21
+ now = Time.now.utc.iso8601(6)
22
+ Phronomy::Agent::HandoffState.new(main_agent_id: SecureRandom.uuid, handoff_revision: 1,
23
+ active_agent_id: "contract-target", active_handoff_context_ref: nil, phase: "target_pending",
24
+ pending_source_execution_id: "source-execution", pending_target_execution_id: "target-execution",
25
+ created_at: now, updated_at: now, metadata: {})
26
+ end
27
+ end
28
+
29
+ RSpec.shared_examples "a Handoff state repository" do
30
+ include_context "coordination repository values"
31
+
32
+ it "returns nil only for an authoritative absent Handoff anchor" do
33
+ expect(persistence.handoff_states.load(SecureRandom.uuid)).to be_nil
34
+ end
35
+
36
+ it "round-trips the immutable Handoff snapshot and rejects stale routing CAS" do
37
+ value = coordination_handoff
38
+ persistence.handoff_states.save(value.main_agent_id, expected_revision: nil, state: value)
39
+ loaded = persistence.handoff_states.load(value.main_agent_id)
40
+ expect(loaded.to_h).to eq(value.to_h)
41
+ expect(loaded).to be_frozen
42
+ updated = value.with(phase: "stable")
43
+ persistence.handoff_states.save(value.main_agent_id, expected_revision: 1, state: updated)
44
+ expect do
45
+ persistence.handoff_states.save(value.main_agent_id, expected_revision: 1, state: updated)
46
+ end.to raise_error(Phronomy::Persistence::ConflictError)
47
+ expect(persistence.handoff_states.load(value.main_agent_id).handoff_revision).to eq(2)
48
+ end
49
+
50
+ it "rejects a snapshot for a different routing anchor" do
51
+ expect do
52
+ persistence.handoff_states.save("other", expected_revision: nil, state: coordination_handoff)
53
+ end.to raise_error(Phronomy::Persistence::SerializationError)
54
+ end
55
+ end
56
+
57
+ RSpec.shared_examples "a Team repository" do
58
+ include_context "coordination repository values"
59
+
60
+ it "round-trips a Team lineage and rejects duplicate creation" do
61
+ persistence.teams.create(coordination_team)
62
+ expect(persistence.teams.load(coordination_team.team_id).to_h).to eq(coordination_team.to_h)
63
+ expect { persistence.teams.create(coordination_team) }.to raise_error(Phronomy::Persistence::ConflictError)
64
+ end
65
+
66
+ it "rejects a stale Team revision" do
67
+ persistence.teams.create(coordination_team)
68
+ updated = coordination_team.with(lifecycle_status: "active")
69
+ persistence.teams.save(coordination_team.team_id, expected_revision: 0, root: updated)
70
+ expect do
71
+ persistence.teams.save(coordination_team.team_id, expected_revision: 0, root: updated)
72
+ end.to raise_error(Phronomy::Persistence::ConflictError)
73
+ end
74
+
75
+ it "raises NotFoundError for an absent Team" do
76
+ expect { persistence.teams.load(SecureRandom.uuid) }.to raise_error(Phronomy::Persistence::NotFoundError)
77
+ end
78
+ end
79
+
80
+ RSpec.shared_examples "a Team execution repository" do
81
+ include_context "coordination repository values"
82
+ before { persistence.teams.create(coordination_team) }
83
+
84
+ it "atomically admits at most one active run per Team" do
85
+ persistence.team_executions.create_active(coordination_run)
86
+ other = coordination_run.with(team_execution_id: SecureRandom.uuid, execution_revision: 0)
87
+ expect { persistence.team_executions.create_active(other) }.to raise_error(Phronomy::AgentBusyError)
88
+ expect(persistence.team_executions.list_active(coordination_team.team_id).map(&:team_execution_id)).to eq([coordination_run.team_execution_id])
89
+ end
90
+
91
+ it "releases admission after terminal CAS while retaining discoverable outcomes" do
92
+ persistence.team_executions.create_active(coordination_run)
93
+ completed = coordination_run.with(status: "completed", phase: "completed")
94
+ persistence.team_executions.save(coordination_run.team_execution_id, expected_revision: 0, execution: completed)
95
+ other = coordination_run.with(team_execution_id: SecureRandom.uuid, execution_revision: 0)
96
+ persistence.team_executions.create_active(other)
97
+ ids = [completed.team_execution_id, other.team_execution_id].sort
98
+ expect(persistence.team_executions.list(coordination_team.team_id, limit: 1).map(&:team_execution_id)).to eq(ids.first(1))
99
+ expect(persistence.team_executions.list(coordination_team.team_id, after: ids.first).map(&:team_execution_id)).to eq(ids.last(1))
100
+ expect(persistence.team_executions.list("other-team")).to be_empty
101
+ expect do
102
+ persistence.team_executions.save(completed.team_execution_id, expected_revision: 0, execution: completed)
103
+ end.to raise_error(Phronomy::Persistence::ConflictError)
104
+ expect do
105
+ persistence.team_executions.save(completed.team_execution_id, expected_revision: 1,
106
+ execution: completed.with(status: "active", phase: "coordinator"))
107
+ end.to raise_error(Phronomy::Persistence::ConflictError)
108
+ end
109
+
110
+ it "rolls the new three repositories back with the existing transaction domain" do
111
+ handoff = coordination_handoff
112
+ root_id = "coordination-rollback-agent-#{SecureRandom.uuid}"
113
+ content_ref = nil
114
+ expect do
115
+ persistence.transaction do |tx|
116
+ tx.team_executions.create_active(coordination_run)
117
+ tx.teams.save(coordination_team.team_id, expected_revision: 0, root: coordination_team.with(lifecycle_status: "active"))
118
+ tx.handoff_states.save(handoff.main_agent_id, expected_revision: nil, state: handoff)
119
+ content_ref = tx.contents.put_text("coordination-rollback-#{SecureRandom.uuid}")
120
+ root = Phronomy::Agent::AgentRoot.create(agent_id: root_id, agent_definition_id: "rollback", agent_definition_version: 1)
121
+ tx.agents.create(root)
122
+ tx.journals.append(root_id, expected_position: 0, records: [])
123
+ record = Phronomy::Agent::JournalRecord.new(agent_id: root_id, kind: :input_received, channel: :external)
124
+ tx.executions.create_active(Phronomy::Agent::AgentExecution.start(agent_root: root, input_record: record))
125
+ tx.workflow_states.save(root_id, expected_revision: nil, snapshot: {fields: {value: "committed"}, phase: "pause"})
126
+ raise "rollback all eight"
127
+ end
128
+ end.to raise_error(RuntimeError, "rollback all eight")
129
+ expect(persistence.teams.load(coordination_team.team_id).team_revision).to eq(0)
130
+ expect(persistence.handoff_states.load(handoff.main_agent_id)).to be_nil
131
+ expect(persistence.team_executions.list(coordination_team.team_id)).to be_empty
132
+ expect { persistence.agents.load(root_id) }.to raise_error(Phronomy::Persistence::NotFoundError)
133
+ expect(persistence.executions.list(root_id)).to be_empty
134
+ expect(persistence.workflow_states.load(root_id)).to be_nil
135
+ expect(persistence.contents.exist?(content_ref)).to be(false)
136
+ end
137
+ end
@@ -27,6 +27,9 @@ module Phronomy
27
27
  "a Journal repository",
28
28
  "an Execution repository",
29
29
  "a workflow state repository",
30
+ "a Handoff state repository",
31
+ "a Team repository",
32
+ "a Team execution repository",
30
33
  "a Persistence backend"
31
34
  ].freeze
32
35
  end
@@ -39,3 +42,5 @@ require_relative "persistence_contract/a_journal_repository"
39
42
  require_relative "persistence_contract/an_execution_repository"
40
43
  require_relative "persistence_contract/a_workflow_state_repository"
41
44
  require_relative "persistence_contract/a_persistence_backend"
45
+
46
+ require_relative "persistence_contract/coordination_repositories"
@@ -161,7 +161,7 @@ module Phronomy
161
161
  end
162
162
 
163
163
  def settle_async_error(task, error)
164
- if error.is_a?(Phronomy::ToolError) || error.is_a?(Phronomy::CancellationError)
164
+ if error.is_a?(Phronomy::ToolError) || error.is_a?(Phronomy::CancellationError) || error.is_a?(Phronomy::ExecutionRehydrationRequiredError)
165
165
  task.fail(error)
166
166
  return task
167
167
  end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Tracing
5
+ # Framework-private automatic instrumentation for the small D02-F03
6
+ # logical-operation coverage set.
7
+ #
8
+ # This helper deliberately does not define trace-context propagation,
9
+ # durable span state, or backend-native parent/child semantics.
10
+ # @api private
11
+ module Automatic
12
+ REDACTED = "[REDACTED]"
13
+ SENSITIVE_METADATA_KEYS = %i[user_id session_id].freeze
14
+
15
+ Handle = Data.define(:name, :tracer, :span, :trace_pii)
16
+ private_constant :Handle
17
+
18
+ module_function
19
+
20
+ def start(name, input: nil, **metadata)
21
+ trace_pii = false
22
+ configuration = Phronomy.configuration
23
+ trace_pii = !!configuration.trace_pii
24
+ tracer = configuration.tracer
25
+ span = tracer.start_span(
26
+ name,
27
+ input: traced_payload(input, trace_pii: trace_pii),
28
+ **safe_metadata(metadata, trace_pii: trace_pii)
29
+ )
30
+ Handle.new(
31
+ name: name.to_s.freeze,
32
+ tracer: tracer,
33
+ span: span,
34
+ trace_pii: trace_pii
35
+ )
36
+ rescue => error
37
+ report_failure(:start, name, error, include_message: trace_pii)
38
+ nil
39
+ end
40
+
41
+ def finish(handle, output: nil, usage: nil, error: nil)
42
+ return nil unless handle
43
+
44
+ attributes = {
45
+ output: traced_payload(output, trace_pii: handle.trace_pii),
46
+ usage: usage,
47
+ error: traced_error(error, trace_pii: handle.trace_pii)
48
+ }.freeze
49
+
50
+ if Phronomy::Runtime.in_event_loop_context?
51
+ submit_finish(handle, attributes)
52
+ else
53
+ finish_now(handle, attributes)
54
+ end
55
+ nil
56
+ rescue => trace_error
57
+ # simplecov:disable
58
+ report_failure(
59
+ :finish,
60
+ handle&.name || "unknown",
61
+ trace_error,
62
+ include_message: handle ? handle.trace_pii : false
63
+ )
64
+ nil
65
+ # simplecov:enable
66
+ end
67
+
68
+ def observe_task(task, name, input: nil, **metadata)
69
+ handle = start(name, input: input, **metadata)
70
+ task.on_complete do |value, error|
71
+ finish(
72
+ handle,
73
+ output: value,
74
+ usage: task_usage(value),
75
+ error: error
76
+ )
77
+ end
78
+ task
79
+ end
80
+
81
+ def trace(name, input: nil, **metadata)
82
+ handle = start(name, input: input, **metadata)
83
+ result = yield
84
+ finish(handle, output: result, usage: task_usage(result))
85
+ result
86
+ rescue => operation_error
87
+ finish(handle, error: operation_error) if defined?(handle)
88
+ raise
89
+ end
90
+
91
+ def traced_payload(value, trace_pii:)
92
+ return value if trace_pii
93
+ return nil if value.nil?
94
+
95
+ REDACTED
96
+ end
97
+ private_class_method :traced_payload
98
+
99
+ def safe_metadata(metadata, trace_pii:)
100
+ metadata.each_with_object({}) do |(key, value), safe|
101
+ next if value.nil?
102
+
103
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
104
+ next if !trace_pii && SENSITIVE_METADATA_KEYS.include?(normalized_key)
105
+
106
+ safe[key] = value
107
+ end
108
+ end
109
+ private_class_method :safe_metadata
110
+
111
+ def traced_error(error, trace_pii:)
112
+ return nil unless error
113
+ return error if trace_pii
114
+
115
+ redacted = error.exception(REDACTED)
116
+ redacted.set_backtrace([])
117
+ redacted
118
+ rescue
119
+ fallback = StandardError.new(REDACTED)
120
+ fallback.set_backtrace([])
121
+ fallback
122
+ end
123
+ private_class_method :traced_error
124
+
125
+ def task_usage(value)
126
+ return nil unless value.is_a?(Hash)
127
+
128
+ value[:usage] || value["usage"]
129
+ end
130
+ private_class_method :task_usage
131
+
132
+ def submit_finish(handle, attributes)
133
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
134
+ finish_now(handle, attributes)
135
+ end
136
+ nil
137
+ rescue => error
138
+ report_failure(
139
+ :finish,
140
+ handle.name,
141
+ error,
142
+ include_message: handle.trace_pii
143
+ )
144
+ nil
145
+ end
146
+ private_class_method :submit_finish
147
+
148
+ def finish_now(handle, attributes)
149
+ handle.tracer.finish_span(handle.span, **attributes)
150
+ nil
151
+ rescue => error
152
+ report_failure(
153
+ :finish,
154
+ handle.name,
155
+ error,
156
+ include_message: handle.trace_pii
157
+ )
158
+ nil
159
+ end
160
+ private_class_method :finish_now
161
+
162
+ def report_failure(stage, name, error, include_message:)
163
+ detail = include_message ? ": #{error.message}" : ""
164
+ message =
165
+ "[Phronomy] automatic tracing #{stage} failed for " \
166
+ "#{name}: #{error.class}#{detail}"
167
+ logger = Phronomy.configuration.logger
168
+ logger ? logger.warn(message) : Kernel.warn(message)
169
+ nil
170
+ rescue
171
+ nil
172
+ end
173
+ private_class_method :report_failure
174
+ end
175
+ end
176
+ end
@@ -12,8 +12,17 @@ module Phronomy
12
12
  # c.tracer = MyTracer.new
13
13
  # end
14
14
  #
15
- # The tracer is then automatically used by Chain and Agent components
16
- # via the #trace helper.
15
+ # The configured tracer instance may be shared by independent Phronomy
16
+ # operations. Phronomy does not serialize calls to that instance; #trace,
17
+ # #start_span, and #finish_span may therefore be entered concurrently.
18
+ # Custom tracer implementations must not assume exclusive single-operation use
19
+ # and must protect mutable shared state they own.
20
+ #
21
+ # This contract does not guarantee a particular OS thread, EventLoop,
22
+ # OffloadPool worker, or async-context affinity. Framework-owned automatic
23
+ # instrumentation uses this same start_span / finish_span SPI for a small
24
+ # set of logical operations. Phronomy guarantees portable semantic
25
+ # correlation metadata, not one backend-native parent/child span tree.
17
26
  class Base
18
27
  # Wraps a block in a span. Yields the span to the block.
19
28
  # Calls #finish_span with the output on success or with the error on failure.
@@ -13,8 +13,8 @@ module Phronomy
13
13
  # (+POST /api/public/ingestion+). No external gem is required — only
14
14
  # Ruby standard-library network primitives are used.
15
15
  #
16
- # Ingestion errors are silently swallowed so that a Langfuse outage never
17
- # breaks the application.
16
+ # Ingestion failures are reported as warnings and are not re-raised, so a
17
+ # Langfuse outage does not fail the traced application operation.
18
18
  #
19
19
  # @example Configure globally
20
20
  # Phronomy.configure do |c|
@@ -37,6 +37,7 @@ module Phronomy
37
37
  @secret_key = secret_key
38
38
  @host = host.chomp("/")
39
39
  @http = nil
40
+ @http_mutex = Mutex.new
40
41
  end
41
42
 
42
43
  # Returns a plain Hash that records the span start state.
@@ -91,16 +92,23 @@ module Phronomy
91
92
  req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
92
93
  req.body = JSON.generate({batch: events})
93
94
 
94
- @http ||= build_http(uri)
95
- @http.request(req)
96
- rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
97
- # Connection was reset; drop the cached connection and warn.
98
- @http = nil
99
- warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
100
- nil
101
- rescue => e
102
- warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
103
- nil
95
+ # Net::HTTP persistent connections are mutable and are not used
96
+ # concurrently through one configured LangfuseTracer instance. This
97
+ # protects the adapter's own shared connection cache; it does not
98
+ # serialize Phronomy tracing calls or impose serialization on custom
99
+ # Tracing::Base implementations.
100
+ @http_mutex.synchronize do
101
+ @http ||= build_http(uri)
102
+ @http.request(req)
103
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
104
+ # Connection was reset; drop the cached connection and warn.
105
+ @http = nil
106
+ warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
107
+ nil
108
+ rescue => e
109
+ warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
110
+ nil
111
+ end
104
112
  end
105
113
 
106
114
  def build_http(uri)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.22.0"
4
+ VERSION = "0.24.0"
5
5
  end
@@ -43,16 +43,16 @@ module Phronomy
43
43
 
44
44
  # Sends an event to an active Workflow execution without blocking.
45
45
  #
46
- # +thread_id+ is the logical/durable Workflow identity. EventLoop resolves it
46
+ # +workflow_instance_id+ is the logical/durable Workflow identity. EventLoop resolves it
47
47
  # to the currently owning Runtime-only fsm_session_id. InvocationContext's
48
48
  # application session_id is unrelated to this routing.
49
49
  #
50
50
  # @return [Boolean] true when admitted; false when the Workflow is not live
51
51
  # or Runtime shutdown has begun
52
52
  # @api public
53
- def signal(thread_id:, event:, payload: nil)
53
+ def signal(workflow_instance_id:, event:, payload: nil)
54
54
  @runner.signal(
55
- thread_id: thread_id,
55
+ workflow_instance_id: workflow_instance_id,
56
56
  event: event,
57
57
  payload: payload
58
58
  )
@@ -62,9 +62,6 @@ module Phronomy
62
62
 
63
63
  def _apply_invocation_context(config, invocation_context)
64
64
  effective = config.merge(invocation_context: invocation_context)
65
- if effective[:thread_id].nil? && invocation_context.thread_id
66
- effective = effective.merge(thread_id: invocation_context.thread_id)
67
- end
68
65
  if effective[:cancellation_token].nil?
69
66
  token = invocation_context.effective_timeout_token
70
67
  effective = effective.merge(cancellation_token: token) if token
@@ -19,6 +19,13 @@ module Phronomy
19
19
 
20
20
  module ClassMethods
21
21
  def field(name, type: :replace, default: nil)
22
+ # workflow_instance_id is framework-owned metadata; do not declare it as a field.
23
+ if name.to_sym == :workflow_instance_id
24
+ raise ArgumentError,
25
+ "WorkflowContext field :workflow_instance_id is reserved for " \
26
+ "framework-owned Workflow identity metadata. " \
27
+ "Use a different field name for application state."
28
+ end
22
29
  if default.is_a?(Array) || default.is_a?(Hash)
23
30
  raise ArgumentError,
24
31
  "Mutable default for field #{name.inspect} must be wrapped in a Proc " \
@@ -40,7 +47,7 @@ module Phronomy
40
47
  end
41
48
  end
42
49
 
43
- attr_reader :thread_id
50
+ attr_reader :workflow_instance_id
44
51
 
45
52
  def phase
46
53
  @phase || :__end__
@@ -50,8 +57,10 @@ module Phronomy
50
57
  phase != :__end__
51
58
  end
52
59
 
53
- def set_graph_metadata(thread_id: nil, phase: nil)
54
- @thread_id = thread_id unless thread_id.nil?
60
+ # Workflow identity is explicit domain metadata. FSMSession supplies this
61
+ # metadata without a generic thread/session identity bridge.
62
+ def set_graph_metadata(workflow_instance_id: nil, phase: nil)
63
+ @workflow_instance_id = workflow_instance_id unless workflow_instance_id.nil?
55
64
  @phase = phase unless phase.nil?
56
65
  self
57
66
  end
@@ -75,7 +84,7 @@ module Phronomy
75
84
  attrs.fetch(name, default)
76
85
  )
77
86
  end
78
- @thread_id = nil
87
+ @workflow_instance_id = nil
79
88
  @phase = :__end__
80
89
  end
81
90
 
@@ -107,7 +116,7 @@ module Phronomy
107
116
 
108
117
  new_context = self.class.new(**new_attrs)
109
118
  new_context.set_graph_metadata(
110
- thread_id: @thread_id,
119
+ workflow_instance_id: @workflow_instance_id,
111
120
  phase: @phase
112
121
  )
113
122
  new_context
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # ACS-15 Workflow-side Persistence F1 reconciliation. Workflow hydration and
5
+ # orchestration remain owned by WorkflowRunner; this module only replaces the
6
+ # terminal durable barrier so response loss is reconciled from authoritative
7
+ # durable pre/post state instead of being treated as an ordinary failed save.
8
+ # @api private
9
+ module WorkflowRecovery
10
+ private
11
+
12
+ def begin_terminal_persistence_on_event_loop(
13
+ execution,
14
+ terminal_type:,
15
+ context:,
16
+ event_sink:
17
+ )
18
+ runtime = Phronomy::Runtime.instance
19
+ event_loop = runtime.event_loop
20
+ assert_event_loop!(event_loop)
21
+ event_loop.mark_workflow_admission(
22
+ execution.workflow_instance_id,
23
+ owner_token: execution.owner_token,
24
+ state: :persisting_terminal
25
+ )
26
+
27
+ operation = Phronomy::WorkflowRunner::WorkflowTerminalPersistenceCommand.new(
28
+ repository: execution.repository,
29
+ workflow_instance_id: execution.workflow_instance_id.to_s.freeze,
30
+ expected_revision: execution.expected_revision,
31
+ snapshot: deep_immutable_copy(snapshot_for(context))
32
+ )
33
+
34
+ task = runtime.offload.submit(on_full: :raise) do
35
+ revision = operation.repository.save(
36
+ operation.workflow_instance_id,
37
+ expected_revision: operation.expected_revision,
38
+ snapshot: operation.snapshot
39
+ )
40
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
41
+ outcome: :success,
42
+ revision: revision,
43
+ error: nil
44
+ )
45
+ rescue Phronomy::Persistence::ConflictError,
46
+ Phronomy::Persistence::NotFoundError,
47
+ Phronomy::Persistence::SerializationError,
48
+ Phronomy::Persistence::UnsupportedBackendError => error
49
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
50
+ outcome: :known_failure,
51
+ revision: nil,
52
+ error: error
53
+ )
54
+ rescue => error
55
+ reconcile_workflow_terminal_f1(operation, error)
56
+ end
57
+
58
+ task.on_complete do |result, operation_error|
59
+ delivery = if operation_error
60
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
61
+ outcome: :outcome_unknown,
62
+ revision: nil,
63
+ error: operation_error
64
+ )
65
+ else
66
+ result
67
+ end
68
+ accepted = event_sink.post(:workflow_terminal_persistence_result, delivery)
69
+ unless accepted
70
+ Phronomy.configuration.logger&.warn(
71
+ "[Phronomy] EventLoop rejected Workflow terminal persistence result " \
72
+ "for #{execution.workflow_instance_id.inspect}"
73
+ )
74
+ end
75
+ end
76
+ terminal_type
77
+ end
78
+
79
+ def reconcile_workflow_terminal_f1(operation, original_error)
80
+ record = operation.repository.load(operation.workflow_instance_id)
81
+ case Phronomy::Recovery.compare_revisioned_snapshot(
82
+ record: record,
83
+ expected_pre_revision: operation.expected_revision,
84
+ intended_snapshot: operation.snapshot
85
+ )
86
+ when :post_state
87
+ revision = Phronomy::Recovery.fetch_value(record, :revision)
88
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
89
+ outcome: :success,
90
+ revision: revision,
91
+ error: nil
92
+ )
93
+ when :pre_state
94
+ # The uncertain save is now known not to have committed. Do not blindly
95
+ # retry it here; surface a known failure through the ordinary terminal
96
+ # barrier semantics.
97
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
98
+ outcome: :known_failure,
99
+ revision: nil,
100
+ error: original_error
101
+ )
102
+ else
103
+ conflict = Phronomy::Persistence::ConflictError.new(
104
+ "Workflow terminal Persistence outcome conflicts with both expected " \
105
+ "pre-state and intended post-state for #{operation.workflow_instance_id.inspect}"
106
+ )
107
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
108
+ outcome: :outcome_unknown,
109
+ revision: nil,
110
+ error: conflict
111
+ )
112
+ end
113
+ rescue => reconciliation_error
114
+ Phronomy::WorkflowRunner::WorkflowTerminalPersistenceResult.new(
115
+ outcome: :outcome_unknown,
116
+ revision: nil,
117
+ error: reconciliation_error
118
+ )
119
+ end
120
+ end
121
+ end
122
+
123
+ Phronomy::WorkflowRunner.prepend(Phronomy::WorkflowRecovery)