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.
- checksums.yaml +4 -4
- data/.mutant.yml +3 -4
- data/CHANGELOG.md +215 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +147 -0
- data/docs/architecture/persistence.md +186 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +89 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +176 -0
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +65 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +162 -72
- data/lib/phronomy/agent/base.rb +416 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +339 -103
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
- 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"
|
data/lib/phronomy/tools/agent.rb
CHANGED
|
@@ -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
|
|
16
|
-
#
|
|
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
|
|
17
|
-
#
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -43,16 +43,16 @@ module Phronomy
|
|
|
43
43
|
|
|
44
44
|
# Sends an event to an active Workflow execution without blocking.
|
|
45
45
|
#
|
|
46
|
-
# +
|
|
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(
|
|
53
|
+
def signal(workflow_instance_id:, event:, payload: nil)
|
|
54
54
|
@runner.signal(
|
|
55
|
-
|
|
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 :
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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)
|