ace-assign 0.60.0 → 0.61.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a8f387dd91c6da87ca5eb3712cb5fb71051a0326dfa13cf0d9d91e054c1cc00
4
- data.tar.gz: 4698ad04a950a1401caec3387ef085e8c66512a07e9dd5af168446d80e7d89eb
3
+ metadata.gz: d55939eec104b31f029b0e8a703977c5b06afc67a82ddbc1b6274c9fa471bd21
4
+ data.tar.gz: e9f910d2474dbbdee91b75760c25711f32169c0a8e8ea476ef01ec1dd9ac8526
5
5
  SHA512:
6
- metadata.gz: 89c8df3b2e0823c195dfc3d3b59fbcc58e79c97b3978e0db2e4395c83e86307e8d450a637e633b9aa947efeb79ff94ba78307bc24de577effb8cb55b53e9b8f5
7
- data.tar.gz: dd2e0a73591ea2e646c9c8164cb56269ff6353860005acdfb57bc268ca8e2d5c89b77b3a19af8a1b895dcfa8716199ae0272a1705387dc4c5f5bda424c062668
6
+ metadata.gz: 86d8c02efe5e126b34d683eba6c81dc1e49367e2f847c08f3cad560bde57cfebae491745346b3cfd852fccbf067e7581f01c6175068a88e7d41df24b363cab35
7
+ data.tar.gz: 27c7e06bfc8e553615416ea1ec36f3bc7d4a818bf7e5c844d16fb130ef6384e8132af30e5271f529679c69714e6b222620b4bb8a8e40f2c20755735df855aa22
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.61.1] - 2026-10-02
11
+
12
+ ### Fixed
13
+ - Historical evidence reads (`--historical-head`) validate acceptance from the append-only journal without re-hashing receipt artifacts, so campaigns that resolve findings mid-campaign (archiving and annotating the referenced files) can still finish; current-head reads keep full artifact re-verification.
14
+
15
+
16
+ ## [0.61.0] - 2026-10-02
17
+
18
+ ### Added
19
+
20
+ - Journal-backed service request claims, terminal receipts, and uncertainty
21
+ tracking. Assignment evidence now exposes unresolved service effects and
22
+ the latest evidence ref commit without changing the candidate head.
23
+
24
+ ### Fixed
25
+
26
+ - Validate terminal service receipts against the exact claimed request and
27
+ executor evidence before accepting them in the assignment journal.
28
+
29
+ ### Technical
30
+
31
+ - Updated the ace-tmux dependency to the 0.18 adapter release line.
32
+
10
33
  ## [0.60.0] - 2026-10-01
11
34
 
12
35
  ### 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
 
@@ -17,6 +17,7 @@ module Ace
17
17
  TYPES = %w[
18
18
  intent process_start receipt_accepted transition
19
19
  candidate_invalidated reconciliation
20
+ service_claim service_transition
20
21
  ].freeze
21
22
 
22
23
  # Build an event chained to a previous digest.
@@ -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/") }.sort
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(' ')} failed: #{stderr}"
670
+ raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(" ")} failed: #{stderr}"
391
671
  end
392
672
 
393
673
  [out, stderr, status]
@@ -64,9 +64,15 @@ module Ace
64
64
  receipt
65
65
  end
66
66
  # Recheck the source artifacts/checks of an already accepted receipt.
67
- def verify_accepted_evidence!(data, live_head:, repo_root:)
67
+ # Historical reads (recorded at a past head) validate acceptance from
68
+ # the append-only journal without re-hashing artifacts: the review
69
+ # lifecycle legitimately archives and annotates finding files after
70
+ # collection, and `.ace-local` working files are disposable, so their
71
+ # current existence proves nothing about an accepted past receipt.
72
+ # Current-head reads keep full artifact re-verification.
73
+ def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
68
74
  verify_head(data, live_head)
69
- verify_artifacts(data, repo_root)
75
+ verify_artifacts(data, repo_root) unless historical
70
76
  verify_checks(data)
71
77
  verify_review(data)
72
78
  end
@@ -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
- unless kind == "review-approval" || Array(data["checks"]).any? { |check| check["name"] == required_check && check["verdict"] == "passed" }
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)
@@ -224,7 +485,8 @@ module Ace
224
485
  unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
225
486
  raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
226
487
  end
227
- @verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root)
488
+ @verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root,
489
+ historical: !historical_head.nil?)
228
490
  {"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
229
491
  "historical" => !historical_head.nil?,
230
492
  "operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
@@ -319,7 +581,7 @@ module Ace
319
581
  return nil if assignment && !assignment.managed?
320
582
 
321
583
  return journal_for.derived_attempts(assignment_id)
322
- .find { |candidate| candidate.attempt_id == attempt_id }
584
+ .find { |candidate| candidate.attempt_id == attempt_id }
323
585
  end
324
586
 
325
587
  journal_for.assignment_ids.each do |journal_assignment_id|
@@ -407,7 +669,7 @@ module Ace
407
669
  recorded = reconciler.recorded_runtime(attempt)
408
670
  unless data.dig("producer", "runtime") == recorded
409
671
  raise AttemptErrors::ReceiptRejected,
410
- "Receipt runtime #{data.dig('producer', 'runtime').inspect} does not match the recorded " \
672
+ "Receipt runtime #{data.dig("producer", "runtime").inspect} does not match the recorded " \
411
673
  "execution boundary #{recorded.inspect}"
412
674
  end
413
675
 
@@ -449,7 +711,8 @@ module Ace
449
711
  @reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
450
712
  end
451
713
 
452
- def reserve(assignment, scope, project, identity, base_head, attempt_id) binding = Models::AttemptBinding.new(
714
+ def reserve(assignment, scope, project, identity, base_head, attempt_id)
715
+ binding = Models::AttemptBinding.new(
453
716
  attempt_id: attempt_id,
454
717
  assignment_id: assignment.id,
455
718
  scope: scope,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.60.0"
5
+ VERSION = "0.61.1"
6
6
  end
7
7
  end
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.60.0
4
+ version: 0.61.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-01 00:00:00.000000000 Z
10
+ date: 2026-10-03 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.17'
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.17'
151
+ version: '0.18'
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: ace-support-test-helpers
154
154
  requirement: !ruby/object:Gem::Requirement