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.
- checksums.yaml +4 -4
- data/.mutant.yml +2 -2
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +2 -2
- data/README.md +1 -1
- data/docs/architecture/multi-agent-handoff.md +35 -40
- data/docs/architecture/persistence.md +19 -8
- data/docs/architecture.md +8 -1
- data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
- 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 +5 -1
- 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 +35 -1
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +42 -3
- data/lib/phronomy/agent/agent_execution.rb +2 -2
- data/lib/phronomy/agent/agent_invocation.rb +1 -1
- data/lib/phronomy/agent/async_event_api.rb +18 -1
- data/lib/phronomy/agent/base.rb +28 -0
- data/lib/phronomy/agent/context_assembler.rb +1 -1
- 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 +484 -27
- data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
- data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
- data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
- data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
- data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
- data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
- data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
- data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
- data/lib/phronomy/agent/recovery_support.rb +15 -23
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
- data/lib/phronomy/agent/tool_invocation.rb +4 -2
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +16 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
- 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 +60 -0
- data/lib/phronomy/persistence/in_memory.rb +264 -2
- data/lib/phronomy/persistence/repository_facades.rb +221 -2
- data/lib/phronomy/persistence.rb +95 -1
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
- 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/version.rb +1 -1
- data/scripts/api_snapshot.rb +3 -3
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +28 -32
- data/sig/phronomy/persistence.rbs +64 -3
- metadata +30 -12
- data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
- data/lib/phronomy/multi_agent/coordinator.rb +0 -154
- data/lib/phronomy/multi_agent/runner.rb +0 -98
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Immutable current-format semantic record; contains no Runtime handles.
|
|
8
|
+
# @api private
|
|
9
|
+
class TeamExecution
|
|
10
|
+
ATTRIBUTES = %w[team_execution_id team_id execution_revision status phase input_ref coordinator tasks workers assignments result_ref error_ref created_at updated_at metadata].freeze
|
|
11
|
+
attr_reader(*ATTRIBUTES)
|
|
12
|
+
|
|
13
|
+
def initialize(**values)
|
|
14
|
+
source = values.transform_keys(&:to_s)
|
|
15
|
+
raise ArgumentError, "TeamExecution schema mismatch" unless source.keys.sort == ATTRIBUTES.sort
|
|
16
|
+
canonical = Phronomy::CanonicalJSON.load(Phronomy::CanonicalJSON.dump(source))
|
|
17
|
+
ATTRIBUTES.each { |key| instance_variable_set("@#{key}", Phronomy::Agent::Immutable.copy(canonical.fetch(key))) }
|
|
18
|
+
raise ArgumentError, "missing team_execution_id" if team_execution_id.to_s.empty?
|
|
19
|
+
raise ArgumentError, "invalid execution_revision" unless execution_revision.is_a?(Integer) && execution_revision >= 0
|
|
20
|
+
raise ArgumentError, "invalid metadata" unless metadata.is_a?(Hash)
|
|
21
|
+
raise ArgumentError, "invalid Team status" unless %w[active completed failed cancelled].include?(status)
|
|
22
|
+
raise ArgumentError, "missing Team owner" if team_id.to_s.empty?
|
|
23
|
+
raise ArgumentError, "invalid Team collections" unless [tasks, workers, assignments].all? { |v| v.is_a?(Array) } && coordinator.is_a?(Hash)
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h = ATTRIBUTES.to_h { |key| [key, public_send(key)] }.freeze
|
|
28
|
+
|
|
29
|
+
def self.from_h(value)
|
|
30
|
+
new(**value.transform_keys(&:to_sym))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with(**changes)
|
|
34
|
+
values = to_h.merge(changes.transform_keys(&:to_s))
|
|
35
|
+
values["execution_revision"] = execution_revision + 1 unless changes.key?(:execution_revision)
|
|
36
|
+
values["updated_at"] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
|
|
37
|
+
self.class.from_h(values)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def active? = status == "active"
|
|
41
|
+
def terminal? = %w[completed failed cancelled].include?(status)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Immutable current-format semantic record; contains no Runtime handles.
|
|
8
|
+
# @api private
|
|
9
|
+
class TeamRoot
|
|
10
|
+
ATTRIBUTES = %w[team_id team_definition_id team_definition_version team_revision lifecycle_status created_at updated_at metadata].freeze
|
|
11
|
+
attr_reader(*ATTRIBUTES)
|
|
12
|
+
|
|
13
|
+
def initialize(**values)
|
|
14
|
+
source = values.transform_keys(&:to_s)
|
|
15
|
+
raise ArgumentError, "TeamRoot schema mismatch" unless source.keys.sort == ATTRIBUTES.sort
|
|
16
|
+
canonical = Phronomy::CanonicalJSON.load(Phronomy::CanonicalJSON.dump(source))
|
|
17
|
+
ATTRIBUTES.each { |key| instance_variable_set("@#{key}", Phronomy::Agent::Immutable.copy(canonical.fetch(key))) }
|
|
18
|
+
raise ArgumentError, "missing team_id" if team_id.to_s.empty?
|
|
19
|
+
raise ArgumentError, "invalid team_revision" unless team_revision.is_a?(Integer) && team_revision >= 0
|
|
20
|
+
raise ArgumentError, "invalid metadata" unless metadata.is_a?(Hash)
|
|
21
|
+
raise ArgumentError, "invalid Team lifecycle" unless %w[idle active closed].include?(lifecycle_status)
|
|
22
|
+
raise ArgumentError, "missing Team definition" if team_definition_id.to_s.empty?
|
|
23
|
+
raise ArgumentError, "invalid Team version" unless team_definition_version.is_a?(Integer) && team_definition_version.positive?
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h = ATTRIBUTES.to_h { |key| [key, public_send(key)] }.freeze
|
|
28
|
+
|
|
29
|
+
def self.from_h(value)
|
|
30
|
+
new(**value.transform_keys(&:to_sym))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with(**changes)
|
|
34
|
+
values = to_h.merge(changes.transform_keys(&:to_s))
|
|
35
|
+
values["team_revision"] = team_revision + 1 unless changes.key?(:team_revision)
|
|
36
|
+
values["updated_at"] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
|
|
37
|
+
self.class.from_h(values)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -58,6 +58,66 @@ module Phronomy
|
|
|
58
58
|
|
|
59
59
|
module_function
|
|
60
60
|
|
|
61
|
+
def encode_handoff_state(value)
|
|
62
|
+
payload = value.to_h
|
|
63
|
+
Phronomy::Agent::HandoffState.from_h(payload)
|
|
64
|
+
build_record("phronomy.handoff_state", "0.1", payload)
|
|
65
|
+
rescue Phronomy::Persistence::SerializationError
|
|
66
|
+
raise
|
|
67
|
+
rescue => error
|
|
68
|
+
serialization_error("cannot encode HandoffState", error)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decode_handoff_state(record)
|
|
72
|
+
payload = current_payload!(record, record_type: "phronomy.handoff_state",
|
|
73
|
+
format_version: "0.1", keys: Phronomy::Agent::HandoffState::ATTRIBUTES, label: "HandoffState")
|
|
74
|
+
Phronomy::Agent::HandoffState.from_h(payload)
|
|
75
|
+
rescue Phronomy::Persistence::SerializationError
|
|
76
|
+
raise
|
|
77
|
+
rescue => error
|
|
78
|
+
serialization_error("cannot decode HandoffState", error)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def encode_team_root(value)
|
|
82
|
+
payload = value.to_h
|
|
83
|
+
Phronomy::MultiAgent::TeamRoot.from_h(payload)
|
|
84
|
+
build_record("phronomy.team_root", "0.1", payload)
|
|
85
|
+
rescue Phronomy::Persistence::SerializationError
|
|
86
|
+
raise
|
|
87
|
+
rescue => error
|
|
88
|
+
serialization_error("cannot encode TeamRoot", error)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_team_root(record)
|
|
92
|
+
payload = current_payload!(record, record_type: "phronomy.team_root",
|
|
93
|
+
format_version: "0.1", keys: Phronomy::MultiAgent::TeamRoot::ATTRIBUTES, label: "TeamRoot")
|
|
94
|
+
Phronomy::MultiAgent::TeamRoot.from_h(payload)
|
|
95
|
+
rescue Phronomy::Persistence::SerializationError
|
|
96
|
+
raise
|
|
97
|
+
rescue => error
|
|
98
|
+
serialization_error("cannot decode TeamRoot", error)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def encode_team_execution(value)
|
|
102
|
+
payload = value.to_h
|
|
103
|
+
Phronomy::MultiAgent::TeamExecution.from_h(payload)
|
|
104
|
+
build_record("phronomy.team_execution", "0.1", payload)
|
|
105
|
+
rescue Phronomy::Persistence::SerializationError
|
|
106
|
+
raise
|
|
107
|
+
rescue => error
|
|
108
|
+
serialization_error("cannot encode TeamExecution", error)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def decode_team_execution(record)
|
|
112
|
+
payload = current_payload!(record, record_type: "phronomy.team_execution",
|
|
113
|
+
format_version: "0.1", keys: Phronomy::MultiAgent::TeamExecution::ATTRIBUTES, label: "TeamExecution")
|
|
114
|
+
Phronomy::MultiAgent::TeamExecution.from_h(payload)
|
|
115
|
+
rescue Phronomy::Persistence::SerializationError
|
|
116
|
+
raise
|
|
117
|
+
rescue => error
|
|
118
|
+
serialization_error("cannot decode TeamExecution", error)
|
|
119
|
+
end
|
|
120
|
+
|
|
61
121
|
def encode_agent_root(root)
|
|
62
122
|
payload = top_level_string_keys(root.to_h, label: "AgentRoot payload")
|
|
63
123
|
payload["lifecycle_status"] = root.lifecycle_status.to_s
|
|
@@ -257,6 +257,15 @@ module Phronomy
|
|
|
257
257
|
end
|
|
258
258
|
end
|
|
259
259
|
|
|
260
|
+
def list(agent_id, after: nil, limit: 100)
|
|
261
|
+
@owner.synchronize do
|
|
262
|
+
ids = @owner.state[:execution_metadata].filter_map do |id, metadata|
|
|
263
|
+
id if metadata.fetch(:agent_id) == agent_id.to_s && (!after || id > after.to_s)
|
|
264
|
+
end.sort.first(limit)
|
|
265
|
+
ids.map { |id| @owner.state[:executions].fetch(id).copy }.freeze
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
260
269
|
def delete(execution_id)
|
|
261
270
|
@owner.synchronize do
|
|
262
271
|
key = execution_id.to_s
|
|
@@ -292,6 +301,255 @@ module Phronomy
|
|
|
292
301
|
end
|
|
293
302
|
end
|
|
294
303
|
|
|
304
|
+
class Teams
|
|
305
|
+
def initialize(owner) = @owner = owner
|
|
306
|
+
|
|
307
|
+
def create(team_id:, team_revision:, record:)
|
|
308
|
+
record = @owner.require_durable_record!(record)
|
|
309
|
+
key = team_id.to_s
|
|
310
|
+
revision = Integer(team_revision)
|
|
311
|
+
raise ConflictError, "team_id must not be empty" if key.empty?
|
|
312
|
+
raise ConflictError, "team_revision must be non-negative" if revision.negative?
|
|
313
|
+
|
|
314
|
+
@owner.synchronize do
|
|
315
|
+
raise ConflictError, "agent already exists: #{key}" if @owner.state[:teams].key?(key)
|
|
316
|
+
@owner.state[:teams][key] = record.copy
|
|
317
|
+
@owner.state[:team_revisions][key] = revision
|
|
318
|
+
end
|
|
319
|
+
record.copy
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def load(team_id)
|
|
323
|
+
@owner.synchronize do
|
|
324
|
+
@owner.state[:teams].fetch(team_id.to_s) do
|
|
325
|
+
raise NotFoundError, "agent not found: #{team_id}"
|
|
326
|
+
end.copy
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def save(team_id, expected_revision:, next_revision:, record:)
|
|
331
|
+
record = @owner.require_durable_record!(record)
|
|
332
|
+
key = team_id.to_s
|
|
333
|
+
expected = Integer(expected_revision)
|
|
334
|
+
next_value = Integer(next_revision)
|
|
335
|
+
@owner.synchronize do
|
|
336
|
+
unless @owner.state[:teams].key?(key)
|
|
337
|
+
raise NotFoundError, "agent not found: #{team_id}"
|
|
338
|
+
end
|
|
339
|
+
actual_revision = @owner.state[:team_revisions].fetch(key)
|
|
340
|
+
unless actual_revision == expected
|
|
341
|
+
raise ConflictError,
|
|
342
|
+
"agent revision conflict: expected #{expected}, actual #{actual_revision}"
|
|
343
|
+
end
|
|
344
|
+
unless next_value == expected + 1
|
|
345
|
+
raise ConflictError,
|
|
346
|
+
"agent save must advance revision exactly once: " \
|
|
347
|
+
"expected #{expected + 1}, got #{next_value}"
|
|
348
|
+
end
|
|
349
|
+
@owner.state[:teams][key] = record.copy
|
|
350
|
+
@owner.state[:team_revisions][key] = next_value
|
|
351
|
+
end
|
|
352
|
+
record.copy
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def delete(team_id)
|
|
356
|
+
@owner.synchronize do
|
|
357
|
+
key = team_id.to_s
|
|
358
|
+
@owner.state[:team_revisions].delete(key)
|
|
359
|
+
@owner.state[:teams].delete(key)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
class TeamExecutions
|
|
365
|
+
def initialize(owner) = @owner = owner
|
|
366
|
+
|
|
367
|
+
def create_active(team_execution_id:, team_id:, execution_revision:, record:)
|
|
368
|
+
record = @owner.require_durable_record!(record)
|
|
369
|
+
execution_key = team_execution_id.to_s
|
|
370
|
+
agent_key = team_id.to_s
|
|
371
|
+
revision = Integer(execution_revision)
|
|
372
|
+
raise ConflictError, "team_execution_id must not be empty" if execution_key.empty?
|
|
373
|
+
raise ConflictError, "team_id must not be empty" if agent_key.empty?
|
|
374
|
+
raise ConflictError, "execution_revision must be non-negative" if revision.negative?
|
|
375
|
+
|
|
376
|
+
@owner.synchronize do
|
|
377
|
+
if @owner.state[:team_executions].key?(execution_key)
|
|
378
|
+
raise ConflictError, "execution already exists: #{execution_key}"
|
|
379
|
+
end
|
|
380
|
+
active = @owner.state[:team_execution_metadata].values.find do |metadata|
|
|
381
|
+
metadata.fetch(:team_id) == agent_key && metadata.fetch(:active)
|
|
382
|
+
end
|
|
383
|
+
raise Phronomy::AgentBusyError, "agent is busy: #{agent_key}" if active
|
|
384
|
+
|
|
385
|
+
@owner.state[:team_executions][execution_key] = record.copy
|
|
386
|
+
@owner.state[:team_execution_metadata][execution_key] = {
|
|
387
|
+
team_id: agent_key,
|
|
388
|
+
revision: revision,
|
|
389
|
+
active: true
|
|
390
|
+
}.freeze
|
|
391
|
+
end
|
|
392
|
+
record.copy
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def load(team_execution_id)
|
|
396
|
+
@owner.synchronize do
|
|
397
|
+
@owner.state[:team_executions].fetch(team_execution_id.to_s) do
|
|
398
|
+
raise NotFoundError, "execution not found: #{team_execution_id}"
|
|
399
|
+
end.copy
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def save(team_execution_id, expected_revision:, next_revision:, team_id:, active:, record:)
|
|
404
|
+
record = @owner.require_durable_record!(record)
|
|
405
|
+
execution_key = team_execution_id.to_s
|
|
406
|
+
agent_key = team_id.to_s
|
|
407
|
+
expected = Integer(expected_revision)
|
|
408
|
+
next_value = Integer(next_revision)
|
|
409
|
+
unless active.equal?(true) || active.equal?(false)
|
|
410
|
+
raise ConflictError, "execution active metadata must be true or false"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
@owner.synchronize do
|
|
414
|
+
unless @owner.state[:team_executions].key?(execution_key)
|
|
415
|
+
raise NotFoundError, "execution not found: #{team_execution_id}"
|
|
416
|
+
end
|
|
417
|
+
current = @owner.state[:team_execution_metadata].fetch(execution_key)
|
|
418
|
+
actual_revision = current.fetch(:revision)
|
|
419
|
+
unless actual_revision == expected
|
|
420
|
+
raise ConflictError,
|
|
421
|
+
"execution revision conflict: expected #{expected}, actual #{actual_revision}"
|
|
422
|
+
end
|
|
423
|
+
unless current.fetch(:team_id) == agent_key
|
|
424
|
+
raise ConflictError,
|
|
425
|
+
"Execution Team identity mismatch: #{agent_key} != #{current.fetch(:team_id)}"
|
|
426
|
+
end
|
|
427
|
+
if active && !current.fetch(:active)
|
|
428
|
+
raise ConflictError, "A terminal Team execution cannot become active"
|
|
429
|
+
end
|
|
430
|
+
unless next_value == expected + 1
|
|
431
|
+
raise ConflictError,
|
|
432
|
+
"execution save must advance revision exactly once: " \
|
|
433
|
+
"expected #{expected + 1}, got #{next_value}"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
@owner.state[:team_executions][execution_key] = record.copy
|
|
437
|
+
@owner.state[:team_execution_metadata][execution_key] = {
|
|
438
|
+
team_id: agent_key,
|
|
439
|
+
revision: next_value,
|
|
440
|
+
active: active
|
|
441
|
+
}.freeze
|
|
442
|
+
end
|
|
443
|
+
record.copy
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def list_active(team_id)
|
|
447
|
+
@owner.synchronize do
|
|
448
|
+
agent_key = team_id.to_s
|
|
449
|
+
ids = @owner.state[:team_execution_metadata].filter_map do |team_execution_id, metadata|
|
|
450
|
+
team_execution_id if metadata.fetch(:team_id) == agent_key && metadata.fetch(:active)
|
|
451
|
+
end
|
|
452
|
+
ids.map { |team_execution_id| @owner.state[:team_executions].fetch(team_execution_id).copy }.freeze
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def list(team_id, after: nil, limit: 100)
|
|
457
|
+
@owner.synchronize do
|
|
458
|
+
ids = @owner.state[:team_execution_metadata].filter_map do |id, metadata|
|
|
459
|
+
id if metadata.fetch(:team_id) == team_id.to_s && (!after || id > after.to_s)
|
|
460
|
+
end.sort.first(limit)
|
|
461
|
+
ids.map { |id| @owner.state[:team_executions].fetch(id).copy }.freeze
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def delete(team_execution_id)
|
|
466
|
+
@owner.synchronize do
|
|
467
|
+
key = team_execution_id.to_s
|
|
468
|
+
@owner.state[:team_execution_metadata].delete(key)
|
|
469
|
+
@owner.state[:team_executions].delete(key)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def delete_for_team(team_id)
|
|
474
|
+
@owner.synchronize do
|
|
475
|
+
agent_key = team_id.to_s
|
|
476
|
+
ids = @owner.state[:team_execution_metadata].filter_map do |team_execution_id, metadata|
|
|
477
|
+
team_execution_id if metadata.fetch(:team_id) == agent_key
|
|
478
|
+
end
|
|
479
|
+
ids.each do |team_execution_id|
|
|
480
|
+
@owner.state[:team_execution_metadata].delete(team_execution_id)
|
|
481
|
+
@owner.state[:team_executions].delete(team_execution_id)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def assert_idle!(team_id)
|
|
487
|
+
@owner.synchronize do
|
|
488
|
+
active = @owner.state[:team_execution_metadata].values.find do |metadata|
|
|
489
|
+
metadata.fetch(:team_id) == team_id.to_s && metadata.fetch(:active)
|
|
490
|
+
end
|
|
491
|
+
if active
|
|
492
|
+
raise Phronomy::AgentBusyError,
|
|
493
|
+
"agent has an active or suspended execution: #{team_id}"
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
true
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
class HandoffStates
|
|
501
|
+
def initialize(owner) = @owner = owner
|
|
502
|
+
|
|
503
|
+
def load(main_agent_id)
|
|
504
|
+
@owner.synchronize do
|
|
505
|
+
record = @owner.state[:handoff_states][main_agent_id.to_s]
|
|
506
|
+
record&.copy
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def save(main_agent_id, expected_revision:, next_revision:, active_agent_id:, record:)
|
|
511
|
+
record = @owner.require_durable_record!(record)
|
|
512
|
+
raise ArgumentError, "active_agent_id is required" if active_agent_id.to_s.empty?
|
|
513
|
+
key = main_agent_id.to_s
|
|
514
|
+
expected = expected_revision.nil? ? nil : Integer(expected_revision)
|
|
515
|
+
next_value = Integer(next_revision)
|
|
516
|
+
@owner.synchronize do
|
|
517
|
+
actual_revision = @owner.state[:handoff_revisions][key]
|
|
518
|
+
unless actual_revision == expected
|
|
519
|
+
raise ConflictError,
|
|
520
|
+
"workflow state revision conflict for #{key}: " \
|
|
521
|
+
"expected #{expected.inspect}, actual #{actual_revision.inspect}"
|
|
522
|
+
end
|
|
523
|
+
expected_next = expected.nil? ? 1 : expected + 1
|
|
524
|
+
unless next_value == expected_next
|
|
525
|
+
raise ConflictError,
|
|
526
|
+
"workflow state save must advance revision exactly once: " \
|
|
527
|
+
"expected #{expected_next}, got #{next_value}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
@owner.state[:handoff_states][key] = record.copy
|
|
531
|
+
@owner.state[:handoff_revisions][key] = next_value
|
|
532
|
+
end
|
|
533
|
+
record.copy
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def delete(main_agent_id, expected_revision:)
|
|
537
|
+
@owner.synchronize do
|
|
538
|
+
key = main_agent_id.to_s
|
|
539
|
+
expected = Integer(expected_revision)
|
|
540
|
+
actual_revision = @owner.state[:handoff_revisions][key]
|
|
541
|
+
unless actual_revision == expected
|
|
542
|
+
raise ConflictError,
|
|
543
|
+
"workflow state revision conflict for #{key}: " \
|
|
544
|
+
"expected #{expected.inspect}, actual #{actual_revision.inspect}"
|
|
545
|
+
end
|
|
546
|
+
@owner.state[:handoff_revisions].delete(key)
|
|
547
|
+
@owner.state[:handoff_states].delete(key)
|
|
548
|
+
end
|
|
549
|
+
nil
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
295
553
|
class WorkflowStates
|
|
296
554
|
def initialize(owner) = @owner = owner
|
|
297
555
|
|
|
@@ -357,7 +615,9 @@ module Phronomy
|
|
|
357
615
|
executions: {},
|
|
358
616
|
execution_metadata: {},
|
|
359
617
|
workflow_states: {},
|
|
360
|
-
workflow_revisions: {}
|
|
618
|
+
workflow_revisions: {},
|
|
619
|
+
handoff_states: {}, handoff_revisions: {},
|
|
620
|
+
teams: {}, team_revisions: {}, team_executions: {}, team_execution_metadata: {}
|
|
361
621
|
}
|
|
362
622
|
@contents_backend = Contents.new(self)
|
|
363
623
|
@agents_backend = Agents.new(self)
|
|
@@ -369,7 +629,9 @@ module Phronomy
|
|
|
369
629
|
agents: @agents_backend,
|
|
370
630
|
journals: @journals_backend,
|
|
371
631
|
executions: @executions_backend,
|
|
372
|
-
workflow_states: @workflow_states_backend
|
|
632
|
+
workflow_states: @workflow_states_backend,
|
|
633
|
+
handoff_states: HandoffStates.new(self),
|
|
634
|
+
teams: Teams.new(self), team_executions: TeamExecutions.new(self)
|
|
373
635
|
)
|
|
374
636
|
end
|
|
375
637
|
|
|
@@ -204,6 +204,13 @@ module Phronomy
|
|
|
204
204
|
end.freeze
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
+
def list(agent_id, after: nil, limit: 100)
|
|
208
|
+
raise ArgumentError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
|
|
209
|
+
Array(@backend_repository.list(agent_id.to_s, after: after&.to_s, limit: limit)).map do |record|
|
|
210
|
+
decode_for_execution(record, nil, agent_id: agent_id)
|
|
211
|
+
end.freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
207
214
|
def delete(execution_id)
|
|
208
215
|
@backend_repository.delete(execution_id.to_s)
|
|
209
216
|
end
|
|
@@ -240,6 +247,215 @@ module Phronomy
|
|
|
240
247
|
end
|
|
241
248
|
end
|
|
242
249
|
|
|
250
|
+
class Teams
|
|
251
|
+
def initialize(backend_repository)
|
|
252
|
+
@backend_repository = backend_repository
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def create(root)
|
|
256
|
+
record = DurableCodec.encode_team_root(root)
|
|
257
|
+
stored = @backend_repository.create(
|
|
258
|
+
team_id: root.team_id.to_s,
|
|
259
|
+
team_revision: Integer(root.team_revision),
|
|
260
|
+
record: record
|
|
261
|
+
)
|
|
262
|
+
decode_for_team(stored, root.team_id, revision: root.team_revision)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def load(team_id)
|
|
266
|
+
decode_for_team(@backend_repository.load(team_id.to_s), team_id)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def save(team_id, expected_revision:, root:)
|
|
270
|
+
expected = Integer(expected_revision)
|
|
271
|
+
next_revision = Integer(root.team_revision)
|
|
272
|
+
unless next_revision == expected + 1
|
|
273
|
+
raise Phronomy::Persistence::ConflictError,
|
|
274
|
+
"agent save must advance revision exactly once: " \
|
|
275
|
+
"expected #{expected + 1}, got #{next_revision}"
|
|
276
|
+
end
|
|
277
|
+
unless root.team_id.to_s == team_id.to_s
|
|
278
|
+
raise Phronomy::Persistence::SerializationError,
|
|
279
|
+
"Team root identity mismatch: #{root.team_id} != #{team_id}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
record = DurableCodec.encode_team_root(root)
|
|
283
|
+
stored = @backend_repository.save(
|
|
284
|
+
team_id.to_s,
|
|
285
|
+
expected_revision: expected,
|
|
286
|
+
next_revision: next_revision,
|
|
287
|
+
record: record
|
|
288
|
+
)
|
|
289
|
+
decode_for_team(stored, team_id, revision: next_revision)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def delete(team_id)
|
|
293
|
+
@backend_repository.delete(team_id.to_s)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def decode_for_team(record, team_id, revision: nil)
|
|
299
|
+
root = DurableCodec.decode_team_root(record)
|
|
300
|
+
unless root.team_id == team_id.to_s
|
|
301
|
+
raise Phronomy::Persistence::SerializationError,
|
|
302
|
+
"backend returned Team root for #{root.team_id.inspect}; expected #{team_id.to_s.inspect}"
|
|
303
|
+
end
|
|
304
|
+
if revision && root.team_revision != revision
|
|
305
|
+
raise Phronomy::Persistence::SerializationError,
|
|
306
|
+
"backend returned Team revision #{root.team_revision}; expected #{revision}"
|
|
307
|
+
end
|
|
308
|
+
root
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class TeamExecutions
|
|
313
|
+
def initialize(backend_repository)
|
|
314
|
+
@backend_repository = backend_repository
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def create_active(execution)
|
|
318
|
+
unless execution.active?
|
|
319
|
+
raise Phronomy::Persistence::SerializationError,
|
|
320
|
+
"create_active requires an active TeamExecution"
|
|
321
|
+
end
|
|
322
|
+
record = DurableCodec.encode_team_execution(execution)
|
|
323
|
+
stored = @backend_repository.create_active(
|
|
324
|
+
team_execution_id: execution.team_execution_id.to_s,
|
|
325
|
+
team_id: execution.team_id.to_s,
|
|
326
|
+
execution_revision: Integer(execution.execution_revision),
|
|
327
|
+
record: record
|
|
328
|
+
)
|
|
329
|
+
decode_for_execution(
|
|
330
|
+
stored,
|
|
331
|
+
execution.team_execution_id,
|
|
332
|
+
team_id: execution.team_id,
|
|
333
|
+
revision: execution.execution_revision,
|
|
334
|
+
active: true
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def load(team_execution_id)
|
|
339
|
+
decode_for_execution(@backend_repository.load(team_execution_id.to_s), team_execution_id)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def save(team_execution_id, expected_revision:, execution:)
|
|
343
|
+
expected = Integer(expected_revision)
|
|
344
|
+
next_revision = Integer(execution.execution_revision)
|
|
345
|
+
unless next_revision == expected + 1
|
|
346
|
+
raise Phronomy::Persistence::ConflictError,
|
|
347
|
+
"execution save must advance revision exactly once: " \
|
|
348
|
+
"expected #{expected + 1}, got #{next_revision}"
|
|
349
|
+
end
|
|
350
|
+
unless execution.team_execution_id.to_s == team_execution_id.to_s
|
|
351
|
+
raise Phronomy::Persistence::SerializationError,
|
|
352
|
+
"Execution identity mismatch: #{execution.team_execution_id} != #{team_execution_id}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
record = DurableCodec.encode_team_execution(execution)
|
|
356
|
+
stored = @backend_repository.save(
|
|
357
|
+
team_execution_id.to_s,
|
|
358
|
+
expected_revision: expected,
|
|
359
|
+
next_revision: next_revision,
|
|
360
|
+
team_id: execution.team_id.to_s,
|
|
361
|
+
active: execution.active?,
|
|
362
|
+
record: record
|
|
363
|
+
)
|
|
364
|
+
decode_for_execution(
|
|
365
|
+
stored,
|
|
366
|
+
team_execution_id,
|
|
367
|
+
team_id: execution.team_id,
|
|
368
|
+
revision: next_revision,
|
|
369
|
+
active: execution.active?
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def list_active(team_id)
|
|
374
|
+
Array(@backend_repository.list_active(team_id.to_s)).map do |record|
|
|
375
|
+
decode_for_execution(record, nil, team_id: team_id, active: true)
|
|
376
|
+
end.freeze
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def list(team_id, after: nil, limit: 100)
|
|
380
|
+
raise ArgumentError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
|
|
381
|
+
Array(@backend_repository.list(team_id.to_s, after: after&.to_s, limit: limit)).map do |record|
|
|
382
|
+
decode_for_execution(record, nil, team_id: team_id)
|
|
383
|
+
end.freeze
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def delete(team_execution_id)
|
|
387
|
+
@backend_repository.delete(team_execution_id.to_s)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def delete_for_team(team_id)
|
|
391
|
+
@backend_repository.delete_for_team(team_id.to_s)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def assert_idle!(team_id)
|
|
395
|
+
@backend_repository.assert_idle!(team_id.to_s)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
|
|
400
|
+
def decode_for_execution(record, team_execution_id, team_id: nil, revision: nil, active: nil)
|
|
401
|
+
execution = DurableCodec.decode_team_execution(record)
|
|
402
|
+
if team_execution_id && execution.team_execution_id != team_execution_id.to_s
|
|
403
|
+
raise Phronomy::Persistence::SerializationError,
|
|
404
|
+
"backend returned Execution #{execution.team_execution_id.inspect}; expected #{team_execution_id.to_s.inspect}"
|
|
405
|
+
end
|
|
406
|
+
if team_id && execution.team_id != team_id.to_s
|
|
407
|
+
raise Phronomy::Persistence::SerializationError,
|
|
408
|
+
"backend returned Execution for Agent #{execution.team_id.inspect}; expected #{team_id.to_s.inspect}"
|
|
409
|
+
end
|
|
410
|
+
if revision && execution.execution_revision != revision
|
|
411
|
+
raise Phronomy::Persistence::SerializationError,
|
|
412
|
+
"backend returned Execution revision #{execution.execution_revision}; expected #{revision}"
|
|
413
|
+
end
|
|
414
|
+
if !active.nil? && execution.active? != active
|
|
415
|
+
raise Phronomy::Persistence::SerializationError,
|
|
416
|
+
"backend returned Execution active=#{execution.active?}; expected #{active}"
|
|
417
|
+
end
|
|
418
|
+
execution
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
class HandoffStates
|
|
423
|
+
def initialize(backend_repository) = @backend_repository = backend_repository
|
|
424
|
+
|
|
425
|
+
def load(main_agent_id)
|
|
426
|
+
record = @backend_repository.load(main_agent_id.to_s)
|
|
427
|
+
record && decode(record, main_agent_id)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def save(main_agent_id, expected_revision:, state:)
|
|
431
|
+
expected = expected_revision.nil? ? nil : Integer(expected_revision)
|
|
432
|
+
next_revision = expected.nil? ? 1 : expected + 1
|
|
433
|
+
unless state.main_agent_id == main_agent_id.to_s && state.handoff_revision == next_revision
|
|
434
|
+
raise Phronomy::Persistence::SerializationError, "Handoff identity/revision mismatch"
|
|
435
|
+
end
|
|
436
|
+
record = @backend_repository.save(main_agent_id.to_s,
|
|
437
|
+
expected_revision: expected, next_revision: next_revision,
|
|
438
|
+
active_agent_id: state.active_agent_id, record: DurableCodec.encode_handoff_state(state))
|
|
439
|
+
decode(record, main_agent_id, revision: next_revision)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def delete(main_agent_id, expected_revision:)
|
|
443
|
+
@backend_repository.delete(main_agent_id.to_s, expected_revision: Integer(expected_revision))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
private
|
|
447
|
+
|
|
448
|
+
def decode(record, main_agent_id, revision: nil)
|
|
449
|
+
state = DurableCodec.decode_handoff_state(record)
|
|
450
|
+
identity_matches = state.main_agent_id == main_agent_id.to_s
|
|
451
|
+
revision_matches = revision.nil? || state.handoff_revision == revision
|
|
452
|
+
unless identity_matches && revision_matches
|
|
453
|
+
raise Phronomy::Persistence::SerializationError, "Backend returned another Handoff identity/revision"
|
|
454
|
+
end
|
|
455
|
+
state
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
243
459
|
class WorkflowStates
|
|
244
460
|
def initialize(backend_repository)
|
|
245
461
|
@backend_repository = backend_repository
|
|
@@ -292,14 +508,17 @@ module Phronomy
|
|
|
292
508
|
# repositories. Backend implementations can use this through
|
|
293
509
|
# Persistence#build_transaction_view without duplicating facade logic.
|
|
294
510
|
class View
|
|
295
|
-
attr_reader :contents, :agents, :journals, :executions, :workflow_states
|
|
511
|
+
attr_reader :contents, :agents, :journals, :executions, :workflow_states, :handoff_states, :teams, :team_executions
|
|
296
512
|
|
|
297
|
-
def initialize(contents:, agents:, journals:, executions:, workflow_states:, watermark:)
|
|
513
|
+
def initialize(contents:, agents:, journals:, executions:, workflow_states:, handoff_states:, teams:, team_executions:, watermark:)
|
|
298
514
|
@contents = contents
|
|
299
515
|
@agents = Agents.new(agents)
|
|
300
516
|
@journals = Journals.new(journals)
|
|
301
517
|
@executions = Executions.new(executions)
|
|
302
518
|
@workflow_states = WorkflowStates.new(workflow_states)
|
|
519
|
+
@handoff_states = HandoffStates.new(handoff_states)
|
|
520
|
+
@teams = Teams.new(teams)
|
|
521
|
+
@team_executions = TeamExecutions.new(team_executions)
|
|
303
522
|
@watermark = watermark
|
|
304
523
|
end
|
|
305
524
|
|