phronomy 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +2 -2
  3. data/CHANGELOG.md +15 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +1 -1
  6. data/docs/architecture/multi-agent-handoff.md +35 -40
  7. data/docs/architecture/persistence.md +19 -8
  8. data/docs/architecture.md +8 -1
  9. data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
  10. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  11. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  12. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  13. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  14. data/docs/decisions/README.md +5 -1
  15. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  16. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  17. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  18. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  19. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  20. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  21. data/docs/features.md +35 -1
  22. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  23. data/docs/persistence-backends.md +42 -3
  24. data/lib/phronomy/agent/agent_execution.rb +2 -2
  25. data/lib/phronomy/agent/agent_invocation.rb +1 -1
  26. data/lib/phronomy/agent/async_event_api.rb +18 -1
  27. data/lib/phronomy/agent/base.rb +28 -0
  28. data/lib/phronomy/agent/context_assembler.rb +1 -1
  29. data/lib/phronomy/agent/exact_execution.rb +153 -0
  30. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  31. data/lib/phronomy/agent/execution_coordinator.rb +484 -27
  32. data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
  33. data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
  34. data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
  35. data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
  36. data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
  37. data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
  38. data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
  39. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  40. data/lib/phronomy/agent/handoff_state.rb +43 -0
  41. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
  42. data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
  43. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
  44. data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
  45. data/lib/phronomy/agent/recovery_support.rb +15 -23
  46. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
  47. data/lib/phronomy/agent/tool_invocation.rb +4 -2
  48. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  49. data/lib/phronomy/engine/runtime.rb +16 -1
  50. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  51. data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  53. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  54. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  55. data/lib/phronomy/persistence/durable_codec.rb +60 -0
  56. data/lib/phronomy/persistence/in_memory.rb +264 -2
  57. data/lib/phronomy/persistence/repository_facades.rb +221 -2
  58. data/lib/phronomy/persistence.rb +95 -1
  59. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
  60. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  61. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  62. data/lib/phronomy/tools/agent.rb +1 -1
  63. data/lib/phronomy/version.rb +1 -1
  64. data/scripts/api_snapshot.rb +3 -3
  65. data/sig/phronomy/handoff.rbs +41 -0
  66. data/sig/phronomy/multi_agent.rbs +28 -32
  67. data/sig/phronomy/persistence.rbs +64 -3
  68. metadata +30 -12
  69. data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
  70. data/lib/phronomy/multi_agent/coordinator.rb +0 -154
  71. data/lib/phronomy/multi_agent/runner.rb +0 -98
@@ -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