ace-assign 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +18 -0
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +32 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  12. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  13. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  14. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  15. data/lib/ace/assign/cli/commands/status.rb +24 -1
  16. data/lib/ace/assign/cli.rb +30 -2
  17. data/lib/ace/assign/models/assignment.rb +21 -4
  18. data/lib/ace/assign/models/attempt.rb +178 -0
  19. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  20. data/lib/ace/assign/models/evidence_event.rb +82 -0
  21. data/lib/ace/assign/models/execution_receipt.rb +135 -0
  22. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  23. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  24. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  25. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  26. data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
  27. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  28. data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
  29. data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
  30. data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
  31. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  32. data/lib/ace/assign/version.rb +1 -1
  33. data/lib/ace/assign.rb +56 -0
  34. metadata +20 -2
@@ -0,0 +1,544 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Organisms
9
+ # Authoritative coordinator for assignment attempts.
10
+ #
11
+ # The coordinator is the only authority allowed to reserve and start
12
+ # attempts, accept authoritative receipts, transition attempt state,
13
+ # invalidate stale candidate evidence, and write accepted journal
14
+ # events. It composes the attempt store (local ownership), the evidence
15
+ # journal (managed Git-ref evidence), the identity resolver (trusted
16
+ # boundary), and the receipt verifier (acceptance rules).
17
+ #
18
+ # Guarantee: only one active attempt owns an assignment + subtree;
19
+ # `base_head` is captured exactly once at start; `candidate_head` is
20
+ # pinned only when candidate-bound evidence is accepted and any change
21
+ # to the candidate revision invalidates prior candidate authorization;
22
+ # `journal_commit` is tracked separately from both.
23
+ class AttemptCoordinator
24
+ # @param cache_base [String, nil] Assignment cache base
25
+ # @param repo_root [String, nil] Candidate repository root (default: project root)
26
+ # @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
27
+ # @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
28
+ # @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
29
+ def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil)
30
+ @manager = Molecules::AssignmentManager.new(cache_base: cache_base)
31
+ @store = @manager.attempt_store
32
+ @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
33
+ @journal = journal
34
+ @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
35
+ @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
36
+ end
37
+
38
+ attr_reader :store
39
+
40
+ # Start a scoped attempt for an assignment.
41
+ #
42
+ # A repeated identical start returns the existing active attempt; a
43
+ # conflicting or scope-overlapping binding raises
44
+ # {AttemptErrors::Conflict} without ever launching a second writer.
45
+ # Active attempts are resolved from the local store AND, for managed
46
+ # assignments, from the authoritative journal, so a lost local record
47
+ # cannot admit a competing writer.
48
+ #
49
+ # @param assignment_id [String] Assignment ID
50
+ # @param step [String] Step/subtree scope
51
+ # @param project_id [String] Project the attempt executes in
52
+ # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
53
+ # @return [Models::Attempt] Active attempt (reused, recovered, or newly reserved)
54
+ def start(assignment_id:, step:, project_id:, identity: nil)
55
+ assignment = @manager.load(assignment_id)
56
+ raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
57
+
58
+ scope = Atoms::AssignmentScope.canonicalize(step)
59
+ project = project_id.to_s.strip
60
+ raise Error, "attempt start requires --project ID" if project.empty?
61
+
62
+ identity ||= @identity_resolver.resolve
63
+ base_head = candidate_head!
64
+
65
+ @store.with_lock(assignment_id) do
66
+ journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
67
+
68
+ existing = @store.active(assignment_id, scope) ||
69
+ journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
70
+ if existing
71
+ if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
72
+ existing.binding.actor == identity.actor && existing.binding.role == identity.role
73
+ return existing
74
+ end
75
+
76
+ raise AttemptErrors::Conflict,
77
+ "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
78
+ "(project #{existing.binding.project_id}); refusing to launch a second writer"
79
+ end
80
+
81
+ active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
82
+ active_scopes.concat(journal_actives.map(&:binding))
83
+ blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
84
+ if blocker
85
+ raise AttemptErrors::Conflict,
86
+ "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
87
+ "refusing overlapping writers"
88
+ end
89
+
90
+ attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
91
+ events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
92
+ attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
93
+ if attempt.managed?
94
+ commit = journal_for.append(
95
+ assignment_id: assignment_id,
96
+ attempt_id: attempt.attempt_id,
97
+ events: events
98
+ )
99
+ attempt = attempt.with(journal_commit: commit)
100
+ attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
101
+ else
102
+ attempt = attempt.with(events: events)
103
+ end
104
+
105
+ @store.save(attempt)
106
+ @store.claim(assignment_id, scope, attempt)
107
+ attempt
108
+ end
109
+ end
110
+
111
+ # Accept a receipt for an attempt and transition it.
112
+ #
113
+ # @param attempt_id [String] Attempt ID
114
+ # @param receipt_path [String] Path to the receipt JSON file
115
+ # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
116
+ # @return [Models::Attempt] Updated attempt
117
+ def finish(attempt_id:, receipt_path:, identity: nil)
118
+ data = read_receipt_file(receipt_path)
119
+ assignment_id = data["assignment_id"].to_s
120
+
121
+ # Serialize load-validate-accept so concurrent finishes observe one
122
+ # consistent state and cannot persist contradictory terminal outcomes.
123
+ @store.with_lock(assignment_id) do
124
+ attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
125
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
126
+
127
+ ensure_journal_consistent!(attempt) if attempt.managed?
128
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
129
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
130
+
131
+ identity ||= @identity_resolver.resolve
132
+ live_head = candidate_head!
133
+
134
+ attempt = invalidate_stale_candidate(attempt, live_head)
135
+
136
+ receipt = @verifier.verify!(
137
+ data,
138
+ attempt: attempt,
139
+ identity: identity,
140
+ live_head: live_head,
141
+ repo_root: @repo_root
142
+ )
143
+ if @verifier.external_effect?(receipt.operation)
144
+ unless attempt.managed?
145
+ raise AttemptErrors::InvalidState,
146
+ "Taskless attempts cannot record external effects without managed evidence"
147
+ end
148
+
149
+ require_review_evidence(attempt, live_head)
150
+ end
151
+
152
+ accept(attempt, receipt, live_head)
153
+ end
154
+ end
155
+
156
+ # Active-attempt projection for status surfaces.
157
+ #
158
+ # @param assignment_id [String] Assignment ID
159
+ # @return [Hash, nil] Projection fields or nil when no attempt exists
160
+ def status(assignment_id)
161
+ attempts = @store.list(assignment_id)
162
+ attempt = attempts.find(&:active?) || attempts.first
163
+ return nil unless attempt
164
+
165
+ attempt.projection
166
+ end
167
+
168
+ # Reconcile an interrupted attempt.
169
+ #
170
+ # Running attempts are classified conservatively (stopped before
171
+ # process start, uncertain when an effect cannot be proven either
172
+ # way, still running only for verifiably live processes). Uncertain
173
+ # attempts resolve only against a verified receipt attributed to the
174
+ # recorded execution boundary. Reconciliation never replays merge,
175
+ # publish, or deploy effects.
176
+ #
177
+ # @param attempt_id [String] Attempt ID
178
+ # @param receipt_path [String, nil] Receipt JSON for resolving uncertainty
179
+ # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
180
+ # @return [Models::Attempt] Updated attempt
181
+ def reconcile(attempt_id:, receipt_path: nil, identity: nil)
182
+ probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
183
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
184
+
185
+ @store.with_lock(probe.binding.assignment_id) do
186
+ attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
187
+ recover_managed_attempt(probe.binding.assignment_id, attempt_id)
188
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
189
+
190
+ derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
191
+ # The journal is authoritative for managed attempts: uncertainty
192
+ # journaled after the local save drives reconciliation.
193
+ attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
194
+
195
+ if attempt.terminal?
196
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
197
+ end
198
+
199
+ return classify_running(attempt) if attempt.state == "running"
200
+
201
+ identity ||= @identity_resolver.resolve
202
+ resolve_uncertain(attempt, receipt_path, identity)
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ # Attempt IDs must be unique per assignment AND globally: records,
209
+ # journal events, and reconciliation lookups key on them. Allocation
210
+ # runs under the assignment lock, avoids journal-known IDs, and
211
+ # claims the ID atomically in the cross-assignment registry.
212
+ def unique_attempt_id(assignment_id, taken_ids = [])
213
+ base = Ace::B36ts.now
214
+ suffix = 0
215
+ 100.times do
216
+ candidate = suffix.zero? ? base : "#{base}#{suffix.to_s(36)}"
217
+ suffix += 1
218
+ next if taken_ids.include?(candidate)
219
+ next unless @store.load(assignment_id, candidate).nil?
220
+ return candidate if @store.reserve_id(candidate)
221
+ end
222
+
223
+ raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
224
+ end
225
+
226
+ # Ancestors and descendants overlap: an active attempt for 010 owns
227
+ # its 010.01 subtree, and vice versa.
228
+ def scopes_overlap?(left, right)
229
+ return true if Atoms::AssignmentScope.equal?(left, right)
230
+
231
+ left.start_with?("#{right}.") || right.start_with?("#{left}.")
232
+ end
233
+
234
+ # Active attempts derived from the authoritative journal, used when
235
+ # the local record may be missing (lost or wiped cache). Derivation
236
+ # lives in the journal (owner of attempt-state interpretation).
237
+ def active_journal_attempts(assignment)
238
+ journal_for.active_attempts(assignment.id)
239
+ end
240
+
241
+ # Recover a managed attempt from the journal when the local record is
242
+ # missing. With an assignment ID only that journal is consulted;
243
+ # without one, assignment IDs are discovered from the evidence ref
244
+ # itself, so a fully lost cache still reconciles.
245
+ def recover_managed_attempt(assignment_id, attempt_id)
246
+ if assignment_id
247
+ assignment = @manager.load(assignment_id)
248
+ # A surviving taskless record never consults the journal; a lost
249
+ # record falls through to journal evidence (only managed attempts
250
+ # are journaled).
251
+ return nil if assignment && !assignment.managed?
252
+
253
+ return journal_for.derived_attempts(assignment_id)
254
+ .find { |candidate| candidate.attempt_id == attempt_id }
255
+ end
256
+
257
+ journal_for.assignment_ids.each do |journal_assignment_id|
258
+ attempt = journal_for.derived_attempts(journal_assignment_id)
259
+ .find { |candidate| candidate.attempt_id == attempt_id }
260
+ return attempt if attempt
261
+ end
262
+
263
+ nil
264
+ end
265
+
266
+ # The journal is authoritative for managed attempts: a crash after a
267
+ # journaled terminal/uncertain event but before the local save must
268
+ # not admit contradictory transitions.
269
+ def ensure_journal_consistent!(attempt, allow_uncertain: false)
270
+ derived = journal_for.derived_attempts(attempt.binding.assignment_id)
271
+ .find { |candidate| candidate.attempt_id == attempt.attempt_id }
272
+ return if derived.nil?
273
+
274
+ journal_state = derived.state
275
+ if %w[succeeded failed stopped].include?(journal_state)
276
+ raise AttemptErrors::InvalidState,
277
+ "Journal shows attempt #{attempt.attempt_id} is #{journal_state}; accepted history is immutable"
278
+ end
279
+ return derived if allow_uncertain || journal_state != "uncertain" || attempt.uncertain?
280
+
281
+ raise AttemptErrors::InvalidState,
282
+ "Journal shows attempt #{attempt.attempt_id} is uncertain; reconcile before finishing"
283
+ end
284
+
285
+ # After appending our start events, revalidate ownership: a
286
+ # coordinator with a separate cache directory may have claimed the
287
+ # same scope between our read and our append (journal CAS replay
288
+ # merges both). The loser records a stopped transition and refuses.
289
+ def yield_lost_ownership_race(attempt, assignment_id, scope)
290
+ rival = journal_for.active_attempts(assignment_id).find do |other|
291
+ other.attempt_id != attempt.attempt_id && scopes_overlap?(other.binding.scope, scope)
292
+ end
293
+ return attempt if rival.nil?
294
+
295
+ stopped = append_events(attempt, [transition_event(attempt, "stopped", "lost ownership race to #{rival.attempt_id}")])
296
+ stopped = stopped.transition("stopped")
297
+ @store.save(stopped)
298
+ raise AttemptErrors::Conflict,
299
+ "Active attempt #{rival.attempt_id} owns overlapping scope #{rival.binding.scope} on #{assignment_id}; " \
300
+ "our start #{attempt.attempt_id} was recorded stopped without executing"
301
+ end
302
+
303
+ # Conservative classification of a running attempt after interruption.
304
+ def classify_running(attempt)
305
+ case reconciler.classify(attempt)
306
+ when :live
307
+ raise AttemptErrors::InvalidState,
308
+ "Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
309
+ when :stopped
310
+ attempt = append_events(attempt, [transition_event(attempt, "stopped", "interrupted before process start")])
311
+ attempt = attempt.transition("stopped")
312
+ @store.save(attempt)
313
+ @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id)
314
+ attempt
315
+ else
316
+ attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
317
+ attempt = attempt.transition("uncertain")
318
+ @store.save(attempt)
319
+ attempt
320
+ end
321
+ end
322
+
323
+ # Resolve an uncertain attempt against a verified, boundary-attributed
324
+ # receipt. Never resolves by assumption.
325
+ def resolve_uncertain(attempt, receipt_path, identity)
326
+ unless attempt.uncertain?
327
+ raise AttemptErrors::InvalidState,
328
+ "Attempt #{attempt.attempt_id} is #{attempt.state}; reconciliation targets uncertain attempts"
329
+ end
330
+ unless receipt_path
331
+ raise AttemptErrors::InvalidState,
332
+ "Reconciliation requires --receipt FILE; uncertain attempts are never resolved by assumption"
333
+ end
334
+
335
+ data = read_receipt_file(receipt_path)
336
+ live_head = candidate_head!
337
+ attempt = invalidate_stale_candidate(attempt, live_head)
338
+
339
+ recorded = reconciler.recorded_runtime(attempt)
340
+ unless data.dig("producer", "runtime") == recorded
341
+ raise AttemptErrors::ReceiptRejected,
342
+ "Receipt runtime #{data.dig('producer', 'runtime').inspect} does not match the recorded " \
343
+ "execution boundary #{recorded.inspect}"
344
+ end
345
+
346
+ receipt = @verifier.verify!(
347
+ data,
348
+ attempt: attempt,
349
+ identity: identity,
350
+ live_head: live_head,
351
+ repo_root: @repo_root
352
+ )
353
+ if @verifier.external_effect?(receipt.operation)
354
+ unless attempt.managed?
355
+ raise AttemptErrors::InvalidState,
356
+ "Taskless attempts cannot record external effects without managed evidence"
357
+ end
358
+
359
+ require_review_evidence(attempt, live_head)
360
+ end
361
+
362
+ reconciliation = Models::EvidenceEvent.build(
363
+ type: "reconciliation",
364
+ attempt_id: attempt.attempt_id,
365
+ payload: {"resolution" => receipt.verdict, "receipt_digest" => receipt.digest},
366
+ previous_digest: last_event_digest(attempt)
367
+ )
368
+ accept(attempt, receipt, live_head, [reconciliation])
369
+ end
370
+
371
+ def transition_event(attempt, to, reason)
372
+ Models::EvidenceEvent.build(
373
+ type: "transition",
374
+ attempt_id: attempt.attempt_id,
375
+ payload: {"from" => attempt.state, "to" => to, "reason" => reason},
376
+ previous_digest: last_event_digest(attempt)
377
+ )
378
+ end
379
+
380
+ def reconciler
381
+ @reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
382
+ end
383
+
384
+ def reserve(assignment, scope, project, identity, base_head, attempt_id) binding = Models::AttemptBinding.new(
385
+ attempt_id: attempt_id,
386
+ assignment_id: assignment.id,
387
+ scope: scope,
388
+ project_id: project,
389
+ task_id: assignment.task_id,
390
+ actor: identity.actor,
391
+ role: identity.role,
392
+ runtime: identity.runtime,
393
+ base_head: base_head,
394
+ evidence_git_ref: assignment.managed? ? journal_for.ref : nil,
395
+ created_at: Time.now.utc
396
+ )
397
+ Models::Attempt.new(binding: binding, state: "reserved").transition("running")
398
+ end
399
+
400
+ def start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
401
+ intent = Models::EvidenceEvent.build(
402
+ type: "intent",
403
+ attempt_id: attempt_id,
404
+ payload: {
405
+ "assignment_id" => assignment_id,
406
+ "scope" => scope,
407
+ "project_id" => project,
408
+ "task_id" => assignment.task_id,
409
+ "base_head" => base_head
410
+ }
411
+ )
412
+ process_start = Models::EvidenceEvent.build(
413
+ type: "process_start",
414
+ attempt_id: attempt_id,
415
+ payload: {
416
+ "actor" => identity.actor,
417
+ "role" => identity.role,
418
+ "runtime" => identity.runtime,
419
+ "pid" => Process.pid
420
+ },
421
+ previous_digest: intent["digest"]
422
+ )
423
+ [intent, process_start]
424
+ end
425
+
426
+ # A changed candidate SHA invalidates prior candidate evidence and
427
+ # authorizations; the change itself is journaled before anything else.
428
+ def invalidate_stale_candidate(attempt, live_head)
429
+ return attempt if attempt.candidate_head.nil? || attempt.candidate_head == live_head
430
+
431
+ event = Models::EvidenceEvent.build(
432
+ type: "candidate_invalidated",
433
+ attempt_id: attempt.attempt_id,
434
+ payload: {"previous_candidate_head" => attempt.candidate_head, "current_head" => live_head}
435
+ )
436
+ attempt = append_events(attempt, [event])
437
+ attempt = attempt.with(candidate_head: nil, accepted_receipts: [])
438
+ @store.save(attempt)
439
+ attempt
440
+ end
441
+
442
+ def accept(attempt, receipt, live_head, pre_events = [])
443
+ previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
444
+ event = Models::EvidenceEvent.build(
445
+ type: "receipt_accepted",
446
+ attempt_id: attempt.attempt_id,
447
+ payload: {"receipt" => receipt.to_h},
448
+ previous_digest: previous
449
+ )
450
+ attempt = append_events(attempt, pre_events + [event])
451
+
452
+ receipts = attempt.accepted_receipts + [receipt.to_h]
453
+ effects = attempt.effects
454
+ if @verifier.external_effect?(receipt.operation)
455
+ effects += [{"operation" => receipt.operation, "receipt_digest" => receipt.digest}]
456
+ end
457
+
458
+ attempt = attempt.with(
459
+ candidate_head: attempt.candidate_head || receipt.head,
460
+ accepted_receipts: receipts,
461
+ effects: effects,
462
+ state: attempt.state,
463
+ updated_at: Time.now.utc
464
+ )
465
+ attempt = attempt.transition(receipt.verdict) if %w[succeeded failed].include?(receipt.verdict)
466
+
467
+ @store.save(attempt)
468
+ @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
469
+
470
+ attempt
471
+ end
472
+
473
+ def require_review_evidence(attempt, live_head)
474
+ receipts = if attempt.managed?
475
+ journal_for.read_events(attempt.binding.assignment_id)
476
+ .select { |event| event["type"] == "receipt_accepted" }
477
+ .map { |event| event.dig("payload", "receipt") }
478
+ .compact
479
+ else
480
+ attempt.accepted_receipts
481
+ end
482
+ approved = receipts.any? do |candidate|
483
+ candidate["operation"] == Molecules::ReceiptVerifier::REVIEW_OPERATION &&
484
+ candidate["verdict"] == "succeeded" &&
485
+ candidate["head"] == live_head &&
486
+ candidate.dig("review", "verdict") == Molecules::ReceiptVerifier::APPROVED_VERDICT
487
+ end
488
+ return if approved
489
+
490
+ raise AttemptErrors::ReceiptRejected,
491
+ "External effect requires an executed independent review receipt for candidate #{live_head}"
492
+ end
493
+
494
+ def read_receipt_file(path)
495
+ raise AttemptErrors::ReceiptRejected, "Receipt file not found: #{path}" unless File.exist?(path.to_s)
496
+
497
+ data = JSON.parse(File.read(path))
498
+ raise AttemptErrors::ReceiptRejected, "Receipt must be a JSON object" unless data.is_a?(Hash)
499
+
500
+ data
501
+ rescue JSON::ParserError => e
502
+ raise AttemptErrors::ReceiptRejected, "Receipt file is not valid JSON: #{e.message}"
503
+ end
504
+
505
+ def append_events(attempt, events)
506
+ if attempt.managed?
507
+ commit = journal_for.append(
508
+ assignment_id: attempt.binding.assignment_id,
509
+ attempt_id: attempt.attempt_id,
510
+ events: events
511
+ )
512
+ attempt.with(journal_commit: commit)
513
+ else
514
+ attempt.with(events: attempt.events + events)
515
+ end
516
+ end
517
+
518
+ # Digest chain continuation: managed attempts chain through the
519
+ # authoritative journal; taskless attempts chain through the local
520
+ # event trail.
521
+ def last_event_digest(attempt)
522
+ if attempt.managed?
523
+ journal_for.read_events(attempt.binding.assignment_id).last&.dig("digest")
524
+ else
525
+ attempt.events.last&.dig("digest")
526
+ end
527
+ end
528
+
529
+ def journal_for
530
+ @journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
531
+ @journal
532
+ end
533
+
534
+ def candidate_head!
535
+ out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
536
+ head = out.to_s.strip
537
+ return head if status.success? && !head.empty?
538
+
539
+ raise AttemptErrors::EvidenceUnavailable, "Cannot resolve candidate HEAD in #{@repo_root}"
540
+ end
541
+ end
542
+ end
543
+ end
544
+ end
@@ -54,7 +54,7 @@ module Ace
54
54
  steps: steps
55
55
  )
56
56
 
57
- result = @executor.start(job_path)
57
+ result = @executor.start(job_path, task_id: primary_ref)
58
58
  result.merge(
59
59
  skipped_terminal: skipped_terminal,
60
60
  primary_ref: primary_ref,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.57.0"
5
+ VERSION = "0.58.0"
6
6
  end
7
7
  end
data/lib/ace/assign.rb CHANGED
@@ -60,6 +60,62 @@ module Ace
60
60
  class InvalidState < Error; end
61
61
  end
62
62
 
63
+ # Attempt/evidence-related errors (exit code 5)
64
+ module AttemptErrors
65
+ class InvalidTransition < Error
66
+ def initialize(message = "Illegal attempt state transition")
67
+ super(message, exit_code: 5)
68
+ end
69
+ end
70
+
71
+ class InvalidScope < Error
72
+ def initialize(message = "Invalid attempt scope")
73
+ super(message, exit_code: 5)
74
+ end
75
+ end
76
+
77
+ # Duplicate start with a conflicting binding for an owned subtree
78
+ class Conflict < Error
79
+ def initialize(message = "Conflicting active attempt for scope")
80
+ super(message, exit_code: 5)
81
+ end
82
+ end
83
+
84
+ class NotFound < Error
85
+ def initialize(message = "Attempt not found")
86
+ super(message, exit_code: 5)
87
+ end
88
+ end
89
+
90
+ # Untrusted or unknown execution identity; authority fails closed
91
+ class UnauthorizedIdentity < Error
92
+ def initialize(message = "Unknown or unauthorized execution identity")
93
+ super(message, exit_code: 5)
94
+ end
95
+ end
96
+
97
+ # Receipt failed verification (binding, head, digest, artifacts, review)
98
+ class ReceiptRejected < Error
99
+ def initialize(message = "Receipt rejected")
100
+ super(message, exit_code: 5)
101
+ end
102
+ end
103
+
104
+ # Attempt state forbids the requested effect (terminal, uncertain, blocked)
105
+ class InvalidState < Error
106
+ def initialize(message = "Attempt state forbids the requested operation")
107
+ super(message, exit_code: 5)
108
+ end
109
+ end
110
+
111
+ # Evidence storage is missing or unwritable; external effects are blocked
112
+ class EvidenceUnavailable < Error
113
+ def initialize(message = "Evidence storage unavailable")
114
+ super(message, exit_code: 5)
115
+ end
116
+ end
117
+ end
118
+
63
119
  # Define module namespaces
64
120
  module Commands; end
65
121