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,1337 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "json"
5
- require "open3"
6
- require "pathname"
7
- require "ace/herdr/errors"
8
- require "ace/herdr/models/delivery_record"
9
- require "ace/herdr/molecules/delivery_record_store"
10
- require_relative "../molecules/canonical_attempt_state"
11
-
12
- module Ace
13
- module Assign
14
- module Organisms
15
- # Authoritative coordinator for assignment attempts.
16
- #
17
- # The coordinator is the only authority allowed to reserve and start
18
- # attempts, accept authoritative receipts, transition attempt state,
19
- # invalidate stale candidate evidence, and write accepted journal
20
- # events. It composes the attempt store (local ownership), the evidence
21
- # journal (managed Git-ref evidence), the identity resolver (trusted
22
- # boundary), and the receipt verifier (acceptance rules).
23
- #
24
- # Guarantee: only one active attempt owns an assignment + subtree;
25
- # `base_head` is captured exactly once at start; `candidate_head` is
26
- # pinned only when candidate-bound evidence is accepted and any change
27
- # to the candidate revision invalidates prior candidate authorization;
28
- # `journal_commit` is tracked separately from both.
29
- class AttemptCoordinator
30
- SERVICE_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
31
- input_digest operation outcome project_id request_id target transport].freeze
32
- SERVICE_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
33
- input_digest target candidate_head executor_uid transport].freeze
34
- # @param cache_base [String, nil] Assignment cache base
35
- # @param repo_root [String, nil] Candidate repository root (default: project root)
36
- # @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
37
- # @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
38
- # @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
39
- def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
40
- lifecycle_exclusion: nil, recorded_binding_observer: nil)
41
- unless recorded_binding_observer.nil? || recorded_binding_observer.respond_to?(:call)
42
- raise ArgumentError, "recorded native binding observer must be callable"
43
- end
44
- @manager = Molecules::AssignmentManager.new(cache_base: cache_base)
45
- @store = @manager.attempt_store
46
- @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
47
- @journal = journal
48
- @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
49
- @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
50
- @lifecycle_exclusion = lifecycle_exclusion
51
- @recorded_binding_observer = recorded_binding_observer
52
- end
53
-
54
- attr_reader :store
55
-
56
- # Pure producer plan. The protected lifecycle authenticates the original
57
- # proof and both exhaustive owner projections anew at each CAS prefix.
58
- # No caller receipt, cache state, native wait or store mutation enters it.
59
- def self.stopped_transition_plan(events:, selection:, service_evidence:, commit:)
60
- Atoms::AttemptStateMachine.proof_stopped_transition!(Molecules::CanonicalAttemptState.derive(events))
61
- payload = Molecules::CanonicalAttemptState.stopped_payload!(events: events, selection: selection,
62
- service_evidence: service_evidence, commit: commit)
63
- {events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
64
- end
65
-
66
- # The source-selected result owner authenticates imported bytes, candidate,
67
- # closure and independent settlements at the same CAS prefix.
68
- def self.finished_transition_plan(events:, receipt:, authority:)
69
- raise ArgumentError, "unknown result authority" unless %i[ordinary protected].include?(authority)
70
- state = Molecules::CanonicalAttemptState.derive(events)
71
- unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
72
- raise AttemptErrors::Conflict, "Result finish requires a running attempt"
73
- end
74
- Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
75
- {events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
76
- {type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "#{authority}_result_accepted"}}], blobs: {}}
77
- end
78
-
79
- def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
80
- return ["restart-required", "terminal_attempt"] if %w[succeeded failed stopped].include?(state)
81
- reason = if effects_pending
82
- "unresolved_effect"
83
- elsif state == "uncertain"
84
- "attempt_uncertain"
85
- elsif !bound
86
- "launch_unbound"
87
- elsif !checkpoint
88
- "checkpoint_unavailable"
89
- elsif !live
90
- "scope_unverifiable"
91
- end
92
- reason ? ["reconcile-required", reason] : ["adopt", "live_owner"]
93
- end
94
-
95
- def self.protected_recovery_plan(events:, decision:, reason:, observation:)
96
- state = Molecules::CanonicalAttemptState.derive(events)
97
- if %w[succeeded failed stopped].include?(state)
98
- raise AttemptErrors::Conflict, "Terminal recovery is read-only"
99
- end
100
- planned = [{type: "recovery_observation", payload: {"decision" => decision,
101
- "recovery_reason" => reason, "observation" => observation}}]
102
- if state == "running" && decision != "adopt"
103
- Atoms::AttemptStateMachine.transition!(state, "uncertain")
104
- planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
105
- state = "uncertain"
106
- end
107
- {events: planned, blobs: {}, state: state}
108
- end
109
-
110
- # Read-only exact native reverse binding from accepted history. A PID
111
- # supplied by a trusted boundary must be kernel-derived; no command
112
- # flag, environment address or local cache grants this authority.
113
- # Missing/dead/changed owners remain a classified refusal.
114
- def runtime_binding(attempt_id:, caller_pid: nil)
115
- attempt = recover_managed_attempt(nil, attempt_id)
116
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found in accepted history" unless attempt
117
- unless attempt.managed? && attempt.active?
118
- raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
119
- end
120
- binding = reconciler.recorded_binding(attempt)
121
- unless binding && %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
122
- raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
123
- end
124
- unless caller_pid.nil? || (caller_pid.is_a?(Integer) && caller_pid.positive?)
125
- raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
126
- end
127
- observe_runtime_binding!(attempt, binding, caller_pid)
128
- latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
129
- unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
130
- raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
131
- end
132
- binding
133
- rescue Ace::Runtime::Error => e
134
- raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
135
- end
136
-
137
- # An installed consumer selects its own bounded native reader. It must
138
- # never fall back to the service process's ambient runtime on failure.
139
- def observe_runtime_binding!(attempt, binding, caller_pid)
140
- if @recorded_binding_observer
141
- actual = @recorded_binding_observer.call(binding: binding, caller_pid: caller_pid)
142
- unless actual == binding
143
- raise AttemptErrors::UnauthorizedIdentity, "Installed native owner observation differs"
144
- end
145
- return
146
- end
147
- observation = reconciler.observation(attempt)
148
- unless observation["liveness"] == "live" && binding["process_identity"] == observation["identity"]
149
- raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
150
- end
151
- return if caller_pid.nil?
152
- caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
153
- unless caller == binding
154
- raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
155
- end
156
- end
157
- private :observe_runtime_binding!
158
-
159
- # Claim an external service effect before dispatch. Only a managed,
160
- # active attempt for the exact project and candidate may own it. The
161
- # guard re-runs inside the journal lock per CAS attempt, so a
162
- # terminal event landing between validation and commit still blocks
163
- # the claim.
164
- def claim_service_request(binding)
165
- attempt = service_attempt(binding)
166
- head = candidate_head!
167
- valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
168
- unless valid_head
169
- raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
170
- end
171
- require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
172
- # Dispatching claims record uncertain directly: the effect may
173
- # happen as soon as the claim lands, and a crash before the
174
- # executor response must leave uncertainty, not a stranded
175
- # accepted state that a retry would return without dispatching.
176
- journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
177
- end
178
-
179
- def proposal_authorization(reference, binding)
180
- journal_for.proposal_authorize!(reference, binding)
181
- end
182
-
183
- # Rechecked against the authoritative ref for every locked claim
184
- # attempt: the attempt must still be journal-active and the exact
185
- # authorization must still be unconsumed.
186
- def claim_guard(binding)
187
- derived = journal_for.derived_attempts(binding.fetch("assignment_id"))
188
- .find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
189
- unless derived&.active?
190
- raise AttemptErrors::ReceiptRejected, "Attempt is no longer active in the authoritative journal"
191
- end
192
- journal_for.authorization_conflict(binding)
193
- end
194
-
195
- def service_attempt(binding)
196
- # An effect can never acquire authority from a requester cache, even
197
- # when the assignment definition is missing from this consumer.
198
- attempt = journal_for.derived_attempts(binding.fetch("assignment_id"))
199
- .find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
200
- raise AttemptErrors::NotFound, "Attempt not found" unless attempt
201
- unless attempt.managed? && attempt.active? &&
202
- attempt.binding.assignment_id == binding["assignment_id"] &&
203
- attempt.binding.project_id == binding["project_id"]
204
- raise AttemptErrors::ReceiptRejected, "Service request does not match an active managed attempt"
205
- end
206
- attempt
207
- end
208
-
209
- # The authoritative journal wins for managed assignments, and a
210
- # managed service claim REQUIRES the journal-derived attempt: a lost
211
- # or unavailable evidence ref fails closed instead of trusting a
212
- # local cache record that the journal cannot corroborate.
213
- def authoritative_attempt(binding)
214
- assignment_id = binding.fetch("assignment_id")
215
- assignment = @manager.load(assignment_id)
216
- if assignment&.managed?
217
- journal_for.derived_attempts(assignment_id)
218
- .find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
219
- else
220
- @store.find(binding.fetch("attempt_id")) ||
221
- recover_managed_attempt(assignment_id, binding.fetch("attempt_id"))
222
- end
223
- end
224
-
225
- # The exact-authority check behind with_verified_attempt: the
226
- # journal-derived managed attempt must be live (reserved/running —
227
- # uncertainty is unproven liveness and never grants external
228
- # authority) and bound to the exact assignment, project, and
229
- # requesting actor.
230
- def verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
231
- attempt = journal_for.derived_attempts(assignment_id)
232
- .find { |candidate| candidate.attempt_id == attempt_id }
233
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
234
-
235
- unless attempt.managed? && %w[reserved running].include?(attempt.state) &&
236
- attempt.binding.assignment_id == assignment_id &&
237
- attempt.binding.project_id == project_id
238
- raise AttemptErrors::ReceiptRejected,
239
- "Authority request does not match an active managed attempt"
240
- end
241
- unless attempt.binding.actor == requester
242
- raise AttemptErrors::UnauthorizedIdentity,
243
- "Requester #{requester.inspect} does not own attempt '#{attempt_id}'"
244
- end
245
- attempt
246
- end
247
-
248
- def reject_service_request(binding, reason:)
249
- service_attempt(binding)
250
- journal_for.reject_service_request(binding, reason: reason)
251
- end
252
-
253
- def service_request_status(request_id)
254
- journal_for.service_request(request_id)
255
- end
256
-
257
- def transition_service_request(request_id, state:, receipt: nil)
258
- if %w[succeeded failed].include?(state)
259
- request = journal_for.service_request(request_id)
260
- raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
261
- validate_service_receipt!(request, state, receipt)
262
- elsif receipt
263
- raise AttemptErrors::ReceiptRejected, "Non-terminal service transition cannot carry a receipt"
264
- end
265
- journal_for.transition_service_request(request_id, state: state, receipt: receipt, validated: true)
266
- end
267
-
268
- # Settle a failed effect as "no effect occurred". The settlement is
269
- # evidence-validated like a terminal receipt — the executor must
270
- # attest this request with outcome:failed and attributable evidence
271
- # — and the original failure event stays in the journal history.
272
- def reconcile_service_failure(request_id, receipt:)
273
- request = journal_for.service_request(request_id)
274
- raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
275
- unless %w[failed rejected uncertain].include?(request["state"]) &&
276
- (request["state"] != "rejected" || request["consumed"] != false)
277
- raise AttemptErrors::InvalidState,
278
- "Only dispatched (failed, uncertain, or rejected) service requests can be reconciled"
279
- end
280
- settle_no_effect(request_id, request, receipt)
281
- end
282
-
283
- # Settle an uncertain claim whose handler was never invoked (a
284
- # pre-dispatch refusal observed by the trusted local service). Same
285
- # evidence rules as a failure settlement: a distinct, executor-owned
286
- # no-effect attestation bound to this request.
287
- def reconcile_service_no_effect(request_id, receipt:)
288
- request = journal_for.service_request(request_id)
289
- raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
290
- unless request["state"] == "uncertain"
291
- raise AttemptErrors::InvalidState, "Only uncertain service requests can settle as no-effect"
292
- end
293
- settle_no_effect(request_id, request, receipt)
294
- end
295
-
296
- def settle_no_effect(request_id, request, receipt)
297
- # The settlement must be a distinct, later executor attestation:
298
- # replaying the already-recorded failure receipt proves nothing
299
- # about whether the effect took place.
300
- if request["receipt"] && Atoms::EvidenceDigest.digest(receipt) ==
301
- Atoms::EvidenceDigest.digest(request["receipt"])
302
- raise AttemptErrors::ReceiptRejected,
303
- "Service failure settlement requires a new attestation, not the recorded receipt"
304
- end
305
- validate_service_receipt!(request, "failed", receipt, require_no_effect: true)
306
- journal_for.transition_service_request(request_id, state: "failed-settled",
307
- receipt: receipt, validated: true)
308
- end
309
-
310
- # ---- ordinary fixed merge primitive (spec 8x3.t.xz9.4) -------------
311
- #
312
- # The shared creation/resolution/read-only-status primitive the
313
- # ordinary receiver composes. Every durable write and read for
314
- # ordinary fixed merge requests lives in the canonical evidence
315
- # journal — this API never creates a receiver-local record.
316
-
317
- # Admit one ordinary fixed merge request. The opaque reference is
318
- # created here and returned only through the durable record; callers
319
- # receive the canonical record with its journal commit, so an
320
- # acknowledgment is never issued before durability. An identical
321
- # resubmission replays the original record; any different field
322
- # under the same request ID, or a reused authorization, is a
323
- # conflict/refusal — never a second effect.
324
- def admit_fixed_merge_request(submission)
325
- record = stringified(submission).merge("state" => "pending",
326
- "reference" => Molecules::FixedEffectReference.create)
327
- # The guard raises the distinct error itself, so journal conflicts
328
- # never need message-string discrimination: replay/binding conflicts
329
- # stay Conflict, a consumed authorization is AuthorizationConflict.
330
- guard = lambda do
331
- conflict = journal_for.fixed_authorization_conflict(record)
332
- if conflict
333
- raise AttemptErrors::AuthorizationConflict,
334
- "Authorization reference is not currently valid"
335
- end
336
- nil
337
- end
338
- journal_for.admit_fixed_merge_request(record, guard: guard)
339
- end
340
-
341
- # Begin the fixed effect (pending -> uncertain). The single CAS
342
- # transition is the at-most-one-merge proof: a restarted receiver
343
- # refuses an already-started claim and must not dispatch again.
344
- def begin_fixed_merge_effect(request_id)
345
- journal_for.begin_fixed_merge_request(request_id)
346
- end
347
-
348
- # Settle the fixed effect with the exact terminal receipt. The
349
- # journal validates the closed receipt schema and its binding to the
350
- # admitted record before the transition lands.
351
- def settle_fixed_merge_effect(request_id, terminal_status:, receipt:, attestation: nil)
352
- journal_for.settle_fixed_merge_request(request_id, terminal_status: terminal_status,
353
- receipt: receipt, attestation: attestation)
354
- end
355
-
356
- # Read-only canonical record lookup by request ID: lets the producer
357
- # recognize an exact replay (and return the original acknowledgment)
358
- # or a conflicting reuse without any receiver-local state.
359
- def fixed_merge_record(request_id)
360
- journal_for.fixed_merge_request(request_id)
361
- end
362
-
363
- # Read-only listing of every canonical fixed merge record: lets the
364
- # receiver restart reconciliation find its own still-pending records
365
- # by binding. Read-only; performs no dispatch and owns no state.
366
- def fixed_merge_records
367
- journal_for.fixed_merge_request_records
368
- end
369
-
370
- # Read-only status: resolve the opaque reference against the
371
- # canonical record and verify every selector. Performs no dispatch,
372
- # retry, authorization refresh or selector repair. Returns the
373
- # frozen canonical record, or nil when the reference or any selector
374
- # does not match — the caller must answer a mismatch identically
375
- # without revealing whether another project holds the reference.
376
- def fixed_merge_status(reference:, request_id:, project_id:, assignment_id:, attempt_id:,
377
- queue_entry:, input_digest:)
378
- record = journal_for.fixed_merge_request(request_id)
379
- return nil unless record
380
- matches = reference == record["reference"] && input_digest == record["input_digest"] &&
381
- [project_id, assignment_id, attempt_id, queue_entry].zip(
382
- %w[project_id assignment_id attempt_id queue_entry].map { |key| record[key] }
383
- ).all? { |supplied, canonical| supplied == canonical }
384
- matches ? freeze_canonical(record) : nil
385
- end
386
-
387
- def freeze_canonical(record)
388
- JSON.parse(JSON.generate(record)).freeze
389
- end
390
- private :freeze_canonical
391
-
392
- def stringified(submission)
393
- unless submission.is_a?(Hash)
394
- raise ArgumentError, "fixed merge submission must be a mapping"
395
- end
396
- submission.each_with_object({}) do |(key, value), result|
397
- result[key.to_s] = value
398
- end
399
- end
400
- private :stringified
401
-
402
- def validate_service_receipt!(request, state, receipt, require_no_effect: false)
403
- unless receipt.is_a?(Hash) && receipt.keys.sort == SERVICE_RECEIPT_FIELDS.sort
404
- raise AttemptErrors::ReceiptRejected, "Service receipt has invalid fields"
405
- end
406
- SERVICE_BINDING_FIELDS.each do |key|
407
- unless receipt[key] == request[key]
408
- raise AttemptErrors::ReceiptRejected, "Service receipt does not match #{key}"
409
- end
410
- end
411
- unless receipt["outcome"] == state && receipt["executor_uid"].is_a?(Integer) &&
412
- receipt["executor_uid"] >= 0 && valid_service_evidence?(receipt["evidence"])
413
- raise AttemptErrors::ReceiptRejected, "Service receipt has invalid executor or evidence"
414
- end
415
- unless receipt["executor_uid"] == request["executor_uid"]
416
- raise AttemptErrors::ReceiptRejected, "Service receipt executor does not match the claimed executor"
417
- end
418
- # Local transport runs the executor in the submitting process, so
419
- # the submitter must be the configured executor identity itself.
420
- if request["transport"] == "local" && Process.uid != request["executor_uid"]
421
- raise AttemptErrors::ReceiptRejected, "Service receipt submitter is not the configured executor"
422
- end
423
- verify_service_evidence!(receipt["evidence"], request, state, require_no_effect: require_no_effect)
424
- end
425
-
426
- def valid_service_evidence?(evidence)
427
- evidence.is_a?(Array) && !evidence.empty? && evidence.all? do |item|
428
- item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
429
- item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
430
- item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
431
- end
432
- end
433
-
434
- # Evidence must live inside the candidate repository (symlinks
435
- # resolved), be owned by — and not writable by anyone but — the
436
- # claimed executor identity, postdate the claim, and carry a
437
- # structured attestation naming the claimed request, input digest,
438
- # and attested outcome. All checks read one open handle, so a swap
439
- # on a caller-writable path cannot mix files between checks.
440
- def verify_service_evidence!(evidence, request, state, require_no_effect: false)
441
- if @journal&.evidence_mode == :protected
442
- receipt = request.slice(*SERVICE_BINDING_FIELDS).merge("outcome" => state, "evidence" => evidence)
443
- @journal.validate_terminal_receipt!(request, require_no_effect ? "failed-settled" : state, receipt)
444
- return true
445
- end
446
- repo_root = File.realpath(@repo_root)
447
- claimed_at = parse_claimed_at(request)
448
- evidence.each do |item|
449
- ref = item["ref"]
450
- unless Pathname.new(ref).relative?
451
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence must be repository-relative: #{ref}"
452
- end
453
- path = File.expand_path(ref, repo_root)
454
- unless path.start_with?(repo_root + File::SEPARATOR)
455
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
456
- end
457
- begin
458
- file = File.open(path, "rb")
459
- rescue Errno::ENOENT
460
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence is unavailable: #{ref}"
461
- end
462
- begin
463
- real = File.realpath(path)
464
- unless real.start_with?(repo_root + File::SEPARATOR)
465
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
466
- end
467
- stat = file.stat
468
- # The open handle must still be the file at the validated
469
- # location: a swap between resolution and open fails here.
470
- opened = File.stat(real)
471
- unless opened.dev == stat.dev && opened.ino == stat.ino
472
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence changed during verification: #{ref}"
473
- end
474
- unless stat.uid == request["executor_uid"] && (stat.mode & 0o022).zero?
475
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence is not executor-owned: #{ref}"
476
- end
477
- unless claimed_at.nil? || stat.mtime >= claimed_at
478
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the claim: #{ref}"
479
- end
480
- failed_at = parse_failed_at(request)
481
- unless failed_at.nil? || stat.mtime >= failed_at
482
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the failure: #{ref}"
483
- end
484
- content = file.read
485
- unless attestation_line(content, request, state, require_no_effect: require_no_effect)
486
- raise AttemptErrors::ReceiptRejected,
487
- "Service receipt evidence does not bind the claimed request: #{ref}"
488
- end
489
- unless Digest::SHA256.hexdigest(content) == item["sha256"]
490
- raise AttemptErrors::ReceiptRejected, "Service receipt evidence digest mismatch: #{ref}"
491
- end
492
- ensure
493
- file.close
494
- end
495
- end
496
- end
497
-
498
- # The evidence artifact must carry a structured attestation line
499
- # naming the claimed request, input digest, and attested outcome
500
- # exactly: an unrelated executor-owned file cannot attest this
501
- # effect, prose cannot substitute for the attested outcome, and its
502
- # result cannot be recorded under another terminal state.
503
- def attestation_line(content, request, state, require_no_effect: false)
504
- attestation = /^ace-service-attestation request:#{Regexp.escape(request.fetch("request_id"))} \
505
- input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(\S+))?$/
506
- line = content.scan(attestation).first
507
- return false unless line
508
- return false if require_no_effect && line[2] != "true"
509
- %w[succeeded failed].include?(state) ? line.first == state : true
510
- end
511
-
512
- def parse_claimed_at(request)
513
- return nil if request["claimed_at"].nil?
514
- Time.iso8601(request["claimed_at"])
515
- rescue ArgumentError
516
- raise AttemptErrors::ReceiptRejected, "Service request has an invalid claim timestamp"
517
- end
518
-
519
- def parse_failed_at(request)
520
- return nil if request["failed_at"].nil?
521
- Time.iso8601(request["failed_at"])
522
- rescue ArgumentError
523
- raise AttemptErrors::ReceiptRejected, "Service request has an invalid failure timestamp"
524
- end
525
-
526
- # Read-only eligibility check for dry-run: applies the same review
527
- # evidence and candidate-head requirements as the claim without
528
- # reserving anything.
529
- def request_eligible!(binding)
530
- attempt = service_attempt(binding)
531
- head = candidate_head!
532
- valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
533
- unless valid_head
534
- raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
535
- end
536
- require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
537
- true
538
- end
539
-
540
- # Hold verified authority over one managed attempt for the duration
541
- # of a single external transition (the HITL consume/deliver
542
- # critical sections). The shared exclusion side is held across the
543
- # yielded transition, so a terminal commit (finish/reconcile hold
544
- # the exclusive side) or a prune cannot land between the liveness
545
- # check and the caller's commit: stale, ended or replaced attempts
546
- # cannot acquire new authority. Authority is attribution over
547
- # journal facts and the requesting identity — never a process id,
548
- # runtime marker, or caller-supplied name.
549
- #
550
- # @param assignment_id [String] Owning assignment ID
551
- # @param attempt_id [String] Attempt ID
552
- # @param project_id [String] Project the attempt must be bound to
553
- # @param requester [String] Authenticated requester (execution-
554
- # boundary identity of the caller, matched against binding actor)
555
- # @yield [attempt] the verified managed attempt
556
- # @raise [AttemptErrors::NotFound, AttemptErrors::ReceiptRejected,
557
- # AttemptErrors::UnauthorizedIdentity] on any authority doubt
558
- def with_verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
559
- assignment_id = assignment_id.to_s
560
- attempt_id = attempt_id.to_s
561
- lifecycle_exclusion.with_shared(lifecycle_exclusion.assignment_key(assignment_id)) do
562
- attempt = verified_attempt(
563
- assignment_id: assignment_id, attempt_id: attempt_id,
564
- project_id: project_id.to_s, requester: requester.to_s
565
- )
566
- yield attempt
567
- end
568
- end
569
-
570
- # Start a scoped attempt for an assignment.
571
- #
572
- # A repeated identical start returns the existing active attempt; a
573
- # conflicting or scope-overlapping binding raises
574
- # {AttemptErrors::Conflict} without ever launching a second writer.
575
- # Active attempts are resolved from the local store AND, for managed
576
- # assignments, from the authoritative journal, so a lost local record
577
- # cannot admit a competing writer.
578
- #
579
- # @param assignment_id [String] Assignment ID
580
- # @param step [String] Step/subtree scope
581
- # @param project_id [String] Project the attempt executes in
582
- # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
583
- # @return [Models::Attempt] Active attempt (reused, recovered, or newly reserved)
584
- def start(assignment_id:, step:, project_id:, identity: nil)
585
- assignment = @manager.load(assignment_id)
586
- raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
587
-
588
- scope = Atoms::AssignmentScope.canonicalize(step)
589
- project = project_id.to_s.strip
590
- raise Error, "attempt start requires --project ID" if project.empty?
591
-
592
- identity ||= @identity_resolver.resolve
593
- base_head = candidate_head!
594
-
595
- # Serialize writer registration against prune: prune holds the
596
- # exclusive exclusion from its final evidence reads through removal.
597
- # A worktree-backed task identity shares the same protocol (task
598
- # key first, then assignment key — the one consistent global
599
- # order), so a worktree prune and an attempt start cannot interleave.
600
- start_keys = []
601
- unless assignment.task_id.to_s.strip.empty?
602
- start_keys << lifecycle_exclusion.task_key(assignment.task_id)
603
- end
604
- start_keys << lifecycle_exclusion.assignment_key(assignment_id)
605
- lifecycle_exclusion.with_shared_multi(start_keys) do
606
- @store.with_lock(assignment_id) do
607
- journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
608
-
609
- existing = journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) } ||
610
- @store.active(assignment_id, scope)
611
- if existing
612
- if existing.state == "reserved"
613
- raise AttemptErrors::Conflict,
614
- "Unresolved reservation #{existing.attempt_id} owns #{assignment_id}@#{scope}; " \
615
- "protected launch reconciliation needs positive no-execution/no-surviving-writer proof before abort"
616
- end
617
- if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
618
- existing.binding.actor == identity.actor && existing.binding.role == identity.role
619
- return existing
620
- end
621
-
622
- raise AttemptErrors::Conflict,
623
- "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
624
- "(project #{existing.binding.project_id}); refusing to launch a second writer"
625
- end
626
-
627
- active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
628
- active_scopes.concat(journal_actives.map(&:binding))
629
- blocker = active_scopes.find { |binding| Atoms::AssignmentScope.overlap?(binding.scope, scope) }
630
- if blocker
631
- raise AttemptErrors::Conflict,
632
- "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
633
- "refusing overlapping writers"
634
- end
635
-
636
- attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
637
- events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
638
- attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
639
- if attempt.managed?
640
- commit = journal_for.append(
641
- assignment_id: assignment_id,
642
- attempt_id: attempt.attempt_id,
643
- events: events
644
- )
645
- attempt = attempt.with(journal_commit: commit)
646
- attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
647
- else
648
- attempt = attempt.with(events: events)
649
- end
650
-
651
- @store.save(attempt)
652
- @store.claim(assignment_id, scope, attempt)
653
- attempt
654
- end
655
- end
656
- end
657
-
658
- # Accept a receipt for an attempt and transition it.
659
- #
660
- # @param attempt_id [String] Attempt ID
661
- # @param receipt_path [String] Path to the receipt JSON file
662
- # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
663
- # @return [Models::Attempt] Updated attempt
664
- def finish(attempt_id:, receipt_path:, identity: nil)
665
- data = read_receipt_file(receipt_path)
666
- assignment_id = data["assignment_id"].to_s
667
-
668
- # Terminal commits hold the exclusive exclusion so an external
669
- # liveness reader (with_verified_attempt holds the shared side
670
- # across its whole transition) can never observe the attempt as
671
- # active after the terminal event landed.
672
- lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
673
- # Serialize load-validate-accept so concurrent finishes observe one
674
- # consistent state and cannot persist contradictory terminal outcomes.
675
- @store.with_lock(assignment_id) do
676
- attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
677
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
678
-
679
- derived = ensure_journal_consistent!(attempt) if attempt.managed?
680
- attempt = attempt.with(state: derived.state) if derived
681
- if attempt.state == "reserved"
682
- raise AttemptErrors::EvidenceUnavailable,
683
- "Reservation #{attempt_id} is unbound; protected launch reconciliation requires positive " \
684
- "no-execution/no-surviving-writer proof before abort"
685
- end
686
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
687
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
688
-
689
- identity ||= @identity_resolver.resolve
690
- live_head = candidate_head!
691
-
692
- attempt = invalidate_stale_candidate(attempt, live_head)
693
-
694
- receipt = @verifier.verify!(
695
- data,
696
- attempt: attempt,
697
- identity: identity,
698
- live_head: live_head,
699
- repo_root: @repo_root
700
- )
701
- if @verifier.external_effect?(receipt.operation)
702
- unless attempt.managed?
703
- raise AttemptErrors::InvalidState,
704
- "Taskless attempts cannot record external effects without managed evidence"
705
- end
706
-
707
- require_review_evidence(attempt, live_head)
708
- end
709
-
710
- accept(attempt, receipt, live_head)
711
- end
712
- end
713
- end
714
-
715
- # Active-attempt projection for status surfaces.
716
- #
717
- # @param assignment_id [String] Assignment ID
718
- # @return [Hash, nil] Projection fields or nil when no attempt exists
719
- def status(assignment_id)
720
- attempts = @store.list(assignment_id)
721
- attempt = attempts.find(&:active?) || attempts.first
722
- return nil unless attempt
723
-
724
- attempt.projection
725
- end
726
-
727
- # Reconstruct recovery from accepted history. Local caches and pane
728
- # titles never override journal authority. This method has no writes.
729
- def recovery_snapshot(assignment_id)
730
- assignment = @manager.load(assignment_id)
731
- managed = assignment&.managed? || journal_for.assignment_ids.include?(assignment_id)
732
- attempts = managed ? journal_for.derived_attempts(assignment_id) : @store.list(assignment_id)
733
- raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment || attempts.any?
734
-
735
- requests = managed ? journal_for.service_requests(assignment_id) : []
736
- unresolved = requests.reject do |request|
737
- %w[succeeded failed-settled].include?(request["state"]) ||
738
- request["state"] == "rejected" && request["consumed"] == false
739
- end
740
- queue = assignment && Molecules::QueueScanner.new.scan(assignment.steps_dir, assignment: assignment)
741
- inbox_records = recovery_inboxes(attempts)
742
- recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
743
- observation = reconciler.observation(attempt)
744
- blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
745
- unstarted = !reconciler.process_started?(attempt)
746
- decision, reason = if blocked || attempt.uncertain?
747
- ["reconcile-required", blocked ? "unresolved external effect" :
748
- "attempt remains uncertain"]
749
- elsif attempt.terminal?
750
- ["restart-required", "attempt ended; restart requires a new attributable attempt"]
751
- elsif unstarted
752
- ["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
753
- elsif !assignment || observation["liveness"] != "live"
754
- ["reconcile-required", (!assignment) ? "assignment checkpoint unavailable" : observation["reason"]]
755
- else
756
- ["adopt", "verified owner survives; preserve current attempt"]
757
- end
758
- receipt_history = reconciler.events(attempt).select { |event| event["type"] == "receipt_accepted" }
759
- .filter_map { |event| event.dig("payload", "receipt") }
760
- accepted_effects = receipt_history.select { |receipt| @verifier.external_effect?(receipt["operation"]) }
761
- .map { |receipt| receipt.slice("operation", "head", "digest", "verdict") }
762
- attempt.projection.merge("decision" => decision, "recovery_reason" => reason,
763
- "accepted_effects" => accepted_effects,
764
- "accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
765
- "liveness" => observation["liveness"], "last_verified_observation" =>
766
- ((observation["liveness"] == "live") ? observation : last_verified_observation(attempt)),
767
- "observation" => observation)
768
- end
769
- decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
770
- "reconcile-required"
771
- elsif recovered.any? { |item| item["decision"] == "adopt" }
772
- "adopt"
773
- else
774
- "restart-required"
775
- end
776
- {"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
777
- "latest_attempt_id" => recovered.last&.dig("attempt_id"),
778
- "decision" => decision, "liveness" => (decision == "adopt") ? "live" : "unknown",
779
- "last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
780
- .max_by { |item| item["observed_at"].to_s },
781
- "recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
782
- recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
783
- "goal" => assignment&.description || assignment&.name,
784
- "checkpoint" => queue && {"state" => queue.assignment_state.to_s,
785
- "active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
786
- "attempts" => recovered, "inbox_events" => inbox_records,
787
- "pending_hitl" => queue ? queue.steps.filter_map do |step|
788
- id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
789
- {"event_id" => id, "scope" => step.number, "rebind" => false} if id
790
- end : [], "unresolved_effects" => unresolved.map do |request|
791
- request.slice("request_id", "attempt_id", "operation", "state", "candidate_head")
792
- end}
793
- end
794
-
795
- def resume(assignment_id:, dry_run: false)
796
- return recovery_snapshot(assignment_id) if dry_run
797
-
798
- lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
799
- @store.with_lock(assignment_id) do
800
- snapshot = recovery_snapshot(assignment_id)
801
- snapshot.fetch("attempts").each do |projection|
802
- attempt = recover_managed_attempt(assignment_id, projection["attempt_id"]) ||
803
- @store.load(assignment_id, projection["attempt_id"])
804
- next unless attempt && !attempt.terminal?
805
-
806
- event = Models::EvidenceEvent.build(type: "recovery_observation", attempt_id: attempt.attempt_id,
807
- payload: projection.slice("decision", "recovery_reason", "observation"),
808
- previous_digest: last_event_digest(attempt))
809
- attempt = append_events(attempt, [event])
810
- if attempt.state == "running" && projection["decision"] != "adopt"
811
- target = (projection["decision"] == "restart-required") ? "stopped" : "uncertain"
812
- attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
813
- attempt = attempt.transition(target)
814
- @store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
815
- end
816
- @store.save(attempt)
817
- end
818
- recovery_snapshot(assignment_id)
819
- end
820
- end
821
- end
822
-
823
- # Inbox delivery is transport evidence, never business-effect proof.
824
- # Herdr owns signature, pinned key, generation and native identity
825
- # verification; this consumer records only its verified references.
826
-
827
- # Register a transport reference in the existing attempt journal so
828
- # a missing delivery record cannot be mistaken for no pending effect.
829
- def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
830
- identity ||= @identity_resolver.resolve
831
- attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
832
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
833
-
834
- lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
835
- @store.with_lock(attempt.binding.assignment_id) do
836
- attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
837
- @store.load(attempt.binding.assignment_id, attempt_id)
838
- unless attempt.active? && attempt.binding.actor == identity.actor
839
- raise AttemptErrors::UnauthorizedIdentity, "Inbox registration must belong to the active attempt owner"
840
- end
841
- record = inbox.status(event: event_id)
842
- unless record["attempt_id"] == attempt_id
843
- raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
844
- end
845
- payload = record.slice("event_id", "attempt_id", "payload_sha256")
846
- existing = reconciler.events(attempt).find do |event|
847
- event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
848
- end
849
- if existing && existing["payload"] != payload
850
- raise AttemptErrors::ReceiptRejected, "Inbox binding changed"
851
- end
852
- unless existing
853
- event = Models::EvidenceEvent.build(type: "inbox_binding", attempt_id: attempt_id,
854
- payload: payload, previous_digest: last_event_digest(attempt))
855
- @store.save(append_events(attempt, [event]))
856
- end
857
- payload
858
- end
859
- end
860
- end
861
-
862
- # Read accepted check evidence without transitions, locks, cache writes
863
- # or audit checkout creation. Managed history comes from the Git ref.
864
- def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
865
- unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
866
- raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
867
- end
868
- if !historical_head.nil? && (!historical_head.is_a?(String) ||
869
- !historical_head.match?(/\A[0-9a-f]{40,64}\z/))
870
- raise AttemptErrors::ReceiptRejected, "Historical evidence requires an exact recorded head"
871
- end
872
- unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
873
- receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
874
- raise AttemptErrors::ReceiptRejected, "Invalid attempt ID or receipt digest"
875
- end
876
- attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
877
- raise AttemptErrors::NotFound, "Attempt #{attempt_id} not found" unless attempt
878
- raise AttemptErrors::ReceiptRejected, "Execution evidence requires managed immutable journal history" unless attempt.managed?
879
- attempt = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
880
- raise AttemptErrors::ReceiptRejected, "No succeeded accepted attempt" unless attempt&.state == "succeeded"
881
- accepted = journal_for.accepted_receipts(attempt.binding.assignment_id)
882
- data = accepted.find { |r| r["attempt_id"] == attempt_id && r["digest"] == receipt_digest }
883
- raise AttemptErrors::ReceiptRejected, "Receipt was not accepted by coordinator" unless data
884
- if kind == "check" && (%w[review review-collect].include?(data["operation"]) || @verifier.external_effect?(data["operation"]))
885
- raise AttemptErrors::ReceiptRejected, "Review or external-effect operations cannot be check evidence"
886
- end
887
- expected_operation = case kind
888
- when "review-collection" then "review-collect"
889
- when "review-approval" then "review"
890
- else (check_name == "tests") ? "test" : check_name
891
- end
892
- unless data["campaign"].nil? && data["operation"] == expected_operation
893
- raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
894
- end
895
- required_check = (kind == "review-collection") ? "review-execution" : check_name
896
- has_required_check = Array(data["checks"]).any? { |check| check["name"] == required_check && check["verdict"] == "passed" }
897
- unless kind == "review-approval" || has_required_check
898
- raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
899
- end
900
- receipt = Models::ExecutionReceipt.from_h(data)
901
- unless receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
902
- raise AttemptErrors::ReceiptRejected, "Accepted receipt digest is corrupt"
903
- end
904
- evidence_head = historical_head || candidate_head!
905
- unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
906
- raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
907
- end
908
- @verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root,
909
- historical: !historical_head.nil?)
910
- {"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
911
- "historical" => !historical_head.nil?,
912
- "operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
913
- "artifacts" => data["artifacts"], "assignment_id" => data["assignment_id"],
914
- "project_id" => data["project_id"], "scope" => data["scope"],
915
- "evidence_git_ref" => attempt.binding.evidence_git_ref, "journal_commit" => attempt.journal_commit}
916
- end
917
-
918
- # Reconcile an interrupted attempt.
919
- #
920
- # Running attempts are classified conservatively (stopped before
921
- # process start, uncertain when an effect cannot be proven either
922
- # way, still running only for verifiably live processes). Uncertain
923
- # attempts resolve only against a verified receipt attributed to the
924
- # recorded execution boundary. Reconciliation never replays merge,
925
- # publish, or deploy effects.
926
- #
927
- # @param attempt_id [String] Attempt ID
928
- # @param receipt_path [String, nil] Receipt JSON for resolving uncertainty
929
- # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
930
- # @return [Models::Attempt] Updated attempt
931
- def reconcile(attempt_id:, receipt_path: nil, identity: nil)
932
- probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
933
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
934
-
935
- # Terminal commits hold the exclusive exclusion (see finish).
936
- lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(probe.binding.assignment_id)) do
937
- @store.with_lock(probe.binding.assignment_id) do
938
- attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
939
- recover_managed_attempt(probe.binding.assignment_id, attempt_id)
940
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
941
-
942
- derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
943
- # The journal is authoritative, including an unbound reservation
944
- # that a stale local running projection must not promote.
945
- attempt = attempt.with(state: derived.state) if derived
946
-
947
- if attempt.terminal?
948
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
949
- end
950
-
951
- if attempt.state == "reserved"
952
- raise AttemptErrors::EvidenceUnavailable,
953
- "Reservation #{attempt_id} requires protected launch reconciliation with positive no-execution/" \
954
- "no-surviving-writer proof before abort; missing process_start is not proof"
955
- end
956
- return classify_running(attempt) if attempt.state == "running"
957
-
958
- identity ||= @identity_resolver.resolve
959
- resolve_uncertain(attempt, receipt_path, identity)
960
- end
961
- end
962
- end
963
-
964
- private
965
-
966
- def last_verified_observation(attempt)
967
- event = reconciler.events(attempt).reverse.find do |candidate|
968
- candidate["type"] == "recovery_observation" && candidate.dig("payload", "observation", "liveness") == "live"
969
- end
970
- event&.dig("payload", "observation")
971
- end
972
-
973
- def recovery_inboxes(attempts)
974
- # Ordinary local recovery selects Herdr's configuration only here;
975
- # protected launch admission must not initialize that broad entry.
976
- require "ace/herdr"
977
- root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
978
- entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
979
- registrations = attempts.flat_map do |attempt|
980
- reconciler.events(attempt).select { |event| event["type"] == "inbox_binding" }
981
- .map { |event| [attempt, event["payload"]] }
982
- end
983
- records = registrations.map do |attempt, payload|
984
- record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
985
- attributed = record && record.inbox && record.event_id == payload["event_id"] &&
986
- record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
987
- record.answer_digest == payload["payload_sha256"]
988
- state = attributed ? record.state : "unknown"
989
- payload.slice("event_id", "attempt_id").merge("state" => state)
990
- rescue JSON::ParserError, ArgumentError, SystemCallError
991
- payload.slice("event_id", "attempt_id").merge("state" => "unknown")
992
- end
993
- entries.each do |entry|
994
- next if records.any? { |record| record["event_id"] == entry[:event_id] }
995
- record = entry[:record]
996
- next if record && (!record.inbox || !attempts.any? { |attempt| attempt.attempt_id == record.inbox["attempt_id"] })
997
- records << {"event_id" => entry[:event_id], "state" => "unknown"}
998
- end
999
- records
1000
- end
1001
-
1002
- # Attempt IDs must be unique per assignment AND globally: records,
1003
- # journal events, and reconciliation lookups key on them. Allocation
1004
- # runs under the assignment lock, avoids journal-known IDs, and
1005
- # claims the ID atomically in the cross-assignment registry.
1006
- def unique_attempt_id(assignment_id, taken_ids = [])
1007
- base = Ace::B36ts.now
1008
- suffix = 0
1009
- 100.times do
1010
- candidate = suffix.zero? ? base : "#{base}#{suffix.to_s(36)}"
1011
- suffix += 1
1012
- next if taken_ids.include?(candidate)
1013
- next unless @store.load(assignment_id, candidate).nil?
1014
- return candidate if @store.reserve_id(candidate)
1015
- end
1016
-
1017
- raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
1018
- end
1019
-
1020
- # Active attempts derived from the authoritative journal, used when
1021
- # the local record may be missing (lost or wiped cache). Derivation
1022
- # lives in the journal (owner of attempt-state interpretation).
1023
- def active_journal_attempts(assignment)
1024
- journal_for.active_attempts(assignment.id)
1025
- end
1026
-
1027
- # Recover a managed attempt from the journal when the local record is
1028
- # missing. With an assignment ID only that journal is consulted;
1029
- # without one, assignment IDs are discovered from the evidence ref
1030
- # itself, so a fully lost cache still reconciles.
1031
- def recover_managed_attempt(assignment_id, attempt_id)
1032
- if assignment_id
1033
- assignment = @manager.load(assignment_id)
1034
- # A surviving taskless record never consults the journal; a lost
1035
- # record falls through to journal evidence (only managed attempts
1036
- # are journaled).
1037
- return nil if assignment && !assignment.managed?
1038
-
1039
- return journal_for.derived_attempts(assignment_id)
1040
- .find { |candidate| candidate.attempt_id == attempt_id }
1041
- end
1042
-
1043
- journal_for.assignment_ids.each do |journal_assignment_id|
1044
- attempt = journal_for.derived_attempts(journal_assignment_id)
1045
- .find { |candidate| candidate.attempt_id == attempt_id }
1046
- return attempt if attempt
1047
- end
1048
-
1049
- nil
1050
- end
1051
-
1052
- # The journal is authoritative for managed attempts: a crash after a
1053
- # journaled terminal/uncertain event but before the local save must
1054
- # not admit contradictory transitions.
1055
- def ensure_journal_consistent!(attempt, allow_uncertain: false)
1056
- derived = journal_for.derived_attempts(attempt.binding.assignment_id)
1057
- .find { |candidate| candidate.attempt_id == attempt.attempt_id }
1058
- return if derived.nil?
1059
-
1060
- journal_state = derived.state
1061
- if %w[succeeded failed stopped].include?(journal_state)
1062
- raise AttemptErrors::InvalidState,
1063
- "Journal shows attempt #{attempt.attempt_id} is #{journal_state}; accepted history is immutable"
1064
- end
1065
- return derived if allow_uncertain || journal_state != "uncertain" || attempt.uncertain?
1066
-
1067
- raise AttemptErrors::InvalidState,
1068
- "Journal shows attempt #{attempt.attempt_id} is uncertain; reconcile before finishing"
1069
- end
1070
-
1071
- # After appending our start events, revalidate ownership: a
1072
- # coordinator with a separate cache directory may have claimed the
1073
- # same scope between our read and our append (journal CAS replay
1074
- # merges both). The loser records a stopped transition and refuses.
1075
- def yield_lost_ownership_race(attempt, assignment_id, scope)
1076
- rival = journal_for.active_attempts(assignment_id).find do |other|
1077
- other.attempt_id != attempt.attempt_id && Atoms::AssignmentScope.overlap?(other.binding.scope, scope)
1078
- end
1079
- return attempt if rival.nil?
1080
-
1081
- stopped = append_events(attempt, [transition_event(attempt, "stopped", "lost ownership race to #{rival.attempt_id}")])
1082
- stopped = stopped.transition("stopped")
1083
- @store.save(stopped)
1084
- raise AttemptErrors::Conflict,
1085
- "Active attempt #{rival.attempt_id} owns overlapping scope #{rival.binding.scope} on #{assignment_id}; " \
1086
- "our start #{attempt.attempt_id} was recorded stopped without executing"
1087
- end
1088
-
1089
- # Conservative classification of a running attempt after interruption.
1090
- def classify_running(attempt)
1091
- case reconciler.classify(attempt)
1092
- when :live
1093
- raise AttemptErrors::InvalidState,
1094
- "Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
1095
- else
1096
- attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
1097
- attempt = attempt.transition("uncertain")
1098
- @store.save(attempt)
1099
- attempt
1100
- end
1101
- end
1102
-
1103
- # Resolve an uncertain attempt against a verified, boundary-attributed
1104
- # receipt. Never resolves by assumption.
1105
- def resolve_uncertain(attempt, receipt_path, identity)
1106
- unless attempt.uncertain?
1107
- raise AttemptErrors::InvalidState,
1108
- "Attempt #{attempt.attempt_id} is #{attempt.state}; reconciliation targets uncertain attempts"
1109
- end
1110
- unless receipt_path
1111
- raise AttemptErrors::InvalidState,
1112
- "Reconciliation requires --receipt FILE; uncertain attempts are never resolved by assumption"
1113
- end
1114
-
1115
- data = read_receipt_file(receipt_path)
1116
- live_head = candidate_head!
1117
- attempt = invalidate_stale_candidate(attempt, live_head)
1118
-
1119
- recorded = reconciler.recorded_runtime(attempt)
1120
- unless data.dig("producer", "runtime") == recorded
1121
- raise AttemptErrors::ReceiptRejected,
1122
- "Receipt runtime #{data.dig("producer", "runtime").inspect} does not match the recorded " \
1123
- "execution boundary #{recorded.inspect}"
1124
- end
1125
-
1126
- receipt = @verifier.verify!(
1127
- data,
1128
- attempt: attempt,
1129
- identity: identity,
1130
- live_head: live_head,
1131
- repo_root: @repo_root
1132
- )
1133
- if @verifier.external_effect?(receipt.operation)
1134
- unless attempt.managed?
1135
- raise AttemptErrors::InvalidState,
1136
- "Taskless attempts cannot record external effects without managed evidence"
1137
- end
1138
-
1139
- require_review_evidence(attempt, live_head)
1140
- end
1141
-
1142
- reconciliation = Models::EvidenceEvent.build(
1143
- type: "reconciliation",
1144
- attempt_id: attempt.attempt_id,
1145
- payload: {"resolution" => receipt.verdict, "receipt_digest" => receipt.digest},
1146
- previous_digest: last_event_digest(attempt)
1147
- )
1148
- accept(attempt, receipt, live_head, [reconciliation])
1149
- end
1150
-
1151
- def transition_event(attempt, to, reason)
1152
- Models::EvidenceEvent.build(
1153
- type: "transition",
1154
- attempt_id: attempt.attempt_id,
1155
- payload: {"from" => attempt.state, "to" => to, "reason" => reason},
1156
- previous_digest: last_event_digest(attempt)
1157
- )
1158
- end
1159
-
1160
- def reconciler
1161
- @reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
1162
- end
1163
-
1164
- def reserve(assignment, scope, project, identity, base_head, attempt_id)
1165
- binding = Models::AttemptBinding.new(
1166
- attempt_id: attempt_id,
1167
- assignment_id: assignment.id,
1168
- scope: scope,
1169
- project_id: project,
1170
- task_id: assignment.task_id,
1171
- actor: identity.actor,
1172
- role: identity.role,
1173
- runtime: identity.runtime,
1174
- base_head: base_head,
1175
- evidence_git_ref: assignment.managed? ? journal_for.ref : nil,
1176
- created_at: Time.now.utc
1177
- )
1178
- Models::Attempt.new(binding: binding, state: "reserved").transition("running")
1179
- end
1180
-
1181
- def start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
1182
- intent = Models::EvidenceEvent.build(
1183
- type: "intent",
1184
- attempt_id: attempt_id,
1185
- payload: {
1186
- "assignment_id" => assignment_id,
1187
- "scope" => scope,
1188
- "project_id" => project,
1189
- "task_id" => assignment.task_id,
1190
- "base_head" => base_head
1191
- }
1192
- )
1193
- process_start = Models::EvidenceEvent.build(
1194
- type: "process_start",
1195
- attempt_id: attempt_id,
1196
- payload: {
1197
- "actor" => identity.actor,
1198
- "role" => identity.role,
1199
- "runtime" => identity.runtime,
1200
- "pid" => identity.process_pid,
1201
- "process_identity" => Ace::Runtime::Molecules::ProcessIdentity.new.capture(identity.process_pid),
1202
- "runtime_binding" => identity.runtime_binding
1203
- },
1204
- previous_digest: intent["digest"]
1205
- )
1206
- [intent, process_start]
1207
- end
1208
-
1209
- # A changed candidate SHA invalidates prior candidate evidence and
1210
- # authorizations; the change itself is journaled before anything else.
1211
- def invalidate_stale_candidate(attempt, live_head)
1212
- return attempt if attempt.candidate_head.nil? || attempt.candidate_head == live_head
1213
-
1214
- event = Models::EvidenceEvent.build(
1215
- type: "candidate_invalidated",
1216
- attempt_id: attempt.attempt_id,
1217
- payload: {"previous_candidate_head" => attempt.candidate_head, "current_head" => live_head}
1218
- )
1219
- attempt = append_events(attempt, [event])
1220
- attempt = attempt.with(candidate_head: nil, accepted_receipts: [])
1221
- @store.save(attempt)
1222
- attempt
1223
- end
1224
-
1225
- def accept(attempt, receipt, live_head, pre_events = [])
1226
- # Reject illegal terminal outcomes before accepting any receipt.
1227
- if %w[succeeded failed].include?(receipt.verdict)
1228
- Atoms::AttemptStateMachine.transition!(attempt.state, receipt.verdict)
1229
- end
1230
- previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
1231
- event = Models::EvidenceEvent.build(
1232
- type: "receipt_accepted",
1233
- attempt_id: attempt.attempt_id,
1234
- payload: {"receipt" => receipt.to_h},
1235
- previous_digest: previous
1236
- )
1237
- attempt = append_events(attempt, pre_events + [event])
1238
-
1239
- receipts = attempt.accepted_receipts + [receipt.to_h]
1240
- effects = attempt.effects
1241
- if @verifier.external_effect?(receipt.operation)
1242
- effects += [{"operation" => receipt.operation, "receipt_digest" => receipt.digest}]
1243
- end
1244
-
1245
- attempt = attempt.with(
1246
- candidate_head: attempt.candidate_head || receipt.head,
1247
- accepted_receipts: receipts,
1248
- effects: effects,
1249
- state: attempt.state,
1250
- updated_at: Time.now.utc
1251
- )
1252
- attempt = attempt.transition(receipt.verdict) if %w[succeeded failed].include?(receipt.verdict)
1253
-
1254
- @store.save(attempt)
1255
- @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
1256
-
1257
- attempt
1258
- end
1259
-
1260
- def require_review_evidence(attempt, live_head)
1261
- receipts = if attempt.managed?
1262
- journal_for.read_events(attempt.binding.assignment_id)
1263
- .select { |event| event["type"] == "receipt_accepted" }
1264
- .map { |event| event.dig("payload", "receipt") }
1265
- .compact
1266
- else
1267
- attempt.accepted_receipts
1268
- end
1269
- approved = receipts.any? do |candidate|
1270
- candidate["operation"] == Molecules::ReceiptVerifier::REVIEW_OPERATION &&
1271
- candidate["verdict"] == "succeeded" &&
1272
- candidate["head"] == live_head &&
1273
- candidate.dig("review", "verdict") == Molecules::ReceiptVerifier::APPROVED_VERDICT
1274
- end
1275
- return if approved
1276
-
1277
- raise AttemptErrors::ReceiptRejected,
1278
- "External effect requires an executed independent review receipt for candidate #{live_head}"
1279
- end
1280
-
1281
- def read_receipt_file(path)
1282
- raise AttemptErrors::ReceiptRejected, "Receipt file not found: #{path}" unless File.exist?(path.to_s)
1283
-
1284
- data = JSON.parse(File.read(path))
1285
- raise AttemptErrors::ReceiptRejected, "Receipt must be a JSON object" unless data.is_a?(Hash)
1286
-
1287
- data
1288
- rescue JSON::ParserError => e
1289
- raise AttemptErrors::ReceiptRejected, "Receipt file is not valid JSON: #{e.message}"
1290
- end
1291
-
1292
- def append_events(attempt, events)
1293
- if attempt.managed?
1294
- commit = journal_for.append(
1295
- assignment_id: attempt.binding.assignment_id,
1296
- attempt_id: attempt.attempt_id,
1297
- events: events
1298
- )
1299
- attempt.with(journal_commit: commit)
1300
- else
1301
- attempt.with(events: attempt.events + events)
1302
- end
1303
- end
1304
-
1305
- # Digest chain continuation: managed attempts chain through the
1306
- # authoritative journal; taskless attempts chain through the local
1307
- # event trail.
1308
- def last_event_digest(attempt)
1309
- if attempt.managed?
1310
- journal_for.read_events(attempt.binding.assignment_id).last&.dig("digest")
1311
- else
1312
- attempt.events.last&.dig("digest")
1313
- end
1314
- end
1315
-
1316
- def journal_for
1317
- @journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
1318
- @journal
1319
- end
1320
-
1321
- # Prune/writer exclusion shared with overseer prune (durable, outside
1322
- # every deletion target).
1323
- def lifecycle_exclusion
1324
- @lifecycle_exclusion ||= Molecules::LifecycleExclusion.new(repo_root: @repo_root)
1325
- end
1326
-
1327
- def candidate_head!
1328
- out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
1329
- head = out.to_s.strip
1330
- return head if status.success? && !head.empty?
1331
-
1332
- raise AttemptErrors::EvidenceUnavailable, "Cannot resolve candidate HEAD in #{@repo_root}"
1333
- end
1334
- end
1335
- end
1336
- end
1337
- end