ace-assign 0.70.0 → 0.73.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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/config.yml +0 -13
  6. data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
  7. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
  8. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
  9. data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
  10. data/CHANGELOG.md +3 -2099
  11. data/README.md +58 -31
  12. data/docs/exit-codes.md +36 -44
  13. data/docs/getting-started.md +9 -4
  14. data/docs/usage.md +129 -522
  15. data/handbook/skills/as-assign-drive/SKILL.md +1 -1
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -118
  17. data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
  18. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
  19. data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
  20. data/lib/ace/assign/cli/commands/create.rb +1 -8
  21. data/lib/ace/assign/cli/commands/fail.rb +0 -6
  22. data/lib/ace/assign/cli/commands/finish.rb +2 -23
  23. data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
  24. data/lib/ace/assign/cli/commands/select.rb +0 -1
  25. data/lib/ace/assign/cli/commands/start.rb +0 -2
  26. data/lib/ace/assign/cli/commands/status.rb +1 -31
  27. data/lib/ace/assign/cli/commands/step.rb +1 -3
  28. data/lib/ace/assign/cli.rb +1 -85
  29. data/lib/ace/assign/errors.rb +8 -80
  30. data/lib/ace/assign/models/assignment.rb +5 -28
  31. data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
  32. data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
  36. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  37. data/lib/ace/assign/version.rb +1 -1
  38. data/lib/ace/assign.rb +0 -6
  39. metadata +4 -161
  40. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  41. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  42. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  43. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  44. data/docs/protected-authority.md +0 -282
  45. data/exe/ace-scope-ready +0 -55
  46. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  47. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  48. data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
  49. data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
  50. data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
  51. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  52. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  53. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  54. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  55. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  56. data/lib/ace/assign/authority/candidate_transfer.rb +0 -327
  57. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  58. data/lib/ace/assign/authority/client.rb +0 -257
  59. data/lib/ace/assign/authority/deployment.rb +0 -627
  60. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  61. data/lib/ace/assign/authority/endcap.rb +0 -455
  62. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  63. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  64. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  65. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  66. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  67. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  68. data/lib/ace/assign/authority/endcap_publication.rb +0 -207
  69. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  70. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  71. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  72. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  73. data/lib/ace/assign/authority/endcap_services.rb +0 -598
  74. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  75. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  76. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  77. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  78. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  79. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  80. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  81. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  82. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  83. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  84. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  85. data/lib/ace/assign/authority/launch_review.rb +0 -91
  86. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  87. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  88. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  89. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  90. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  91. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  92. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  93. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  94. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  95. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  96. data/lib/ace/assign/authority/ordinary_completion.rb +0 -94
  97. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  98. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -191
  99. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  100. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  101. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  102. data/lib/ace/assign/authority/ordinary_results.rb +0 -196
  103. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  104. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  105. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  106. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  107. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  108. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  109. data/lib/ace/assign/authority/prepared_work.rb +0 -304
  110. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  111. data/lib/ace/assign/authority/private_directory.rb +0 -33
  112. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  113. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  114. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  115. data/lib/ace/assign/authority/router.rb +0 -81
  116. data/lib/ace/assign/authority/server.rb +0 -131
  117. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  118. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  119. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  120. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  121. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  122. data/lib/ace/assign/authority/socket_server.rb +0 -255
  123. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  124. data/lib/ace/assign/authority/transfer_codec.rb +0 -187
  125. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  126. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  127. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  128. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  129. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  130. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  131. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  132. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  133. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  134. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  135. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  136. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  137. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  138. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  139. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  140. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  141. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  142. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  143. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  144. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  145. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  146. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  147. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  148. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  149. data/lib/ace/assign/models/attempt.rb +0 -178
  150. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  151. data/lib/ace/assign/models/evidence_event.rb +0 -86
  152. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  153. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  154. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  155. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  156. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -153
  157. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  158. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  159. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  160. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1912
  161. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  162. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  163. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  164. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  165. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  166. data/lib/ace/assign/molecules/journal_mutation.rb +0 -273
  167. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  168. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  169. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  170. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  171. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  172. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  173. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  174. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  175. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -314
  176. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  177. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1337
  178. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  179. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  180. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  181. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,127 +0,0 @@
1
- # frozen_string_literal: true
2
- require_relative "canonical_attempt_state"
3
-
4
- require "json"
5
-
6
- module Ace
7
- module Assign
8
- module Molecules
9
- # Conservative classification and resolution of interrupted attempts.
10
- #
11
- # Classification of a `running` attempt found after a lost session:
12
- # - no recorded process start -> `uncertain` (launch may be unbound)
13
- # - recorded process still verifiably live -> keep `running`
14
- # - otherwise -> `uncertain` (the operation may have completed without
15
- # a durable receipt)
16
- #
17
- # Reconciliation resolves an `uncertain` attempt only against verified
18
- # receipts whose producer runtime matches the recorded execution
19
- # boundary. It never replays operations: merge, publish, and deploy
20
- # effects are never retried automatically, and an uncertain attempt
21
- # without a receipt stays uncertain.
22
- class AttemptReconciler
23
- # The protected boundary supplies authenticated original scope facts
24
- # and exact kernel identities; never a runtime discovered by pane name.
25
- def self.protected_observation(kernel:, binding:, observer:, lineage:)
26
- return {"liveness" => "unknown"} if lineage.sealed?
27
- kernel.live!(binding.fetch("process_identity"))
28
- kernel.live!(binding.fetch("native_origin").fetch("server_identity"))
29
- observation = observer.observe(lineage)
30
- unless observation.is_a?(Hash) && observation["populated"].is_a?(Integer) && observation["populated"].positive?
31
- return {"liveness" => "unknown"}
32
- end
33
- {"liveness" => "live", "identity" => binding.fetch("process_identity"), "runtime_binding" => binding}
34
- rescue AttemptErrors::EvidenceUnavailable, Ace::Runtime::RuntimeUnavailableError
35
- {"liveness" => "unknown"}
36
- end
37
-
38
- # @param journal [EvidenceJournal, nil] Managed evidence journal
39
- # @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
40
- def initialize(journal: nil, verifier: nil, observer: nil, runtime_resolver: Ace::Runtime)
41
- @journal = journal
42
- @verifier = verifier
43
- @observer = observer || Ace::Runtime::Molecules::ProcessIdentity.new
44
- @runtime_resolver = runtime_resolver
45
- end
46
-
47
- # Classify a running attempt after an interruption.
48
- #
49
- # @param attempt [Models::Attempt] Running attempt
50
- # @return [Symbol] :stopped, :live, or :uncertain
51
- def classify(attempt)
52
- events = events_for(attempt)
53
- if events.any? { |event| event["type"] == "attempt_stopped" } && CanonicalAttemptState.derive(events) == "stopped"
54
- return :stopped
55
- end
56
- return :live if process_live?(attempt)
57
- :uncertain
58
- end
59
-
60
- # @param attempt [Models::Attempt] Attempt to inspect
61
- # @return [Boolean] True when a process start was recorded
62
- def process_started?(attempt)
63
- !start_event(attempt).nil?
64
- end
65
-
66
- # @param attempt [Models::Attempt] Attempt to inspect
67
- # @return [Boolean] True when the recorded process is verifiably alive
68
- def process_live?(attempt)
69
- observation(attempt)["liveness"] == "live"
70
- end
71
-
72
- def observation(attempt)
73
- payload = start_event(attempt)&.dig("payload")
74
- observation = @observer.observe(payload&.dig("process_identity"))
75
- binding = payload&.dig("runtime_binding")
76
- return observation unless binding && observation["liveness"] == "live"
77
-
78
- live = @runtime_resolver.resolve(binding["runtime"]).process_binding(
79
- pane: binding["pane"], caller_pid: binding.dig("process_identity", "pid"))
80
- return observation if binding == live
81
-
82
- observation.merge("liveness" => "unknown", "reason" => "native process/session binding changed")
83
- rescue Ace::Runtime::Error
84
- {"liveness" => "unknown", "observed_at" => Time.now.utc.iso8601,
85
- "reason" => "native process/session observation unavailable"}
86
- end
87
-
88
- def events(attempt)
89
- events_for(attempt)
90
- end
91
-
92
- # Runtime recorded at the attempt's process start — the boundary any
93
- # reconciling receipt must attribute to.
94
- #
95
- # @param attempt [Models::Attempt] Attempt to inspect
96
- # @return [String, nil] Recorded runtime identity
97
- def recorded_runtime(attempt)
98
- start_event(attempt)&.dig("payload", "runtime")
99
- end
100
-
101
- def recorded_binding(attempt)
102
- binding = start_event(attempt)&.dig("payload", "runtime_binding")
103
- JSON.parse(JSON.generate(binding)) if binding.is_a?(Hash)
104
- end
105
-
106
- private
107
-
108
- def start_event(attempt)
109
- events_for(attempt).reverse.find do |event|
110
- event["type"] == "process_start" && event["attempt_id"] == attempt.attempt_id
111
- end
112
- end
113
-
114
- def events_for(attempt)
115
- events = if attempt.managed?
116
- (@journal || Molecules::EvidenceJournal.new(repo_root: Dir.pwd))
117
- .read_events(attempt.binding.assignment_id)
118
- else
119
- attempt.events
120
- end
121
- # Only this attempt's events define its execution boundary.
122
- events.select { |event| event["attempt_id"] == attempt.attempt_id }
123
- end
124
- end
125
- end
126
- end
127
- end
@@ -1,201 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "json"
5
-
6
- module Ace
7
- module Assign
8
- module Molecules
9
- # Durable local attempt state: the single authority for attempt records
10
- # and active subtree ownership.
11
- #
12
- # Layout under the assignment cache directory (`.ace-local/assign` by
13
- # default, which remains disposable session/projection data):
14
- # - `<assignment-id>/attempts/records/<attempt-id>.json` — full attempt
15
- # record, written atomically (temp file + rename).
16
- # - `<assignment-id>/attempts/active/<ownership-key>.json` — pointer to
17
- # the one active attempt owning an assignment + normalized subtree.
18
- # - `<assignment-id>/attempts/.lock` — flock serialization for
19
- # claim/release and record transitions.
20
- #
21
- # Only one active attempt owns a subtree; claim replaces the pointer
22
- # only for the identical binding, and release clears it only when it
23
- # still references the given attempt.
24
- class AttemptStore
25
- # @param cache_base [String] Base cache directory
26
- def initialize(cache_base: nil)
27
- @cache_base = cache_base || Ace::Assign.cache_dir
28
- end
29
-
30
- # @param assignment_id [String] Assignment ID
31
- # @return [String] Attempts directory for the assignment
32
- def attempts_dir(assignment_id)
33
- File.join(@cache_base, assignment_id, "attempts")
34
- end
35
-
36
- # Serialize a block under the assignment's exclusive attempt lock.
37
- #
38
- # @param assignment_id [String] Assignment ID
39
- # @yield Block executed while holding the lock
40
- def with_lock(assignment_id)
41
- FileUtils.mkdir_p(attempts_dir(assignment_id))
42
- File.open(lock_path(assignment_id), File::RDWR | File::CREAT) do |lock|
43
- lock.flock(File::LOCK_EX)
44
- begin
45
- yield
46
- ensure
47
- lock.flock(File::LOCK_UN)
48
- end
49
- end
50
- end
51
-
52
- # Persist an attempt record atomically.
53
- #
54
- # @param attempt [Models::Attempt] Attempt to persist
55
- def save(attempt)
56
- FileUtils.mkdir_p(records_dir(attempt.binding.assignment_id))
57
- atomic_write(
58
- record_path(attempt.binding.assignment_id, attempt.attempt_id),
59
- JSON.pretty_generate(attempt.to_h)
60
- )
61
- end
62
-
63
- # Load an attempt record.
64
- #
65
- # @param assignment_id [String] Assignment ID
66
- # @param attempt_id [String] Attempt ID
67
- # @return [Models::Attempt, nil] Attempt or nil when absent/corrupt
68
- def load(assignment_id, attempt_id)
69
- path = record_path(assignment_id, attempt_id)
70
- return nil unless File.exist?(path)
71
-
72
- Models::Attempt.from_h(JSON.parse(File.read(path)))
73
- rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e
74
- warn "ace-assign: corrupt attempt record #{path}: #{e.message}" if Ace::Assign.debug?
75
- nil
76
- end
77
-
78
- # The active attempt owning an assignment + scope, if any.
79
- #
80
- # @param assignment_id [String] Assignment ID
81
- # @param scope [String] Step/subtree scope
82
- # @return [Models::Attempt, nil] Active attempt or nil
83
- def active(assignment_id, scope)
84
- pointer = active_path(assignment_id, scope)
85
- return nil unless File.exist?(pointer)
86
-
87
- entry = JSON.parse(File.read(pointer))
88
- attempt = load(assignment_id, entry["attempt_id"])
89
- return nil unless attempt&.active?
90
-
91
- attempt
92
- rescue JSON::ParserError, TypeError
93
- nil
94
- end
95
-
96
- # Claim subtree ownership for an attempt. Callers must hold
97
- # {with_lock} for read-modify-write atomicity.
98
- #
99
- # @param assignment_id [String] Assignment ID
100
- # @param scope [String] Canonical scope
101
- # @param attempt [Models::Attempt] Active attempt claiming the scope
102
- def claim(assignment_id, scope, attempt)
103
- FileUtils.mkdir_p(active_dir(assignment_id))
104
- atomic_write(
105
- active_path(assignment_id, scope),
106
- JSON.generate({"attempt_id" => attempt.attempt_id})
107
- )
108
- end
109
-
110
- # Release subtree ownership when it still references the attempt.
111
- #
112
- # @param assignment_id [String] Assignment ID
113
- # @param scope [String] Canonical scope
114
- # @param attempt_id [String] Attempt releasing ownership
115
- def release(assignment_id, scope, attempt_id)
116
- pointer = active_path(assignment_id, scope)
117
- return unless File.exist?(pointer)
118
-
119
- entry = JSON.parse(File.read(pointer))
120
- File.delete(pointer) if entry["attempt_id"] == attempt_id
121
- rescue JSON::ParserError, TypeError
122
- nil
123
- end
124
-
125
- # Atomically reserve an attempt ID across all assignments
126
- # (mkdir-based EEXIST check). Returns false when already taken.
127
- #
128
- # @param attempt_id [String] Candidate ID
129
- # @return [Boolean] True when newly reserved
130
- def reserve_id(attempt_id)
131
- registry = File.join(@cache_base, ".attempt-ids")
132
- FileUtils.mkdir_p(registry)
133
- Dir.mkdir(File.join(registry, attempt_id))
134
- true
135
- rescue Errno::EEXIST
136
- false
137
- end
138
-
139
- # Locate an attempt record by ID across all assignments (used by
140
- # reconcile, which receives only the attempt ID).
141
- #
142
- # @param attempt_id [String] Attempt ID
143
- # @return [Models::Attempt, nil] Attempt or nil
144
- def find(attempt_id)
145
- path = Dir.glob(File.join(@cache_base, "*", "attempts", "records", "#{attempt_id}.json")).first
146
- return nil unless path
147
-
148
- assignment_id = File.basename(File.dirname(File.dirname(File.dirname(path))))
149
- load(assignment_id, attempt_id)
150
- end
151
-
152
- # All attempt records for an assignment, newest first. Ties (records
153
- # serialized within the same second) break deterministically by
154
- # attempt ID.
155
- #
156
- # @param assignment_id [String] Assignment ID
157
- # @return [Array<Models::Attempt>] Persisted attempts
158
- def list(assignment_id)
159
- Dir.glob(File.join(records_dir(assignment_id), "*.json"))
160
- .map { |path| load(assignment_id, File.basename(path, ".json")) }
161
- .compact
162
- .sort_by { |attempt| [attempt.updated_at.utc, attempt.attempt_id] }
163
- .reverse
164
- end
165
-
166
- private
167
-
168
- def records_dir(assignment_id)
169
- File.join(attempts_dir(assignment_id), "records")
170
- end
171
-
172
- def active_dir(assignment_id)
173
- File.join(attempts_dir(assignment_id), "active")
174
- end
175
-
176
- def lock_path(assignment_id)
177
- File.join(attempts_dir(assignment_id), ".lock")
178
- end
179
-
180
- def record_path(assignment_id, attempt_id)
181
- File.join(records_dir(assignment_id), "#{attempt_id}.json")
182
- end
183
-
184
- def active_path(assignment_id, scope)
185
- File.join(active_dir(assignment_id), Atoms::AssignmentScope.ownership_key(assignment_id, scope))
186
- end
187
-
188
- # Atomic same-filesystem write: temp file plus rename.
189
- def atomic_write(path, content)
190
- dir = File.dirname(path)
191
- FileUtils.mkdir_p(dir)
192
- temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
193
- File.write(temp, content)
194
- File.rename(temp, path)
195
- ensure
196
- File.delete(temp) if temp && File.exist?(temp)
197
- end
198
- end
199
- end
200
- end
201
- end
@@ -1,151 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "execution_scope_lineage"
4
- require_relative "../atoms/attempt_state_machine"
5
-
6
- module Ace
7
- module Assign
8
- module Molecules
9
- # Canonical state projection, not live closure or settlement authority.
10
- # The stopped producer and terminal consumer must additionally obtain the
11
- # existing owners' authenticated original-prefix evidence and fresh proof.
12
- module CanonicalAttemptState
13
- STOPPED_FIELDS = %w[project_id mapping_id assignment_id attempt_id descriptor_sha256
14
- scope_binding_event_id seal_event_id closed_proof_event_id service_settlement_event_digests].freeze
15
- SELECTION_FIELDS = (STOPPED_FIELDS - %w[service_settlement_event_digests]).freeze
16
-
17
- def self.derive(events)
18
- return nil unless events.any? { |event| event["type"] == "intent" }
19
- state = "reserved"
20
- stopped = false
21
- events.each do |event|
22
- if stopped && %w[process_start receipt_accepted transition reconciliation attempt_stopped].include?(event["type"])
23
- unavailable!("Accepted stopped attempt cannot be resurrected or replaced")
24
- end
25
- case event["type"]
26
- when "process_start" then state = "running"
27
- when "receipt_accepted" then state = event.dig("payload", "receipt", "verdict") || state
28
- when "transition" then state = event.dig("payload", "to") || state
29
- when "reconciliation" then state = event.dig("payload", "resolution") || state
30
- when "attempt_stopped"
31
- verify_stopped_acceptance!(events, event, prior_state: state)
32
- state = "stopped"
33
- stopped = true
34
- end
35
- end
36
- state
37
- end
38
-
39
- def self.stopped_payload!(events:, selection:, service_evidence:, commit:)
40
- unless selection.is_a?(Hash) && selection.keys.sort == SELECTION_FIELDS.sort &&
41
- service_evidence.is_a?(Hash) && service_evidence.keys.sort == %w[commit services] &&
42
- commit.is_a?(String) && commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
43
- service_evidence["commit"] == commit
44
- unavailable!("Stopped plan evidence prefix differs")
45
- end
46
- services = service_evidence.fetch("services")
47
- unless services.is_a?(Array) &&
48
- services.all? { |item| item.is_a?(Hash) && item.keys.sort == %w[event_digest evidence_refs receipt_digest record_digest request_id state] }
49
- unavailable!("Stopped settlement projections are malformed")
50
- end
51
- service_ids = services.map { |item| item.fetch("request_id") }
52
- unless service_ids == service_ids.sort && service_ids.uniq == service_ids
53
- unavailable!("Stopped settlement projection is not ordered unique owner output")
54
- end
55
- services.each do |item|
56
- event = events.find { |entry| entry["digest"] == item.fetch("event_digest") && entry["type"] == "service_transition" }
57
- unless event && event.dig("payload", "request_id") == item.fetch("request_id") &&
58
- event.dig("payload", "state") == item.fetch("state") && %w[succeeded failed-settled].include?(item.fetch("state")) &&
59
- event.dig("payload", "record_digest") == item.fetch("record_digest") &&
60
- event.dig("payload", "receipt_digest") == item.fetch("receipt_digest")
61
- unavailable!("Stopped service projection differs from canonical terminal event")
62
- end
63
- end
64
- payload = selection.merge("service_settlement_event_digests" => services.map { |item| item.fetch("event_digest") }.sort)
65
- validate_stopped_payload!(events, payload)
66
- payload
67
- rescue KeyError, TypeError, ArgumentError, NoMethodError
68
- unavailable!("Stopped plan evidence is malformed")
69
- end
70
-
71
- def self.verify_stopped_acceptance!(events, event, prior_state:)
72
- Atoms::AttemptStateMachine.proof_stopped_transition!(prior_state)
73
- unless events.count { |entry| entry["type"] == "attempt_stopped" } == 1 && Models::EvidenceEvent.chain_valid?(events)
74
- unavailable!("Stopped canonical chain is ambiguous or corrupt")
75
- end
76
- payload = event.fetch("payload")
77
- prefix = events.take_while { |entry| entry["digest"] != event.fetch("digest") }
78
- validate_stopped_payload!(prefix, payload)
79
- acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
80
- data = acceptance&.dig("payload", "data")
81
- generation = acceptance && events.take_while { |entry| entry["digest"] != acceptance["digest"] }.count { |entry| entry["type"] == "authority_mutation" } + 1
82
- unless acceptance && data.is_a?(Hash) && data.keys.sort == %w[attempt_id generation proof_id required_action state] &&
83
- data["generation"].is_a?(Integer) && data["generation"] == generation &&
84
- acceptance.dig("payload", "operation") == "stop_attempt" &&
85
- acceptance.dig("payload", "assignment_id") == payload.fetch("assignment_id") &&
86
- acceptance.dig("payload", "attempt_id") == payload.fetch("attempt_id") &&
87
- acceptance.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
88
- acceptance.dig("payload", "data", "state") == "stopped" &&
89
- acceptance.dig("payload", "data", "proof_id") == payload.fetch("closed_proof_event_id") &&
90
- acceptance.dig("payload", "data", "required_action").nil?
91
- unavailable!("Stopped transition has no adjacent accepted owner mutation")
92
- end
93
- seal = prefix.find { |entry| entry["digest"] == payload.fetch("seal_event_id") }
94
- accepted_seal = seal && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == seal["digest"] &&
95
- %w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) }
96
- proof = prefix.find { |entry| entry["digest"] == payload.fetch("closed_proof_event_id") }
97
- accepted_proof = proof && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == proof["digest"] &&
98
- %w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) &&
99
- entry.dig("payload", "data", "proof_id") == proof["digest"] &&
100
- (entry.dig("payload", "operation") == "close_execution_scope" ?
101
- entry.dig("payload", "data", "state") == "closed_no_writers" :
102
- entry.dig("payload", "data").is_a?(Hash) && entry.dig("payload", "data").keys.sort == %w[attempt_id generation proof_id required_action state] &&
103
- entry.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
104
- entry.dig("payload", "data", "generation").is_a?(Integer) &&
105
- entry.dig("payload", "data", "generation") == prefix.take_while { |item| item["digest"] != entry["digest"] }.count { |item| item["type"] == "authority_mutation" } + 1 &&
106
- entry.dig("payload", "data", "state") == "uncertain" &&
107
- entry.dig("payload", "data", "required_action") == "reconcile_scope") }
108
- unless accepted_seal && (accepted_proof || event["previous_digest"] == proof&.fetch("digest"))
109
- unavailable!("Stopped canonical seal or proof has no accepted owner")
110
- end
111
- event
112
- rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::InvalidTransition
113
- unavailable!("Stopped canonical acceptance is malformed")
114
- end
115
-
116
- def self.validate_stopped_payload!(events, payload)
117
- unless payload.is_a?(Hash) && payload.keys.sort == STOPPED_FIELDS.sort &&
118
- %w[project_id mapping_id assignment_id attempt_id].all? { |key| payload[key].is_a?(String) && ExecutionScopeLineage::ID.match?(payload[key]) } &&
119
- %w[descriptor_sha256 scope_binding_event_id seal_event_id closed_proof_event_id].all? { |key| digest?(payload[key]) } &&
120
- %w[service_settlement_event_digests].all? { |key|
121
- values = payload[key]
122
- values.is_a?(Array) && values.all? { |value| digest?(value) } && values == values.sort && values.uniq == values }
123
- unavailable!("Stopped canonical payload is not closed")
124
- end
125
- lineage = ExecutionScopeLineage.new(events: events, **payload.slice("project_id", "mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
126
- provisioning = events.select { |event| event["type"] == "scope_provisioning" }
127
- unless provisioning.one? && provisioning.first.dig("payload", "descriptor_sha256") == payload.fetch("descriptor_sha256") &&
128
- lineage.native_event && lineage.child_event &&
129
- lineage.binding_event&.fetch("digest") == payload.fetch("scope_binding_event_id") &&
130
- lineage.seal_event&.fetch("digest") == payload.fetch("seal_event_id") &&
131
- lineage.proof_event&.fetch("digest") == payload.fetch("closed_proof_event_id")
132
- unavailable!("Stopped canonical original scope selectors differ")
133
- end
134
- payload.fetch("service_settlement_event_digests").each do |digest|
135
- unless events.any? { |event| event["digest"] == digest && event["type"] == "service_transition" &&
136
- %w[succeeded failed-settled].include?(event.dig("payload", "state")) }
137
- unavailable!("Stopped canonical service selector is not terminal evidence")
138
- end
139
- end
140
- payload
141
- rescue KeyError, TypeError, ArgumentError, NoMethodError
142
- unavailable!("Stopped canonical scope is malformed")
143
- end
144
-
145
- def self.digest?(value) = value.is_a?(String) && ExecutionScopeLineage::DIGEST.match?(value)
146
- def self.unavailable!(message) = raise AttemptErrors::EvidenceUnavailable, message
147
- private_class_method :digest?, :unavailable!
148
- end
149
- end
150
- end
151
- end
@@ -1,153 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "securerandom"
5
- require "time"
6
-
7
- module Ace
8
- module Assign
9
- module Molecules
10
- # One import/verification contract for protected review, service and
11
- # inbox evidence. Filesystem projections never supply accepted bytes.
12
- class CanonicalEvidence
13
- MAX_ARTIFACT_BYTES = 64 * 1024
14
- MAX_TOTAL_BYTES = 256 * 1024
15
- MAX_ARTIFACTS = 16
16
- ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
17
- ROLES = {"service" => "executor", "review" => "reviewer",
18
- "result" => "worker"}.freeze
19
- DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
20
- binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
21
- candidate_generation_or_claim_generation].freeze
22
-
23
- # Shared closed descriptor validation before clients accept any bytes.
24
- def self.valid_descriptor?(descriptor)
25
- return false unless descriptor.is_a?(Hash) && descriptor.keys.sort == DESCRIPTOR_FIELDS.sort &&
26
- descriptor["version"] == 1 && ROLES.key?(descriptor["kind"]) && ROLES[descriptor["kind"]] == descriptor["role"] &&
27
- %w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
28
- %w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
29
- descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
30
- descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0, MAX_ARTIFACT_BYTES) &&
31
- descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
32
- descriptor["admitted_at"].is_a?(String)
33
- Time.iso8601(descriptor.fetch("admitted_at"))
34
- true
35
- rescue ArgumentError, TypeError
36
- false
37
- end
38
-
39
- def initialize(journal:)
40
- @journal = journal
41
- end
42
-
43
- # Construct the immutable bytes/events for EvidenceJournal#mutate.
44
- # peer_uid comes from the accepted kernel-authenticated connection;
45
- # callers of the wire API cannot supply provenance or artifact IDs.
46
- def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
47
- request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
48
- role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
49
- ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
50
- unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
51
- admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
52
- peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
53
- artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= MAX_ARTIFACTS &&
54
- artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES } &&
55
- artifacts.sum(&:bytesize) <= MAX_TOTAL_BYTES
56
- raise ArgumentError, "invalid or oversized canonical evidence import"
57
- end
58
- events = []
59
- blobs = {}
60
- references = artifacts.map do |bytes|
61
- artifact_id = SecureRandom.hex(16)
62
- artifact_id += "-no-effect" if kind == "service" && binding["no_effect_challenge"]
63
- digest = Digest::SHA256.hexdigest(bytes.b)
64
- path = "evidence/imports/#{artifact_id}"
65
- descriptor = {"version" => 1, "artifact_id" => artifact_id, "kind" => kind,
66
- "project_id" => project_id, "assignment_id" => assignment_id,
67
- "attempt_id" => attempt_id, "peer_uid" => peer_uid, "role" => role,
68
- "binding_digest" => Atoms::EvidenceDigest.digest(binding), "sha256" => digest,
69
- "bytes" => bytes.bytesize, "admitted_at" => Time.now.utc.iso8601(9),
70
- "admitted_after_event_digest" => admitted_after_event_digest,
71
- "request_id_or_event_id" => request_id_or_event_id,
72
- "candidate_generation_or_claim_generation" => generation}
73
- events << {type: "evidence_import", payload: descriptor}
74
- blobs[path] = bytes.b
75
- {"ref" => path, "sha256" => digest}
76
- end
77
- {events: events, blobs: blobs, references: references}
78
- end
79
-
80
- def read(reference, kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
81
- request_id_or_event_id:, generation:, commit: @journal.ref_value)
82
- # One immutable commit supplies both provenance events and bytes.
83
- events = @journal.read_events(assignment_id, commit: commit)
84
- .select { |event| event["attempt_id"] == attempt_id }
85
- verified_bytes(reference, events: events, kind: kind, project_id: project_id,
86
- assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
87
- request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
88
- @journal.blob(path, commit: commit)
89
- end
90
- end
91
-
92
- # Only the journal writer supplies this transient pre-CAS view. It
93
- # validates the exact same provenance/chain/bytes as committed reads;
94
- # it does not accept a receipt or advance the canonical ref itself.
95
- def read_pending(reference, current_events:, pending_events:, blobs:, commit:,
96
- kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
97
- request_id_or_event_id:, generation:)
98
- events = current_events + pending_events
99
- unless events.all? { |event| event["attempt_id"] == attempt_id } && blobs.is_a?(Hash)
100
- unavailable!
101
- end
102
- verified_bytes(reference, events: events, kind: kind, project_id: project_id,
103
- assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
104
- request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
105
- blobs.key?(path) ? blobs.fetch(path) : @journal.blob(path, commit: commit)
106
- end
107
- end
108
-
109
- private
110
-
111
- def verified_bytes(reference, events:, kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
112
- request_id_or_event_id:, generation:)
113
- unless reference.is_a?(Hash) && reference.keys.sort == %w[ref sha256] &&
114
- reference["ref"].is_a?(String) && reference["ref"].match?(%r{\Aevidence/imports/[a-z0-9-]+\z})
115
- unavailable!
116
- end
117
- unavailable! unless Models::EvidenceEvent.chain_valid?(events)
118
- artifact_id = reference.fetch("ref").delete_prefix("evidence/imports/")
119
- imports = events.select do |event|
120
- event["type"] == "evidence_import" && event.dig("payload", "artifact_id") == artifact_id
121
- end
122
- unavailable! unless imports.length == 1
123
- event = imports.first
124
- descriptor = event.fetch("payload")
125
- expected = {"version" => 1, "artifact_id" => artifact_id, "kind" => kind,
126
- "project_id" => project_id, "assignment_id" => assignment_id,
127
- "attempt_id" => attempt_id, "peer_uid" => peer_uid, "role" => ROLES.fetch(kind),
128
- "binding_digest" => Atoms::EvidenceDigest.digest(binding),
129
- "sha256" => reference.fetch("sha256"), "request_id_or_event_id" => request_id_or_event_id,
130
- "candidate_generation_or_claim_generation" => generation}
131
- unavailable! unless self.class.valid_descriptor?(descriptor) &&
132
- expected.all? { |key, value| descriptor[key] == value }
133
- before = events.take_while { |entry| entry["digest"] != event["digest"] }
134
- unavailable! unless before.any? { |entry| entry["digest"] == descriptor["admitted_after_event_digest"] }
135
- if kind == "service" && binding["no_effect_challenge"]
136
- unavailable! unless descriptor["admitted_after_event_digest"] == binding.fetch("challenge_event_digest")
137
- end
138
- Time.iso8601(descriptor.fetch("admitted_at"))
139
- bytes = yield(reference.fetch("ref"))
140
- unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
141
- Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
142
- bytes
143
- rescue ArgumentError, KeyError, TypeError
144
- unavailable!
145
- end
146
-
147
- def unavailable!
148
- raise AttemptErrors::EvidenceUnavailable, "Canonical evidence import or binding is unverifiable"
149
- end
150
- end
151
- end
152
- end
153
- end