ace-assign 0.58.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -12
- data/docs/usage.md +32 -0
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +28 -0
- data/lib/ace/assign/cli.rb +4 -0
- data/lib/ace/assign/models/evidence_event.rb +1 -0
- data/lib/ace/assign/models/execution_receipt.rb +7 -3
- data/lib/ace/assign/molecules/evidence_calculator.rb +51 -1
- data/lib/ace/assign/molecules/evidence_journal.rb +291 -12
- data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +45 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +3 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +378 -42
- data/lib/ace/assign/version.rb +1 -1
- metadata +20 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Assign
|
|
@@ -55,12 +56,21 @@ module Ace
|
|
|
55
56
|
verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
|
|
56
57
|
verify_checks(data)
|
|
57
58
|
verify_review(data)
|
|
59
|
+
verify_campaign(data, repo_root: repo_root, live_head: live_head)
|
|
58
60
|
|
|
59
61
|
receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
|
|
60
62
|
verify_digest(receipt)
|
|
61
63
|
|
|
62
64
|
receipt
|
|
63
65
|
end
|
|
66
|
+
# Recheck the source artifacts/checks of an already accepted receipt.
|
|
67
|
+
def verify_accepted_evidence!(data, live_head:, repo_root:)
|
|
68
|
+
verify_head(data, live_head)
|
|
69
|
+
verify_artifacts(data, repo_root)
|
|
70
|
+
verify_checks(data)
|
|
71
|
+
verify_review(data)
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
# @param operation [String] Receipt operation
|
|
65
75
|
# @return [Boolean] True when the operation acts outside the repository
|
|
66
76
|
def external_effect?(operation)
|
|
@@ -196,6 +206,41 @@ module Ace
|
|
|
196
206
|
end
|
|
197
207
|
end
|
|
198
208
|
|
|
209
|
+
# Consume ace-review's single campaign authority through the existing
|
|
210
|
+
# receipt boundary; a worker-supplied local result grants no authority.
|
|
211
|
+
def verify_campaign(data, repo_root:, live_head:)
|
|
212
|
+
campaign = data["campaign"]
|
|
213
|
+
return unless campaign
|
|
214
|
+
reject_unless(campaign.is_a?(Hash), "campaign must be an object")
|
|
215
|
+
reject("campaign result requires a succeeded review receipt") unless
|
|
216
|
+
data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
|
|
217
|
+
reference = campaign["result"]
|
|
218
|
+
reject_unless(reference.is_a?(Hash), "campaign requires a result artifact reference")
|
|
219
|
+
unless Array(data["artifacts"]).include?(reference)
|
|
220
|
+
reject("campaign result must be included in verified receipt artifacts")
|
|
221
|
+
end
|
|
222
|
+
resolved = safe_resolve(repo_root, reference["path"].to_s)
|
|
223
|
+
reject("campaign result path escapes repository or is missing") unless resolved
|
|
224
|
+
result = JSON.parse(File.read(resolved))
|
|
225
|
+
reject_unless(result.is_a?(Hash), "campaign result must be an object")
|
|
226
|
+
require "ace/review"
|
|
227
|
+
current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
|
|
228
|
+
unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
|
|
229
|
+
result["dry_run"] == false && current["accepted"] == true &&
|
|
230
|
+
result["result_identity"] == current["result_identity"] &&
|
|
231
|
+
result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
|
|
232
|
+
reject("campaign result is stale, incomplete, blocked or does not match live authority")
|
|
233
|
+
end
|
|
234
|
+
approval = current["rounds"].last["approval"]
|
|
235
|
+
unless approval["producer"] == data.dig("producer", "actor") &&
|
|
236
|
+
approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
|
|
237
|
+
approval["head"] == data["head"]
|
|
238
|
+
reject("campaign producer/reviewer attribution does not match independently validated receipt")
|
|
239
|
+
end
|
|
240
|
+
rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
|
|
241
|
+
reject("invalid campaign result: #{e.message}")
|
|
242
|
+
end
|
|
243
|
+
|
|
199
244
|
# Resolve a project-relative artifact path to its real location,
|
|
200
245
|
# rejecting traversal and symlink escapes (an in-repo symlink must
|
|
201
246
|
# not smuggle in an external file); returns nil when the path leaves
|
|
@@ -114,7 +114,9 @@ module Ace
|
|
|
114
114
|
updated_at: assignment.updated_at,
|
|
115
115
|
source_config: archived_path,
|
|
116
116
|
cache_dir: assignment.cache_dir,
|
|
117
|
-
parent: assignment.parent
|
|
117
|
+
parent: assignment.parent,
|
|
118
|
+
task_id: assignment.task_id,
|
|
119
|
+
project_id: assignment.project_id
|
|
118
120
|
)
|
|
119
121
|
assignment_manager.update(assignment)
|
|
120
122
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "open3"
|
|
6
|
+
require "pathname"
|
|
5
7
|
|
|
6
8
|
module Ace
|
|
7
9
|
module Assign
|
|
@@ -21,22 +23,282 @@ module Ace
|
|
|
21
23
|
# to the candidate revision invalidates prior candidate authorization;
|
|
22
24
|
# `journal_commit` is tracked separately from both.
|
|
23
25
|
class AttemptCoordinator
|
|
26
|
+
SERVICE_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
|
|
27
|
+
input_digest operation outcome project_id request_id target transport].freeze
|
|
28
|
+
SERVICE_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
|
|
29
|
+
input_digest target candidate_head executor_uid transport].freeze
|
|
24
30
|
# @param cache_base [String, nil] Assignment cache base
|
|
25
31
|
# @param repo_root [String, nil] Candidate repository root (default: project root)
|
|
26
32
|
# @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
|
|
27
33
|
# @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
|
|
28
34
|
# @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
|
|
29
|
-
def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil
|
|
35
|
+
def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
|
|
36
|
+
lifecycle_exclusion: nil)
|
|
30
37
|
@manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
31
38
|
@store = @manager.attempt_store
|
|
32
39
|
@repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
33
40
|
@journal = journal
|
|
34
41
|
@identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
|
|
35
42
|
@verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
|
|
43
|
+
@lifecycle_exclusion = lifecycle_exclusion
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
attr_reader :store
|
|
39
47
|
|
|
48
|
+
# Claim an external service effect before dispatch. Only a managed,
|
|
49
|
+
# active attempt for the exact project and candidate may own it. The
|
|
50
|
+
# guard re-runs inside the journal lock per CAS attempt, so a
|
|
51
|
+
# terminal event landing between validation and commit still blocks
|
|
52
|
+
# the claim.
|
|
53
|
+
def claim_service_request(binding)
|
|
54
|
+
attempt = service_attempt(binding)
|
|
55
|
+
head = candidate_head!
|
|
56
|
+
valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
|
|
57
|
+
unless valid_head
|
|
58
|
+
raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
|
|
59
|
+
end
|
|
60
|
+
require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
|
|
61
|
+
# Dispatching claims record uncertain directly: the effect may
|
|
62
|
+
# happen as soon as the claim lands, and a crash before the
|
|
63
|
+
# executor response must leave uncertainty, not a stranded
|
|
64
|
+
# accepted state that a retry would return without dispatching.
|
|
65
|
+
journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Rechecked against the authoritative ref for every locked claim
|
|
69
|
+
# attempt: the attempt must still be journal-active and the exact
|
|
70
|
+
# authorization must still be unconsumed.
|
|
71
|
+
def claim_guard(binding)
|
|
72
|
+
derived = journal_for.derived_attempts(binding.fetch("assignment_id"))
|
|
73
|
+
.find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
|
|
74
|
+
unless derived&.active?
|
|
75
|
+
raise AttemptErrors::ReceiptRejected, "Attempt is no longer active in the authoritative journal"
|
|
76
|
+
end
|
|
77
|
+
journal_for.authorization_conflict(binding)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def service_attempt(binding)
|
|
81
|
+
attempt = authoritative_attempt(binding)
|
|
82
|
+
raise AttemptErrors::NotFound, "Attempt not found" unless attempt
|
|
83
|
+
unless attempt.managed? && attempt.active? &&
|
|
84
|
+
attempt.binding.assignment_id == binding["assignment_id"] &&
|
|
85
|
+
attempt.binding.project_id == binding["project_id"]
|
|
86
|
+
raise AttemptErrors::ReceiptRejected, "Service request does not match an active managed attempt"
|
|
87
|
+
end
|
|
88
|
+
attempt
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The authoritative journal wins for managed assignments, and a
|
|
92
|
+
# managed service claim REQUIRES the journal-derived attempt: a lost
|
|
93
|
+
# or unavailable evidence ref fails closed instead of trusting a
|
|
94
|
+
# local cache record that the journal cannot corroborate.
|
|
95
|
+
def authoritative_attempt(binding)
|
|
96
|
+
assignment_id = binding.fetch("assignment_id")
|
|
97
|
+
assignment = @manager.load(assignment_id)
|
|
98
|
+
if assignment&.managed?
|
|
99
|
+
journal_for.derived_attempts(assignment_id)
|
|
100
|
+
.find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
|
|
101
|
+
else
|
|
102
|
+
@store.find(binding.fetch("attempt_id")) ||
|
|
103
|
+
recover_managed_attempt(assignment_id, binding.fetch("attempt_id"))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reject_service_request(binding, reason:)
|
|
108
|
+
service_attempt(binding)
|
|
109
|
+
journal_for.reject_service_request(binding, reason: reason)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def service_request_status(request_id)
|
|
113
|
+
journal_for.service_request(request_id)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def transition_service_request(request_id, state:, receipt: nil)
|
|
117
|
+
if %w[succeeded failed].include?(state)
|
|
118
|
+
request = journal_for.service_request(request_id)
|
|
119
|
+
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
|
|
120
|
+
validate_service_receipt!(request, state, receipt)
|
|
121
|
+
elsif receipt
|
|
122
|
+
raise AttemptErrors::ReceiptRejected, "Non-terminal service transition cannot carry a receipt"
|
|
123
|
+
end
|
|
124
|
+
journal_for.transition_service_request(request_id, state: state, receipt: receipt, validated: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Settle a failed effect as "no effect occurred". The settlement is
|
|
128
|
+
# evidence-validated like a terminal receipt — the executor must
|
|
129
|
+
# attest this request with outcome:failed and attributable evidence
|
|
130
|
+
# — and the original failure event stays in the journal history.
|
|
131
|
+
def reconcile_service_failure(request_id, receipt:)
|
|
132
|
+
request = journal_for.service_request(request_id)
|
|
133
|
+
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
|
|
134
|
+
unless %w[failed rejected uncertain].include?(request["state"]) &&
|
|
135
|
+
(request["state"] != "rejected" || request["consumed"] != false)
|
|
136
|
+
raise AttemptErrors::InvalidState,
|
|
137
|
+
"Only dispatched (failed, uncertain, or rejected) service requests can be reconciled"
|
|
138
|
+
end
|
|
139
|
+
settle_no_effect(request_id, request, receipt)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Settle an uncertain claim whose handler was never invoked (a
|
|
143
|
+
# pre-dispatch refusal observed by the trusted local service). Same
|
|
144
|
+
# evidence rules as a failure settlement: a distinct, executor-owned
|
|
145
|
+
# no-effect attestation bound to this request.
|
|
146
|
+
def reconcile_service_no_effect(request_id, receipt:)
|
|
147
|
+
request = journal_for.service_request(request_id)
|
|
148
|
+
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
|
|
149
|
+
unless request["state"] == "uncertain"
|
|
150
|
+
raise AttemptErrors::InvalidState, "Only uncertain service requests can settle as no-effect"
|
|
151
|
+
end
|
|
152
|
+
settle_no_effect(request_id, request, receipt)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def settle_no_effect(request_id, request, receipt)
|
|
156
|
+
# The settlement must be a distinct, later executor attestation:
|
|
157
|
+
# replaying the already-recorded failure receipt proves nothing
|
|
158
|
+
# about whether the effect took place.
|
|
159
|
+
if request["receipt"] && Atoms::EvidenceDigest.digest(receipt) ==
|
|
160
|
+
Atoms::EvidenceDigest.digest(request["receipt"])
|
|
161
|
+
raise AttemptErrors::ReceiptRejected,
|
|
162
|
+
"Service failure settlement requires a new attestation, not the recorded receipt"
|
|
163
|
+
end
|
|
164
|
+
validate_service_receipt!(request, "failed", receipt, require_no_effect: true)
|
|
165
|
+
journal_for.transition_service_request(request_id, state: "failed-settled",
|
|
166
|
+
receipt: receipt, validated: true)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_service_receipt!(request, state, receipt, require_no_effect: false)
|
|
170
|
+
unless receipt.is_a?(Hash) && receipt.keys.sort == SERVICE_RECEIPT_FIELDS.sort
|
|
171
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt has invalid fields"
|
|
172
|
+
end
|
|
173
|
+
SERVICE_BINDING_FIELDS.each do |key|
|
|
174
|
+
unless receipt[key] == request[key]
|
|
175
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt does not match #{key}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
unless receipt["outcome"] == state && receipt["executor_uid"].is_a?(Integer) &&
|
|
179
|
+
receipt["executor_uid"] >= 0 && valid_service_evidence?(receipt["evidence"])
|
|
180
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt has invalid executor or evidence"
|
|
181
|
+
end
|
|
182
|
+
unless receipt["executor_uid"] == request["executor_uid"]
|
|
183
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt executor does not match the claimed executor"
|
|
184
|
+
end
|
|
185
|
+
# Local transport runs the executor in the submitting process, so
|
|
186
|
+
# the submitter must be the configured executor identity itself.
|
|
187
|
+
if request["transport"] == "local" && Process.uid != request["executor_uid"]
|
|
188
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt submitter is not the configured executor"
|
|
189
|
+
end
|
|
190
|
+
verify_service_evidence!(receipt["evidence"], request, state, require_no_effect: require_no_effect)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def valid_service_evidence?(evidence)
|
|
194
|
+
evidence.is_a?(Array) && !evidence.empty? && evidence.all? do |item|
|
|
195
|
+
item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
|
|
196
|
+
item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
|
|
197
|
+
item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Evidence must live inside the candidate repository (symlinks
|
|
202
|
+
# resolved), be owned by — and not writable by anyone but — the
|
|
203
|
+
# claimed executor identity, postdate the claim, and carry a
|
|
204
|
+
# structured attestation naming the claimed request, input digest,
|
|
205
|
+
# and attested outcome. All checks read one open handle, so a swap
|
|
206
|
+
# on a caller-writable path cannot mix files between checks.
|
|
207
|
+
def verify_service_evidence!(evidence, request, state, require_no_effect: false)
|
|
208
|
+
repo_root = File.realpath(@repo_root)
|
|
209
|
+
claimed_at = parse_claimed_at(request)
|
|
210
|
+
evidence.each do |item|
|
|
211
|
+
ref = item["ref"]
|
|
212
|
+
unless Pathname.new(ref).relative?
|
|
213
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence must be repository-relative: #{ref}"
|
|
214
|
+
end
|
|
215
|
+
path = File.expand_path(ref, repo_root)
|
|
216
|
+
unless path.start_with?(repo_root + File::SEPARATOR)
|
|
217
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
|
|
218
|
+
end
|
|
219
|
+
begin
|
|
220
|
+
file = File.open(path, "rb")
|
|
221
|
+
rescue Errno::ENOENT
|
|
222
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence is unavailable: #{ref}"
|
|
223
|
+
end
|
|
224
|
+
begin
|
|
225
|
+
real = File.realpath(path)
|
|
226
|
+
unless real.start_with?(repo_root + File::SEPARATOR)
|
|
227
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
|
|
228
|
+
end
|
|
229
|
+
stat = file.stat
|
|
230
|
+
# The open handle must still be the file at the validated
|
|
231
|
+
# location: a swap between resolution and open fails here.
|
|
232
|
+
opened = File.stat(real)
|
|
233
|
+
unless opened.dev == stat.dev && opened.ino == stat.ino
|
|
234
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence changed during verification: #{ref}"
|
|
235
|
+
end
|
|
236
|
+
unless stat.uid == request["executor_uid"] && (stat.mode & 0o022).zero?
|
|
237
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence is not executor-owned: #{ref}"
|
|
238
|
+
end
|
|
239
|
+
unless claimed_at.nil? || stat.mtime >= claimed_at
|
|
240
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the claim: #{ref}"
|
|
241
|
+
end
|
|
242
|
+
failed_at = parse_failed_at(request)
|
|
243
|
+
unless failed_at.nil? || stat.mtime >= failed_at
|
|
244
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the failure: #{ref}"
|
|
245
|
+
end
|
|
246
|
+
content = file.read
|
|
247
|
+
unless attestation_line(content, request, state, require_no_effect: require_no_effect)
|
|
248
|
+
raise AttemptErrors::ReceiptRejected,
|
|
249
|
+
"Service receipt evidence does not bind the claimed request: #{ref}"
|
|
250
|
+
end
|
|
251
|
+
unless Digest::SHA256.hexdigest(content) == item["sha256"]
|
|
252
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt evidence digest mismatch: #{ref}"
|
|
253
|
+
end
|
|
254
|
+
ensure
|
|
255
|
+
file.close
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The evidence artifact must carry a structured attestation line
|
|
261
|
+
# naming the claimed request, input digest, and attested outcome
|
|
262
|
+
# exactly: an unrelated executor-owned file cannot attest this
|
|
263
|
+
# effect, prose cannot substitute for the attested outcome, and its
|
|
264
|
+
# result cannot be recorded under another terminal state.
|
|
265
|
+
def attestation_line(content, request, state, require_no_effect: false)
|
|
266
|
+
attestation = /^ace-service-attestation request:#{Regexp.escape(request.fetch("request_id"))} \
|
|
267
|
+
input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(\S+))?$/
|
|
268
|
+
line = content.scan(attestation).first
|
|
269
|
+
return false unless line
|
|
270
|
+
return false if require_no_effect && line[2] != "true"
|
|
271
|
+
%w[succeeded failed].include?(state) ? line.first == state : true
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def parse_claimed_at(request)
|
|
275
|
+
return nil if request["claimed_at"].nil?
|
|
276
|
+
Time.iso8601(request["claimed_at"])
|
|
277
|
+
rescue ArgumentError
|
|
278
|
+
raise AttemptErrors::ReceiptRejected, "Service request has an invalid claim timestamp"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parse_failed_at(request)
|
|
282
|
+
return nil if request["failed_at"].nil?
|
|
283
|
+
Time.iso8601(request["failed_at"])
|
|
284
|
+
rescue ArgumentError
|
|
285
|
+
raise AttemptErrors::ReceiptRejected, "Service request has an invalid failure timestamp"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Read-only eligibility check for dry-run: applies the same review
|
|
289
|
+
# evidence and candidate-head requirements as the claim without
|
|
290
|
+
# reserving anything.
|
|
291
|
+
def request_eligible!(binding)
|
|
292
|
+
attempt = service_attempt(binding)
|
|
293
|
+
head = candidate_head!
|
|
294
|
+
valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
|
|
295
|
+
unless valid_head
|
|
296
|
+
raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
|
|
297
|
+
end
|
|
298
|
+
require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
|
|
299
|
+
true
|
|
300
|
+
end
|
|
301
|
+
|
|
40
302
|
# Start a scoped attempt for an assignment.
|
|
41
303
|
#
|
|
42
304
|
# A repeated identical start returns the existing active attempt; a
|
|
@@ -62,49 +324,61 @@ module Ace
|
|
|
62
324
|
identity ||= @identity_resolver.resolve
|
|
63
325
|
base_head = candidate_head!
|
|
64
326
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
327
|
+
# Serialize writer registration against prune: prune holds the
|
|
328
|
+
# exclusive exclusion from its final evidence reads through removal.
|
|
329
|
+
# A worktree-backed task identity shares the same protocol (task
|
|
330
|
+
# key first, then assignment key — the one consistent global
|
|
331
|
+
# order), so a worktree prune and an attempt start cannot interleave.
|
|
332
|
+
start_keys = []
|
|
333
|
+
unless assignment.task_id.to_s.strip.empty?
|
|
334
|
+
start_keys << lifecycle_exclusion.task_key(assignment.task_id)
|
|
335
|
+
end
|
|
336
|
+
start_keys << lifecycle_exclusion.assignment_key(assignment_id)
|
|
337
|
+
lifecycle_exclusion.with_shared_multi(start_keys) do
|
|
338
|
+
@store.with_lock(assignment_id) do
|
|
339
|
+
journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
|
|
340
|
+
|
|
341
|
+
existing = @store.active(assignment_id, scope) ||
|
|
342
|
+
journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
|
|
343
|
+
if existing
|
|
344
|
+
if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
|
|
345
|
+
existing.binding.actor == identity.actor && existing.binding.role == identity.role
|
|
346
|
+
return existing
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
raise AttemptErrors::Conflict,
|
|
350
|
+
"Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
|
|
351
|
+
"(project #{existing.binding.project_id}); refusing to launch a second writer"
|
|
74
352
|
end
|
|
75
353
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
354
|
+
active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
|
|
355
|
+
active_scopes.concat(journal_actives.map(&:binding))
|
|
356
|
+
blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
|
|
357
|
+
if blocker
|
|
358
|
+
raise AttemptErrors::Conflict,
|
|
359
|
+
"Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
|
|
360
|
+
"refusing overlapping writers"
|
|
361
|
+
end
|
|
80
362
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
363
|
+
attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
|
|
364
|
+
events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
|
|
365
|
+
attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
|
|
366
|
+
if attempt.managed?
|
|
367
|
+
commit = journal_for.append(
|
|
368
|
+
assignment_id: assignment_id,
|
|
369
|
+
attempt_id: attempt.attempt_id,
|
|
370
|
+
events: events
|
|
371
|
+
)
|
|
372
|
+
attempt = attempt.with(journal_commit: commit)
|
|
373
|
+
attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
374
|
+
else
|
|
375
|
+
attempt = attempt.with(events: events)
|
|
376
|
+
end
|
|
89
377
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if attempt.managed?
|
|
94
|
-
commit = journal_for.append(
|
|
95
|
-
assignment_id: assignment_id,
|
|
96
|
-
attempt_id: attempt.attempt_id,
|
|
97
|
-
events: events
|
|
98
|
-
)
|
|
99
|
-
attempt = attempt.with(journal_commit: commit)
|
|
100
|
-
attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
|
|
101
|
-
else
|
|
102
|
-
attempt = attempt.with(events: events)
|
|
378
|
+
@store.save(attempt)
|
|
379
|
+
@store.claim(assignment_id, scope, attempt)
|
|
380
|
+
attempt
|
|
103
381
|
end
|
|
104
|
-
|
|
105
|
-
@store.save(attempt)
|
|
106
|
-
@store.claim(assignment_id, scope, attempt)
|
|
107
|
-
attempt
|
|
108
382
|
end
|
|
109
383
|
end
|
|
110
384
|
|
|
@@ -165,6 +439,61 @@ module Ace
|
|
|
165
439
|
attempt.projection
|
|
166
440
|
end
|
|
167
441
|
|
|
442
|
+
# Read accepted check evidence without transitions, locks, cache writes
|
|
443
|
+
# or audit checkout creation. Managed history comes from the Git ref.
|
|
444
|
+
def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
|
|
445
|
+
unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
|
|
446
|
+
raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
|
|
447
|
+
end
|
|
448
|
+
if !historical_head.nil? && (!%w[review-collection review-approval].include?(kind) || !historical_head.is_a?(String) ||
|
|
449
|
+
!historical_head.match?(/\A[0-9a-f]{40,64}\z/))
|
|
450
|
+
raise AttemptErrors::ReceiptRejected, "Historical evidence requires review collection/approval and an exact recorded head"
|
|
451
|
+
end
|
|
452
|
+
unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
|
|
453
|
+
receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
|
|
454
|
+
raise AttemptErrors::ReceiptRejected, "Invalid attempt ID or receipt digest"
|
|
455
|
+
end
|
|
456
|
+
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
457
|
+
raise AttemptErrors::NotFound, "Attempt #{attempt_id} not found" unless attempt
|
|
458
|
+
raise AttemptErrors::ReceiptRejected, "Execution evidence requires managed immutable journal history" unless attempt.managed?
|
|
459
|
+
attempt = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
|
|
460
|
+
raise AttemptErrors::ReceiptRejected, "No succeeded accepted attempt" unless attempt&.state == "succeeded"
|
|
461
|
+
accepted = journal_for.accepted_receipts(attempt.binding.assignment_id)
|
|
462
|
+
data = accepted.find { |r| r["attempt_id"] == attempt_id && r["digest"] == receipt_digest }
|
|
463
|
+
raise AttemptErrors::ReceiptRejected, "Receipt was not accepted by coordinator" unless data
|
|
464
|
+
if kind == "check" && (%w[review review-collect].include?(data["operation"]) || @verifier.external_effect?(data["operation"]))
|
|
465
|
+
raise AttemptErrors::ReceiptRejected, "Review or external-effect operations cannot be check evidence"
|
|
466
|
+
end
|
|
467
|
+
expected_operation = case kind
|
|
468
|
+
when "review-collection" then "review-collect"
|
|
469
|
+
when "review-approval" then "review"
|
|
470
|
+
else (check_name == "tests") ? "test" : check_name
|
|
471
|
+
end
|
|
472
|
+
unless data["campaign"].nil? && data["operation"] == expected_operation
|
|
473
|
+
raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
|
|
474
|
+
end
|
|
475
|
+
required_check = (kind == "review-collection") ? "review-execution" : check_name
|
|
476
|
+
has_required_check = Array(data["checks"]).any? { |check| check["name"] == required_check && check["verdict"] == "passed" }
|
|
477
|
+
unless kind == "review-approval" || has_required_check
|
|
478
|
+
raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
|
|
479
|
+
end
|
|
480
|
+
receipt = Models::ExecutionReceipt.from_h(data)
|
|
481
|
+
unless receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
|
|
482
|
+
raise AttemptErrors::ReceiptRejected, "Accepted receipt digest is corrupt"
|
|
483
|
+
end
|
|
484
|
+
evidence_head = historical_head || candidate_head!
|
|
485
|
+
unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
|
|
486
|
+
raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
|
|
487
|
+
end
|
|
488
|
+
@verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root)
|
|
489
|
+
{"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
|
|
490
|
+
"historical" => !historical_head.nil?,
|
|
491
|
+
"operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
|
|
492
|
+
"artifacts" => data["artifacts"], "assignment_id" => data["assignment_id"],
|
|
493
|
+
"project_id" => data["project_id"], "scope" => data["scope"],
|
|
494
|
+
"evidence_git_ref" => attempt.binding.evidence_git_ref, "journal_commit" => attempt.journal_commit}
|
|
495
|
+
end
|
|
496
|
+
|
|
168
497
|
# Reconcile an interrupted attempt.
|
|
169
498
|
#
|
|
170
499
|
# Running attempts are classified conservatively (stopped before
|
|
@@ -251,7 +580,7 @@ module Ace
|
|
|
251
580
|
return nil if assignment && !assignment.managed?
|
|
252
581
|
|
|
253
582
|
return journal_for.derived_attempts(assignment_id)
|
|
254
|
-
|
|
583
|
+
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
255
584
|
end
|
|
256
585
|
|
|
257
586
|
journal_for.assignment_ids.each do |journal_assignment_id|
|
|
@@ -339,7 +668,7 @@ module Ace
|
|
|
339
668
|
recorded = reconciler.recorded_runtime(attempt)
|
|
340
669
|
unless data.dig("producer", "runtime") == recorded
|
|
341
670
|
raise AttemptErrors::ReceiptRejected,
|
|
342
|
-
"Receipt runtime #{data.dig(
|
|
671
|
+
"Receipt runtime #{data.dig("producer", "runtime").inspect} does not match the recorded " \
|
|
343
672
|
"execution boundary #{recorded.inspect}"
|
|
344
673
|
end
|
|
345
674
|
|
|
@@ -381,7 +710,8 @@ module Ace
|
|
|
381
710
|
@reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
|
|
382
711
|
end
|
|
383
712
|
|
|
384
|
-
def reserve(assignment, scope, project, identity, base_head, attempt_id)
|
|
713
|
+
def reserve(assignment, scope, project, identity, base_head, attempt_id)
|
|
714
|
+
binding = Models::AttemptBinding.new(
|
|
385
715
|
attempt_id: attempt_id,
|
|
386
716
|
assignment_id: assignment.id,
|
|
387
717
|
scope: scope,
|
|
@@ -531,6 +861,12 @@ module Ace
|
|
|
531
861
|
@journal
|
|
532
862
|
end
|
|
533
863
|
|
|
864
|
+
# Prune/writer exclusion shared with overseer prune (durable, outside
|
|
865
|
+
# every deletion target).
|
|
866
|
+
def lifecycle_exclusion
|
|
867
|
+
@lifecycle_exclusion ||= Molecules::LifecycleExclusion.new(repo_root: @repo_root)
|
|
868
|
+
end
|
|
869
|
+
|
|
534
870
|
def candidate_head!
|
|
535
871
|
out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
|
|
536
872
|
head = out.to_s.strip
|
data/lib/ace/assign/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-assign
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.61.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -107,6 +107,20 @@ dependencies:
|
|
|
107
107
|
- - "~>"
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '0.39'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: ace-review
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0.57'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0.57'
|
|
110
124
|
- !ruby/object:Gem::Dependency
|
|
111
125
|
name: ace-task
|
|
112
126
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -127,14 +141,14 @@ dependencies:
|
|
|
127
141
|
requirements:
|
|
128
142
|
- - "~>"
|
|
129
143
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: '0.
|
|
144
|
+
version: '0.18'
|
|
131
145
|
type: :runtime
|
|
132
146
|
prerelease: false
|
|
133
147
|
version_requirements: !ruby/object:Gem::Requirement
|
|
134
148
|
requirements:
|
|
135
149
|
- - "~>"
|
|
136
150
|
- !ruby/object:Gem::Version
|
|
137
|
-
version: '0.
|
|
151
|
+
version: '0.18'
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: ace-support-test-helpers
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -294,6 +308,7 @@ files:
|
|
|
294
308
|
- lib/ace/assign/cli/commands/add.rb
|
|
295
309
|
- lib/ace/assign/cli/commands/assignment_target.rb
|
|
296
310
|
- lib/ace/assign/cli/commands/attempt/base.rb
|
|
311
|
+
- lib/ace/assign/cli/commands/attempt/evidence.rb
|
|
297
312
|
- lib/ace/assign/cli/commands/attempt/finish.rb
|
|
298
313
|
- lib/ace/assign/cli/commands/attempt/reconcile.rb
|
|
299
314
|
- lib/ace/assign/cli/commands/attempt/start.rb
|
|
@@ -325,6 +340,7 @@ files:
|
|
|
325
340
|
- lib/ace/assign/molecules/evidence_journal.rb
|
|
326
341
|
- lib/ace/assign/molecules/execution_identity_resolver.rb
|
|
327
342
|
- lib/ace/assign/molecules/fork_session_launcher.rb
|
|
343
|
+
- lib/ace/assign/molecules/lifecycle_exclusion.rb
|
|
328
344
|
- lib/ace/assign/molecules/preset_inferrer.rb
|
|
329
345
|
- lib/ace/assign/molecules/queue_scanner.rb
|
|
330
346
|
- lib/ace/assign/molecules/receipt_verifier.rb
|