ace-assign 0.60.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 +17 -0
- data/docs/usage.md +15 -0
- data/lib/ace/assign/models/evidence_event.rb +1 -0
- data/lib/ace/assign/molecules/evidence_calculator.rb +51 -1
- data/lib/ace/assign/molecules/evidence_journal.rb +282 -2
- data/lib/ace/assign/organisms/attempt_coordinator.rb +268 -6
- data/lib/ace/assign/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 494e61c0842f8930425d12aeee6c0b51b46ce491e829c19401df1296e440ca09
|
|
4
|
+
data.tar.gz: 614e997a57ea09e7ff83d30ad372514dc0c0abceac7a809cdcb4aea1b630005a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a83405e18f47bdf33cf83619a88b790ad6ed27ff933f8816530931a2eb571c744f8eeca5c25aded6c0c2dacdda5429aec87372aadae63b0393a17a2797d149ea
|
|
7
|
+
data.tar.gz: c6dba5cc8f2925af84265864bbf4b54e3102f27d0707ee936d0440ec3df28e3acbf67cc7040a103c808461d33cfd77c8dd43f5092bbec6ce0fa362b1920cfc52
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.61.0] - 2026-10-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Journal-backed service request claims, terminal receipts, and uncertainty
|
|
15
|
+
tracking. Assignment evidence now exposes unresolved service effects and
|
|
16
|
+
the latest evidence ref commit without changing the candidate head.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Validate terminal service receipts against the exact claimed request and
|
|
21
|
+
executor evidence before accepting them in the assignment journal.
|
|
22
|
+
|
|
23
|
+
### Technical
|
|
24
|
+
|
|
25
|
+
- Updated the ace-tmux dependency to the 0.18 adapter release line.
|
|
26
|
+
|
|
10
27
|
## [0.60.0] - 2026-10-01
|
|
11
28
|
|
|
12
29
|
### Fixed
|
data/docs/usage.md
CHANGED
|
@@ -124,6 +124,16 @@ ace-assign attempt evidence --attempt ATTEMPT --receipt-digest SHA256 --format j
|
|
|
124
124
|
|
|
125
125
|
A check proof requires the live candidate head, intact accepted artifacts, and the matching executed operation: `tests` uses `test`, other explicit check names use that operation name. Review collection requires an accepted `review-collect` operation with a passed `review-execution` outcome and the retained metadata/report/prompt artifacts. Review approval requires an ordinary accepted `review` operation with the approved independent reviewer verdict, exact head and retained report artifacts; collection proof cannot substitute for approval. Both collection and approval support an exact `--historical-head`. Historical review validation exposes `historical: true` and the recorded head; it preserves accepted source authority across head drift and cannot certify live checks. Unknown receipts, changed artifacts, unaccepted/unmanaged history, wrong purposes and stale current queries fail closed. Retain the configured evidence ref alongside durable review campaigns.
|
|
126
126
|
|
|
127
|
+
### Service effect claims
|
|
128
|
+
|
|
129
|
+
External service operations (driven through `ace-lab service request`) claim their effect on the assignment journal before dispatch, so a retried or crashed request can never duplicate a side effect. The coordinator owns the trusted surface; callers never append journal events directly.
|
|
130
|
+
|
|
131
|
+
- `Ace::Assign::Organisms::AttemptCoordinator#claim_service_request(binding)` records the claim and its attempt event atomically under a journal-wide request-ID index: the same request ID cannot be claimed twice, by another assignment, or against a stale candidate head. External-effect operations additionally require accepted review evidence at the current head, like `merge`.
|
|
132
|
+
- `#service_request_status(request_id)` reads the authoritative claim state, including after local cache loss.
|
|
133
|
+
- `#transition_service_request(request_id, state:, receipt:)` moves a claim to `succeeded` or `failed` only with a bound, non-secret receipt (exact request/attempt/project/operation/input digest/target fields, non-negative executor UID, and SHA-256 evidence references); rejected receipts never become approvals.
|
|
134
|
+
- `#reject_service_request(binding, reason:)` durably records an auditable rejection without inventing a valid attempt.
|
|
135
|
+
- Journal commits advance `journal_commit` only; they never change or authorize `candidate_head`, and `base_head` stays fixed at attempt start.
|
|
136
|
+
|
|
127
137
|
### Evidence storage and recovery modes
|
|
128
138
|
|
|
129
139
|
- Task-attached (managed) attempts journal accepted evidence to the configured evidence Git ref (default `refs/ace/execution`) through an isolated, disposable audit checkout, outside the deliverable candidate branch. Journal commits never advance or exempt the reviewed candidate.
|
|
@@ -176,11 +186,14 @@ HITL stall behavior:
|
|
|
176
186
|
- Canonical contract lives in `wfi://hitl` (`ace-hitl` package workflow).
|
|
177
187
|
- If a step is failed with canonical message format `HITL: <id> <path>`, `ace-assign status` prints operator guidance with the matching `ace-hitl show <id>` command and available path hint.
|
|
178
188
|
- Recommended resume flow:
|
|
189
|
+
|
|
179
190
|
- `ace-hitl show <id>`
|
|
180
191
|
- requester path (default): `ace-hitl wait <id>`
|
|
181
192
|
- fallback path (when waiter inactive): `ace-hitl update <id> --answer "<decision>" --resume`
|
|
182
193
|
- `ace-assign retry <failed-step> --assignment <assignment-id>`
|
|
194
|
+
|
|
183
195
|
- Completion-attention flow:
|
|
196
|
+
|
|
184
197
|
- When assignment work is complete but explicit user action is needed, create an approval HITL event (`kind=approval`) and include the resume instruction for `/as-assign-drive <assignment-id>`.
|
|
185
198
|
|
|
186
199
|
### `ace-assign step [STEP]`
|
|
@@ -284,6 +297,7 @@ Launch modes:
|
|
|
284
297
|
- `headless`: force the existing provider subprocess path and never create tmux panes
|
|
285
298
|
- `tmux`: require tmux context, create or reuse `<origin-window>-fs`, start a real interactive agent in a pane there via `ace-llm --interactive`, and send the scoped `/as-assign-drive <assignment>@<root>` handoff automatically. The fork window name uses the shared `ace-tmux` safe-name policy, so punctuation in the base window is replaced with `-`. Fork windows and panes are created detached, so the current tmux focus stays where the user left it.
|
|
286
299
|
- `tmux`: require tmux context, create or reuse `<origin-window>-fs`, start a real interactive agent in a pane there via `ace-llm --interactive`, and send the scoped `/as-assign-drive <assignment>@<root>` handoff automatically
|
|
300
|
+
|
|
287
301
|
- This mode consumes the shared `ace-tmux` runtime/control surface for tmux targeting, pane dispatch, and diagnostics.
|
|
288
302
|
- The fork window name uses the shared `ace-tmux` safe-name policy, so punctuation in the base window is replaced with `-`.
|
|
289
303
|
- Fork windows and panes are created detached, so the current tmux focus stays where the user left it.
|
|
@@ -355,6 +369,7 @@ When using preset-backed assignment creation (`ace-assign create --task ...`, `/
|
|
|
355
369
|
- Terminal refs (`done`, `skipped`, `cancelled`) are skipped before queue expansion.
|
|
356
370
|
- Mixed sets continue with remaining non-terminal refs and report skipped terminal refs.
|
|
357
371
|
- If all requested refs are terminal, assignment creation stops with:
|
|
372
|
+
|
|
358
373
|
- `All requested tasks are already terminal (done/skipped/cancelled): <refs>`
|
|
359
374
|
- `No assignment created.`
|
|
360
375
|
|
|
@@ -39,6 +39,24 @@ module Ace
|
|
|
39
39
|
review_receipt = receipt_currency(receipts, "review", head)
|
|
40
40
|
release_receipt = receipt_currency(receipts, "release", head)
|
|
41
41
|
unresolved_effects = collect_unresolved_effects(attempts)
|
|
42
|
+
if assignment&.managed?
|
|
43
|
+
requests = journal.service_requests(assignment.id)
|
|
44
|
+
# A rejected effect stays unresolved when its claim had been
|
|
45
|
+
# dispatched (authorization consumed): only attributable evidence
|
|
46
|
+
# that no effect occurred settles it, never the bare rejection.
|
|
47
|
+
unresolved_effects.concat(requests
|
|
48
|
+
.select { |request| unresolved_service_state?(request) }
|
|
49
|
+
.map { |request| "#{request["request_id"]}:#{request["operation"]}:#{request["state"]}" })
|
|
50
|
+
# Settled requests keep their authorization only while their
|
|
51
|
+
# receipt evidence remains intact in the candidate repository:
|
|
52
|
+
# a deleted or modified artifact drops the outcome back to
|
|
53
|
+
# unresolved instead of trusting an unverifiable receipt.
|
|
54
|
+
unresolved_effects.concat(requests
|
|
55
|
+
.select { |request| request["state"] == "succeeded" || request["state"] == "failed-settled" }
|
|
56
|
+
.reject { |request| evidence_intact?(request) }
|
|
57
|
+
.map { |request| "#{request["request_id"]}:#{request["operation"]}:evidence-unavailable" })
|
|
58
|
+
unresolved_effects.uniq!
|
|
59
|
+
end
|
|
42
60
|
feedback_state = derive_feedback_state(attempts, receipts, head, unresolved_effects)
|
|
43
61
|
|
|
44
62
|
# Authorization requires managed evidence, settled work (no active
|
|
@@ -67,7 +85,7 @@ module Ace
|
|
|
67
85
|
base_head: latest&.binding&.base_head,
|
|
68
86
|
candidate_head: latest&.candidate_head,
|
|
69
87
|
evidence_git_ref: evidence_git_ref(assignment),
|
|
70
|
-
journal_commit: latest&.journal_commit,
|
|
88
|
+
journal_commit: assignment&.managed? ? journal.ref_value : latest&.journal_commit,
|
|
71
89
|
unresolved_effects: unresolved_effects
|
|
72
90
|
}
|
|
73
91
|
evidence[:decision_digest] = Digest::SHA256.hexdigest(
|
|
@@ -78,6 +96,38 @@ module Ace
|
|
|
78
96
|
|
|
79
97
|
private
|
|
80
98
|
|
|
99
|
+
# Accepted, uncertain, and failed effects are unresolved by
|
|
100
|
+
# definition; a rejected effect stays unresolved when its claim was
|
|
101
|
+
# dispatched (authorization consumed), because the effect may have
|
|
102
|
+
# happened.
|
|
103
|
+
def unresolved_service_state?(request)
|
|
104
|
+
%w[accepted uncertain failed].include?(request["state"]) ||
|
|
105
|
+
(request["state"] == "rejected" && request["consumed"] != false)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Every receipt evidence artifact must still exist in the candidate
|
|
109
|
+
# repository with its recorded digest; evidence lives in the working
|
|
110
|
+
# tree, so its integrity is re-checked on every calculation.
|
|
111
|
+
def evidence_intact?(request)
|
|
112
|
+
receipt = request["receipt"]
|
|
113
|
+
return false unless receipt.is_a?(Hash)
|
|
114
|
+
repo_root = File.realpath(@repo_root)
|
|
115
|
+
Array(receipt["evidence"]).all? do |item|
|
|
116
|
+
path = File.expand_path(item["ref"].to_s, repo_root)
|
|
117
|
+
real = begin
|
|
118
|
+
File.realpath(path)
|
|
119
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
120
|
+
next false
|
|
121
|
+
end
|
|
122
|
+
begin
|
|
123
|
+
real.start_with?(repo_root + File::SEPARATOR) &&
|
|
124
|
+
Digest::SHA256.file(real).hexdigest == item["sha256"]
|
|
125
|
+
rescue Errno::EACCES, Errno::ELOOP
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
81
131
|
def git_facts
|
|
82
132
|
head, _s = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
|
|
83
133
|
tree, _s = Open3.capture2("git", "rev-parse", "HEAD^{tree}", chdir: @repo_root, stdin_data: "")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "digest"
|
|
3
5
|
require "fileutils"
|
|
4
6
|
require "json"
|
|
5
7
|
require "open3"
|
|
@@ -121,6 +123,215 @@ module Ace
|
|
|
121
123
|
.compact
|
|
122
124
|
end
|
|
123
125
|
|
|
126
|
+
# Reserve a globally unique service request in the same evidence ref
|
|
127
|
+
# as its owning attempt. The request index and attempt event are one
|
|
128
|
+
# Git commit, so a ref race cannot admit two effects for one ID. An
|
|
129
|
+
# exact authorization reference is consumed by its first live claim:
|
|
130
|
+
# a second request presenting the same operation/project/target
|
|
131
|
+
# authorization is rejected instead of dispatching a duplicate effect.
|
|
132
|
+
# Dispatching callers claim directly as +uncertain+ so no commit
|
|
133
|
+
# window exists where a stranded claim reads as accepted.
|
|
134
|
+
def claim_service_request(binding, state: "accepted", guard: nil)
|
|
135
|
+
guard ||= -> { authorization_conflict(binding) }
|
|
136
|
+
update_service_request(binding.fetch("request_id"), expected: nil,
|
|
137
|
+
replacement: binding.merge("state" => state, "claimed_at" => Time.now.utc.iso8601(9)),
|
|
138
|
+
event_type: "service_claim", guard: guard)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Reject a service request. A request not yet on file gets its
|
|
142
|
+
# auditable rejection claim (which never consumed the authorization);
|
|
143
|
+
# an existing live claim transitions to rejected but stays consuming:
|
|
144
|
+
# only attributable evidence that the effect did not occur may free a
|
|
145
|
+
# dispatched authorization, never a bare rejection.
|
|
146
|
+
def reject_service_request(binding, reason:)
|
|
147
|
+
request_id = binding.fetch("request_id")
|
|
148
|
+
existing = service_request(request_id)
|
|
149
|
+
if existing
|
|
150
|
+
unless existing.except("state", "receipt", "reason", "claimed_at", "failed_at") == binding.except("state", "receipt", "reason", "claimed_at", "failed_at")
|
|
151
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
152
|
+
end
|
|
153
|
+
return existing if existing["state"] == "rejected"
|
|
154
|
+
return update_service_request(request_id, expected: existing,
|
|
155
|
+
replacement: existing.merge("state" => "rejected", "reason" => reason),
|
|
156
|
+
event_type: "service_transition")
|
|
157
|
+
end
|
|
158
|
+
update_service_request(request_id, expected: nil,
|
|
159
|
+
replacement: binding.merge("state" => "rejected", "reason" => reason, "consumed" => false),
|
|
160
|
+
event_type: "service_claim")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Terminal writes are validated HERE at the journal boundary —
|
|
164
|
+
# receipt schema, binding against the current record, and attested
|
|
165
|
+
# outcome — so no caller-supplied flag decides trust. The
|
|
166
|
+
# coordinator additionally performs content-level attestation and
|
|
167
|
+
# file verification.
|
|
168
|
+
def transition_service_request(request_id, state:, receipt: nil, validated: false)
|
|
169
|
+
unless %w[succeeded failed uncertain rejected failed-settled].include?(state)
|
|
170
|
+
raise ArgumentError, "invalid service request state"
|
|
171
|
+
end
|
|
172
|
+
current = service_request(request_id)
|
|
173
|
+
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless current
|
|
174
|
+
# Terminal states are immutable; failed-settled is reached only
|
|
175
|
+
# from a dispatched (failed, uncertain, or consumed-rejected)
|
|
176
|
+
# effect with a validated no-effect receipt.
|
|
177
|
+
consumed_rejected = current["state"] == "rejected" && current["consumed"] != false
|
|
178
|
+
if %w[succeeded failed-settled].include?(current["state"]) ||
|
|
179
|
+
(current["state"] == "rejected" && !consumed_rejected) ||
|
|
180
|
+
(consumed_rejected && state != "failed-settled")
|
|
181
|
+
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
182
|
+
end
|
|
183
|
+
validate_terminal_receipt!(current, state, receipt) if terminal_state?(state)
|
|
184
|
+
stamped = current.merge("state" => state, "receipt" => receipt)
|
|
185
|
+
stamped["failed_at"] = Time.now.utc.iso8601(9) if state == "failed"
|
|
186
|
+
update_service_request(request_id, expected: current,
|
|
187
|
+
replacement: stamped,
|
|
188
|
+
event_type: "service_transition")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
TERMINAL_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
|
|
192
|
+
input_digest operation outcome project_id request_id target transport].freeze
|
|
193
|
+
TERMINAL_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
|
|
194
|
+
input_digest target candidate_head executor_uid transport].freeze
|
|
195
|
+
|
|
196
|
+
def terminal_state?(state)
|
|
197
|
+
%w[succeeded failed failed-settled].include?(state)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_terminal_receipt!(current, state, receipt)
|
|
201
|
+
unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
|
|
202
|
+
raise AttemptErrors::ReceiptRejected,
|
|
203
|
+
"Service terminal receipt has invalid fields"
|
|
204
|
+
end
|
|
205
|
+
TERMINAL_BINDING_FIELDS.each do |key|
|
|
206
|
+
unless receipt[key] == current[key]
|
|
207
|
+
raise AttemptErrors::ReceiptRejected, "Service terminal receipt does not match #{key}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
expected_outcome = (state == "failed-settled") ? "failed" : state
|
|
211
|
+
evidence_items = receipt["evidence"]
|
|
212
|
+
valid_evidence = evidence_items.is_a?(Array) && !evidence_items.empty? &&
|
|
213
|
+
evidence_items.all? do |item|
|
|
214
|
+
item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
|
|
215
|
+
item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
|
|
216
|
+
item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
217
|
+
end
|
|
218
|
+
executor_valid = receipt["executor_uid"].is_a?(Integer) && receipt["executor_uid"] >= 0
|
|
219
|
+
unless receipt["outcome"] == expected_outcome && executor_valid && valid_evidence
|
|
220
|
+
raise AttemptErrors::ReceiptRejected, "Service terminal receipt has invalid executor or evidence"
|
|
221
|
+
end
|
|
222
|
+
if state == "failed-settled" &&
|
|
223
|
+
!receipt["evidence"].any? { |item| item["ref"].end_with?("no-effect") }
|
|
224
|
+
raise AttemptErrors::ReceiptRejected,
|
|
225
|
+
"Service settlement requires a no-effect attestation artifact"
|
|
226
|
+
end
|
|
227
|
+
repo_root = File.realpath(@repo_root)
|
|
228
|
+
receipt["evidence"].each do |item|
|
|
229
|
+
path = File.expand_path(item["ref"], repo_root)
|
|
230
|
+
real = begin
|
|
231
|
+
File.realpath(path)
|
|
232
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
233
|
+
raise AttemptErrors::ReceiptRejected,
|
|
234
|
+
"Service terminal receipt evidence is unverifiable: #{item["ref"]}"
|
|
235
|
+
end
|
|
236
|
+
intact = begin
|
|
237
|
+
real.start_with?(repo_root + File::SEPARATOR) &&
|
|
238
|
+
Digest::SHA256.file(real).hexdigest == item["sha256"]
|
|
239
|
+
rescue Errno::EACCES, Errno::ELOOP
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
raise AttemptErrors::ReceiptRejected,
|
|
243
|
+
"Service terminal receipt evidence is unverifiable: #{item["ref"]}" unless intact
|
|
244
|
+
attestation = /^ace-service-attestation request:#{Regexp.escape(current["request_id"])} \
|
|
245
|
+
input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
|
|
246
|
+
attested = File.read(real).scan(attestation).first
|
|
247
|
+
attested_outcome = state == "failed-settled" ? "failed" : state
|
|
248
|
+
unless attested && attested.first == attested_outcome &&
|
|
249
|
+
(state == "failed-settled" ? attested[2] == "true" : true)
|
|
250
|
+
raise AttemptErrors::ReceiptRejected,
|
|
251
|
+
"Service terminal receipt evidence does not attest #{attested_outcome}: #{item["ref"]}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def service_request(request_id)
|
|
257
|
+
validate_request_id!(request_id)
|
|
258
|
+
value = ref_value
|
|
259
|
+
return nil unless value
|
|
260
|
+
out, stderr, status = git("show", "#{value}:#{service_request_path(request_id)}")
|
|
261
|
+
return JSON.parse(out) if status.success?
|
|
262
|
+
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
263
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request: #{stderr}"
|
|
264
|
+
rescue JSON::ParserError
|
|
265
|
+
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Every service request recorded in this evidence ref, whatever the
|
|
269
|
+
# owning assignment. Backs the journal-wide request-ID and
|
|
270
|
+
# authorization-consumption checks.
|
|
271
|
+
# Another live request holding the same exact authorization for the
|
|
272
|
+
# same operation, project and target. Only requests that were born
|
|
273
|
+
# rejected (never claimed, never dispatched) leave the decision
|
|
274
|
+
# unconsumed: a withdrawn or failed claim keeps it consumed until
|
|
275
|
+
# attributable evidence proves the effect did not occur.
|
|
276
|
+
def authorization_conflict(binding)
|
|
277
|
+
authorization = binding["authorization"]
|
|
278
|
+
return nil unless authorization.is_a?(String) && !authorization.empty?
|
|
279
|
+
service_request_records.find do |other|
|
|
280
|
+
next false if other["request_id"] == binding.fetch("request_id")
|
|
281
|
+
next false if other["state"] == "rejected" && other["consumed"] == false
|
|
282
|
+
# A settled failure frees the exact authorization only while its
|
|
283
|
+
# no-effect evidence remains verifiable in the repository.
|
|
284
|
+
next false if other["state"] == "failed-settled" && settlement_evidence_intact?(other)
|
|
285
|
+
other["authorization"] == authorization &&
|
|
286
|
+
other["operation"] == binding.fetch("operation") &&
|
|
287
|
+
other["project_id"] == binding.fetch("project_id") &&
|
|
288
|
+
other["target"] == binding.fetch("target")
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def settlement_evidence_intact?(record)
|
|
293
|
+
receipt = record["receipt"]
|
|
294
|
+
return false unless receipt.is_a?(Hash)
|
|
295
|
+
repo_root = File.realpath(@repo_root)
|
|
296
|
+
Array(receipt["evidence"]).all? do |item|
|
|
297
|
+
path = File.expand_path(item["ref"].to_s, repo_root)
|
|
298
|
+
real = begin
|
|
299
|
+
File.realpath(path)
|
|
300
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
301
|
+
next false
|
|
302
|
+
end
|
|
303
|
+
begin
|
|
304
|
+
real.start_with?(repo_root + File::SEPARATOR) &&
|
|
305
|
+
Digest::SHA256.file(real).hexdigest == item["sha256"]
|
|
306
|
+
rescue Errno::EACCES, Errno::ELOOP
|
|
307
|
+
false
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def service_request_records
|
|
313
|
+
value = ref_value
|
|
314
|
+
return [] unless value
|
|
315
|
+
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/requests/")
|
|
316
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request index: #{stderr}" unless status.success?
|
|
317
|
+
paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.filter_map do |path|
|
|
318
|
+
content, error, read_status = git("show", "#{value}:#{path}")
|
|
319
|
+
unless read_status.success?
|
|
320
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request #{path}: #{error}"
|
|
321
|
+
end
|
|
322
|
+
JSON.parse(content)
|
|
323
|
+
end
|
|
324
|
+
rescue JSON::ParserError
|
|
325
|
+
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request index"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def service_requests(assignment_id)
|
|
329
|
+
request_ids = read_events(assignment_id)
|
|
330
|
+
.select { |event| event["type"] == "service_claim" }
|
|
331
|
+
.map { |event| event.dig("payload", "request_id") }.compact.uniq
|
|
332
|
+
request_ids.filter_map { |request_id| service_request(request_id) }
|
|
333
|
+
end
|
|
334
|
+
|
|
124
335
|
# All attempts derivable from the journal, reconstructed from intent
|
|
125
336
|
# and process_start facts with journal-authoritative state. The
|
|
126
337
|
# journal is the owner of attempt-state interpretation.
|
|
@@ -161,11 +372,80 @@ module Ace
|
|
|
161
372
|
|
|
162
373
|
paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
|
|
163
374
|
raise AttemptErrors::EvidenceUnavailable, "Cannot discover journal assignments: #{stderr}" unless status.success?
|
|
164
|
-
paths.lines.map { |path| path.strip.delete_prefix("execution/") }
|
|
375
|
+
paths.lines.map { |path| path.strip.delete_prefix("execution/") }
|
|
376
|
+
.reject { |id| id == "requests" }.sort
|
|
165
377
|
end
|
|
166
378
|
|
|
167
379
|
private
|
|
168
380
|
|
|
381
|
+
def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
382
|
+
validate_request_id!(request_id)
|
|
383
|
+
with_lock do
|
|
384
|
+
CAS_ATTEMPTS.times do
|
|
385
|
+
old = ref_value
|
|
386
|
+
ensure_checkout!
|
|
387
|
+
old = ref_value if old.nil?
|
|
388
|
+
sync_checkout(old)
|
|
389
|
+
# Guards (for example authorization consumption) re-run inside
|
|
390
|
+
# the lock against the ref being committed, so a racing claim
|
|
391
|
+
# cannot slip through between the check and the CAS.
|
|
392
|
+
if guard
|
|
393
|
+
conflict = guard.call
|
|
394
|
+
if conflict
|
|
395
|
+
raise AttemptErrors::Conflict,
|
|
396
|
+
"Authorization reference already consumed by request #{conflict.fetch("request_id")}"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
path = File.join(checkout_dir, service_request_path(request_id))
|
|
400
|
+
existing = File.exist?(path) ? JSON.parse(File.read(path)) : nil
|
|
401
|
+
if expected.nil? && existing
|
|
402
|
+
return existing if existing.except("state", "receipt", "reason", "claimed_at") ==
|
|
403
|
+
replacement.except("state", "receipt", "reason", "claimed_at")
|
|
404
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
405
|
+
end
|
|
406
|
+
if expected && existing != expected
|
|
407
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
|
|
408
|
+
end
|
|
409
|
+
terminal = existing && %w[succeeded failed rejected].include?(existing["state"])
|
|
410
|
+
settlement = existing && existing["state"] == "failed" && replacement["state"] == "failed-settled"
|
|
411
|
+
if expected && existing && terminal && !settlement
|
|
412
|
+
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
416
|
+
File.write(path, JSON.pretty_generate(replacement))
|
|
417
|
+
assignment_id = replacement.fetch("assignment_id")
|
|
418
|
+
attempt_id = replacement.fetch("attempt_id")
|
|
419
|
+
prior = read_events(assignment_id).reverse
|
|
420
|
+
.find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
|
|
421
|
+
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
422
|
+
"input_digest" => replacement.fetch("input_digest"),
|
|
423
|
+
"receipt_digest" => replacement["receipt"] &&
|
|
424
|
+
Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
425
|
+
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
426
|
+
payload: payload, previous_digest: prior)
|
|
427
|
+
write_event_files(assignment_id, [event])
|
|
428
|
+
git!("-C", checkout_dir, "add", "--", service_request_path(request_id),
|
|
429
|
+
"execution/#{assignment_id}/events")
|
|
430
|
+
state = replacement.fetch("state")
|
|
431
|
+
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
432
|
+
"commit", "-m", "evidence: service request #{request_id} #{state}")
|
|
433
|
+
commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
434
|
+
return replacement.merge("journal_commit" => commit) if update_ref_cas(commit, old)
|
|
435
|
+
end
|
|
436
|
+
raise AttemptErrors::EvidenceUnavailable, "Service request ref stayed conflicting"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def service_request_path(request_id)
|
|
441
|
+
"execution/requests/#{request_id}.json"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def validate_request_id!(request_id)
|
|
445
|
+
return if request_id.is_a?(String) && request_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
|
|
446
|
+
raise ArgumentError, "invalid service request ID"
|
|
447
|
+
end
|
|
448
|
+
|
|
169
449
|
# Latest lifecycle state implied by the events, or nil when the
|
|
170
450
|
# events do not describe a full attempt (intent missing).
|
|
171
451
|
def derive_state(events)
|
|
@@ -387,7 +667,7 @@ module Ace
|
|
|
387
667
|
def git!(*argv)
|
|
388
668
|
out, stderr, status = git(*argv)
|
|
389
669
|
unless status.success?
|
|
390
|
-
raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(
|
|
670
|
+
raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(" ")} failed: #{stderr}"
|
|
391
671
|
end
|
|
392
672
|
|
|
393
673
|
[out, stderr, status]
|
|
@@ -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,6 +23,10 @@ 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)
|
|
@@ -39,6 +45,260 @@ module Ace
|
|
|
39
45
|
|
|
40
46
|
attr_reader :store
|
|
41
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
|
+
|
|
42
302
|
# Start a scoped attempt for an assignment.
|
|
43
303
|
#
|
|
44
304
|
# A repeated identical start returns the existing active attempt; a
|
|
@@ -207,13 +467,14 @@ module Ace
|
|
|
207
467
|
expected_operation = case kind
|
|
208
468
|
when "review-collection" then "review-collect"
|
|
209
469
|
when "review-approval" then "review"
|
|
210
|
-
else check_name == "tests" ? "test" : check_name
|
|
470
|
+
else (check_name == "tests") ? "test" : check_name
|
|
211
471
|
end
|
|
212
472
|
unless data["campaign"].nil? && data["operation"] == expected_operation
|
|
213
473
|
raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
|
|
214
474
|
end
|
|
215
|
-
required_check = kind == "review-collection" ? "review-execution" : check_name
|
|
216
|
-
|
|
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
|
|
217
478
|
raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
|
|
218
479
|
end
|
|
219
480
|
receipt = Models::ExecutionReceipt.from_h(data)
|
|
@@ -319,7 +580,7 @@ module Ace
|
|
|
319
580
|
return nil if assignment && !assignment.managed?
|
|
320
581
|
|
|
321
582
|
return journal_for.derived_attempts(assignment_id)
|
|
322
|
-
|
|
583
|
+
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
323
584
|
end
|
|
324
585
|
|
|
325
586
|
journal_for.assignment_ids.each do |journal_assignment_id|
|
|
@@ -407,7 +668,7 @@ module Ace
|
|
|
407
668
|
recorded = reconciler.recorded_runtime(attempt)
|
|
408
669
|
unless data.dig("producer", "runtime") == recorded
|
|
409
670
|
raise AttemptErrors::ReceiptRejected,
|
|
410
|
-
"Receipt runtime #{data.dig(
|
|
671
|
+
"Receipt runtime #{data.dig("producer", "runtime").inspect} does not match the recorded " \
|
|
411
672
|
"execution boundary #{recorded.inspect}"
|
|
412
673
|
end
|
|
413
674
|
|
|
@@ -449,7 +710,8 @@ module Ace
|
|
|
449
710
|
@reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
|
|
450
711
|
end
|
|
451
712
|
|
|
452
|
-
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(
|
|
453
715
|
attempt_id: attempt_id,
|
|
454
716
|
assignment_id: assignment.id,
|
|
455
717
|
scope: scope,
|
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-
|
|
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
|
|
@@ -141,14 +141,14 @@ dependencies:
|
|
|
141
141
|
requirements:
|
|
142
142
|
- - "~>"
|
|
143
143
|
- !ruby/object:Gem::Version
|
|
144
|
-
version: '0.
|
|
144
|
+
version: '0.18'
|
|
145
145
|
type: :runtime
|
|
146
146
|
prerelease: false
|
|
147
147
|
version_requirements: !ruby/object:Gem::Requirement
|
|
148
148
|
requirements:
|
|
149
149
|
- - "~>"
|
|
150
150
|
- !ruby/object:Gem::Version
|
|
151
|
-
version: '0.
|
|
151
|
+
version: '0.18'
|
|
152
152
|
- !ruby/object:Gem::Dependency
|
|
153
153
|
name: ace-support-test-helpers
|
|
154
154
|
requirement: !ruby/object:Gem::Requirement
|