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