ace-assign 0.57.1 → 0.60.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +46 -12
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +49 -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/evidence.rb +28 -0
  12. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  13. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  14. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  15. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  16. data/lib/ace/assign/cli/commands/status.rb +24 -1
  17. data/lib/ace/assign/cli.rb +34 -2
  18. data/lib/ace/assign/models/assignment.rb +21 -4
  19. data/lib/ace/assign/models/attempt.rb +178 -0
  20. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  21. data/lib/ace/assign/models/evidence_event.rb +82 -0
  22. data/lib/ace/assign/models/execution_receipt.rb +139 -0
  23. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  24. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  25. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  26. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  27. data/lib/ace/assign/molecules/evidence_journal.rb +423 -0
  28. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  29. data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
  30. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
  31. data/lib/ace/assign/molecules/receipt_verifier.rb +272 -0
  32. data/lib/ace/assign/organisms/assignment_executor.rb +12 -4
  33. data/lib/ace/assign/organisms/attempt_coordinator.rb +618 -0
  34. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  35. data/lib/ace/assign/version.rb +1 -1
  36. data/lib/ace/assign.rb +56 -0
  37. metadata +36 -2
@@ -0,0 +1,618 @@
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
+ lifecycle_exclusion: nil)
31
+ @manager = Molecules::AssignmentManager.new(cache_base: cache_base)
32
+ @store = @manager.attempt_store
33
+ @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
34
+ @journal = journal
35
+ @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
36
+ @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
37
+ @lifecycle_exclusion = lifecycle_exclusion
38
+ end
39
+
40
+ attr_reader :store
41
+
42
+ # Start a scoped attempt for an assignment.
43
+ #
44
+ # A repeated identical start returns the existing active attempt; a
45
+ # conflicting or scope-overlapping binding raises
46
+ # {AttemptErrors::Conflict} without ever launching a second writer.
47
+ # Active attempts are resolved from the local store AND, for managed
48
+ # assignments, from the authoritative journal, so a lost local record
49
+ # cannot admit a competing writer.
50
+ #
51
+ # @param assignment_id [String] Assignment ID
52
+ # @param step [String] Step/subtree scope
53
+ # @param project_id [String] Project the attempt executes in
54
+ # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
55
+ # @return [Models::Attempt] Active attempt (reused, recovered, or newly reserved)
56
+ def start(assignment_id:, step:, project_id:, identity: nil)
57
+ assignment = @manager.load(assignment_id)
58
+ raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
59
+
60
+ scope = Atoms::AssignmentScope.canonicalize(step)
61
+ project = project_id.to_s.strip
62
+ raise Error, "attempt start requires --project ID" if project.empty?
63
+
64
+ identity ||= @identity_resolver.resolve
65
+ base_head = candidate_head!
66
+
67
+ # Serialize writer registration against prune: prune holds the
68
+ # exclusive exclusion from its final evidence reads through removal.
69
+ # A worktree-backed task identity shares the same protocol (task
70
+ # key first, then assignment key — the one consistent global
71
+ # order), so a worktree prune and an attempt start cannot interleave.
72
+ start_keys = []
73
+ unless assignment.task_id.to_s.strip.empty?
74
+ start_keys << lifecycle_exclusion.task_key(assignment.task_id)
75
+ end
76
+ start_keys << lifecycle_exclusion.assignment_key(assignment_id)
77
+ lifecycle_exclusion.with_shared_multi(start_keys) do
78
+ @store.with_lock(assignment_id) do
79
+ journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
80
+
81
+ existing = @store.active(assignment_id, scope) ||
82
+ journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
83
+ if existing
84
+ if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
85
+ existing.binding.actor == identity.actor && existing.binding.role == identity.role
86
+ return existing
87
+ end
88
+
89
+ raise AttemptErrors::Conflict,
90
+ "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
91
+ "(project #{existing.binding.project_id}); refusing to launch a second writer"
92
+ end
93
+
94
+ active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
95
+ active_scopes.concat(journal_actives.map(&:binding))
96
+ blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
97
+ if blocker
98
+ raise AttemptErrors::Conflict,
99
+ "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
100
+ "refusing overlapping writers"
101
+ end
102
+
103
+ attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
104
+ events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
105
+ attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
106
+ if attempt.managed?
107
+ commit = journal_for.append(
108
+ assignment_id: assignment_id,
109
+ attempt_id: attempt.attempt_id,
110
+ events: events
111
+ )
112
+ attempt = attempt.with(journal_commit: commit)
113
+ attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
114
+ else
115
+ attempt = attempt.with(events: events)
116
+ end
117
+
118
+ @store.save(attempt)
119
+ @store.claim(assignment_id, scope, attempt)
120
+ attempt
121
+ end
122
+ end
123
+ end
124
+
125
+ # Accept a receipt for an attempt and transition it.
126
+ #
127
+ # @param attempt_id [String] Attempt ID
128
+ # @param receipt_path [String] Path to the receipt JSON file
129
+ # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
130
+ # @return [Models::Attempt] Updated attempt
131
+ def finish(attempt_id:, receipt_path:, identity: nil)
132
+ data = read_receipt_file(receipt_path)
133
+ assignment_id = data["assignment_id"].to_s
134
+
135
+ # Serialize load-validate-accept so concurrent finishes observe one
136
+ # consistent state and cannot persist contradictory terminal outcomes.
137
+ @store.with_lock(assignment_id) do
138
+ attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
139
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
140
+
141
+ ensure_journal_consistent!(attempt) if attempt.managed?
142
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
143
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
144
+
145
+ identity ||= @identity_resolver.resolve
146
+ live_head = candidate_head!
147
+
148
+ attempt = invalidate_stale_candidate(attempt, live_head)
149
+
150
+ receipt = @verifier.verify!(
151
+ data,
152
+ attempt: attempt,
153
+ identity: identity,
154
+ live_head: live_head,
155
+ repo_root: @repo_root
156
+ )
157
+ if @verifier.external_effect?(receipt.operation)
158
+ unless attempt.managed?
159
+ raise AttemptErrors::InvalidState,
160
+ "Taskless attempts cannot record external effects without managed evidence"
161
+ end
162
+
163
+ require_review_evidence(attempt, live_head)
164
+ end
165
+
166
+ accept(attempt, receipt, live_head)
167
+ end
168
+ end
169
+
170
+ # Active-attempt projection for status surfaces.
171
+ #
172
+ # @param assignment_id [String] Assignment ID
173
+ # @return [Hash, nil] Projection fields or nil when no attempt exists
174
+ def status(assignment_id)
175
+ attempts = @store.list(assignment_id)
176
+ attempt = attempts.find(&:active?) || attempts.first
177
+ return nil unless attempt
178
+
179
+ attempt.projection
180
+ end
181
+
182
+ # Read accepted check evidence without transitions, locks, cache writes
183
+ # or audit checkout creation. Managed history comes from the Git ref.
184
+ def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
185
+ unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
186
+ raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
187
+ end
188
+ if !historical_head.nil? && (!%w[review-collection review-approval].include?(kind) || !historical_head.is_a?(String) ||
189
+ !historical_head.match?(/\A[0-9a-f]{40,64}\z/))
190
+ raise AttemptErrors::ReceiptRejected, "Historical evidence requires review collection/approval and an exact recorded head"
191
+ end
192
+ unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
193
+ receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
194
+ raise AttemptErrors::ReceiptRejected, "Invalid attempt ID or receipt digest"
195
+ end
196
+ attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
197
+ raise AttemptErrors::NotFound, "Attempt #{attempt_id} not found" unless attempt
198
+ raise AttemptErrors::ReceiptRejected, "Execution evidence requires managed immutable journal history" unless attempt.managed?
199
+ attempt = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
200
+ raise AttemptErrors::ReceiptRejected, "No succeeded accepted attempt" unless attempt&.state == "succeeded"
201
+ accepted = journal_for.accepted_receipts(attempt.binding.assignment_id)
202
+ data = accepted.find { |r| r["attempt_id"] == attempt_id && r["digest"] == receipt_digest }
203
+ raise AttemptErrors::ReceiptRejected, "Receipt was not accepted by coordinator" unless data
204
+ if kind == "check" && (%w[review review-collect].include?(data["operation"]) || @verifier.external_effect?(data["operation"]))
205
+ raise AttemptErrors::ReceiptRejected, "Review or external-effect operations cannot be check evidence"
206
+ end
207
+ expected_operation = case kind
208
+ when "review-collection" then "review-collect"
209
+ when "review-approval" then "review"
210
+ else check_name == "tests" ? "test" : check_name
211
+ end
212
+ unless data["campaign"].nil? && data["operation"] == expected_operation
213
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
214
+ 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" }
217
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
218
+ end
219
+ receipt = Models::ExecutionReceipt.from_h(data)
220
+ unless receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
221
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt digest is corrupt"
222
+ end
223
+ evidence_head = historical_head || candidate_head!
224
+ unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
225
+ raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
226
+ end
227
+ @verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root)
228
+ {"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
229
+ "historical" => !historical_head.nil?,
230
+ "operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
231
+ "artifacts" => data["artifacts"], "assignment_id" => data["assignment_id"],
232
+ "project_id" => data["project_id"], "scope" => data["scope"],
233
+ "evidence_git_ref" => attempt.binding.evidence_git_ref, "journal_commit" => attempt.journal_commit}
234
+ end
235
+
236
+ # Reconcile an interrupted attempt.
237
+ #
238
+ # Running attempts are classified conservatively (stopped before
239
+ # process start, uncertain when an effect cannot be proven either
240
+ # way, still running only for verifiably live processes). Uncertain
241
+ # attempts resolve only against a verified receipt attributed to the
242
+ # recorded execution boundary. Reconciliation never replays merge,
243
+ # publish, or deploy effects.
244
+ #
245
+ # @param attempt_id [String] Attempt ID
246
+ # @param receipt_path [String, nil] Receipt JSON for resolving uncertainty
247
+ # @param identity [ExecutionIdentityResolver::Identity, nil] Resolved when nil
248
+ # @return [Models::Attempt] Updated attempt
249
+ def reconcile(attempt_id:, receipt_path: nil, identity: nil)
250
+ probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
251
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
252
+
253
+ @store.with_lock(probe.binding.assignment_id) do
254
+ attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
255
+ recover_managed_attempt(probe.binding.assignment_id, attempt_id)
256
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
257
+
258
+ derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
259
+ # The journal is authoritative for managed attempts: uncertainty
260
+ # journaled after the local save drives reconciliation.
261
+ attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
262
+
263
+ if attempt.terminal?
264
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
265
+ end
266
+
267
+ return classify_running(attempt) if attempt.state == "running"
268
+
269
+ identity ||= @identity_resolver.resolve
270
+ resolve_uncertain(attempt, receipt_path, identity)
271
+ end
272
+ end
273
+
274
+ private
275
+
276
+ # Attempt IDs must be unique per assignment AND globally: records,
277
+ # journal events, and reconciliation lookups key on them. Allocation
278
+ # runs under the assignment lock, avoids journal-known IDs, and
279
+ # claims the ID atomically in the cross-assignment registry.
280
+ def unique_attempt_id(assignment_id, taken_ids = [])
281
+ base = Ace::B36ts.now
282
+ suffix = 0
283
+ 100.times do
284
+ candidate = suffix.zero? ? base : "#{base}#{suffix.to_s(36)}"
285
+ suffix += 1
286
+ next if taken_ids.include?(candidate)
287
+ next unless @store.load(assignment_id, candidate).nil?
288
+ return candidate if @store.reserve_id(candidate)
289
+ end
290
+
291
+ raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
292
+ end
293
+
294
+ # Ancestors and descendants overlap: an active attempt for 010 owns
295
+ # its 010.01 subtree, and vice versa.
296
+ def scopes_overlap?(left, right)
297
+ return true if Atoms::AssignmentScope.equal?(left, right)
298
+
299
+ left.start_with?("#{right}.") || right.start_with?("#{left}.")
300
+ end
301
+
302
+ # Active attempts derived from the authoritative journal, used when
303
+ # the local record may be missing (lost or wiped cache). Derivation
304
+ # lives in the journal (owner of attempt-state interpretation).
305
+ def active_journal_attempts(assignment)
306
+ journal_for.active_attempts(assignment.id)
307
+ end
308
+
309
+ # Recover a managed attempt from the journal when the local record is
310
+ # missing. With an assignment ID only that journal is consulted;
311
+ # without one, assignment IDs are discovered from the evidence ref
312
+ # itself, so a fully lost cache still reconciles.
313
+ def recover_managed_attempt(assignment_id, attempt_id)
314
+ if assignment_id
315
+ assignment = @manager.load(assignment_id)
316
+ # A surviving taskless record never consults the journal; a lost
317
+ # record falls through to journal evidence (only managed attempts
318
+ # are journaled).
319
+ return nil if assignment && !assignment.managed?
320
+
321
+ return journal_for.derived_attempts(assignment_id)
322
+ .find { |candidate| candidate.attempt_id == attempt_id }
323
+ end
324
+
325
+ journal_for.assignment_ids.each do |journal_assignment_id|
326
+ attempt = journal_for.derived_attempts(journal_assignment_id)
327
+ .find { |candidate| candidate.attempt_id == attempt_id }
328
+ return attempt if attempt
329
+ end
330
+
331
+ nil
332
+ end
333
+
334
+ # The journal is authoritative for managed attempts: a crash after a
335
+ # journaled terminal/uncertain event but before the local save must
336
+ # not admit contradictory transitions.
337
+ def ensure_journal_consistent!(attempt, allow_uncertain: false)
338
+ derived = journal_for.derived_attempts(attempt.binding.assignment_id)
339
+ .find { |candidate| candidate.attempt_id == attempt.attempt_id }
340
+ return if derived.nil?
341
+
342
+ journal_state = derived.state
343
+ if %w[succeeded failed stopped].include?(journal_state)
344
+ raise AttemptErrors::InvalidState,
345
+ "Journal shows attempt #{attempt.attempt_id} is #{journal_state}; accepted history is immutable"
346
+ end
347
+ return derived if allow_uncertain || journal_state != "uncertain" || attempt.uncertain?
348
+
349
+ raise AttemptErrors::InvalidState,
350
+ "Journal shows attempt #{attempt.attempt_id} is uncertain; reconcile before finishing"
351
+ end
352
+
353
+ # After appending our start events, revalidate ownership: a
354
+ # coordinator with a separate cache directory may have claimed the
355
+ # same scope between our read and our append (journal CAS replay
356
+ # merges both). The loser records a stopped transition and refuses.
357
+ def yield_lost_ownership_race(attempt, assignment_id, scope)
358
+ rival = journal_for.active_attempts(assignment_id).find do |other|
359
+ other.attempt_id != attempt.attempt_id && scopes_overlap?(other.binding.scope, scope)
360
+ end
361
+ return attempt if rival.nil?
362
+
363
+ stopped = append_events(attempt, [transition_event(attempt, "stopped", "lost ownership race to #{rival.attempt_id}")])
364
+ stopped = stopped.transition("stopped")
365
+ @store.save(stopped)
366
+ raise AttemptErrors::Conflict,
367
+ "Active attempt #{rival.attempt_id} owns overlapping scope #{rival.binding.scope} on #{assignment_id}; " \
368
+ "our start #{attempt.attempt_id} was recorded stopped without executing"
369
+ end
370
+
371
+ # Conservative classification of a running attempt after interruption.
372
+ def classify_running(attempt)
373
+ case reconciler.classify(attempt)
374
+ when :live
375
+ raise AttemptErrors::InvalidState,
376
+ "Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
377
+ when :stopped
378
+ attempt = append_events(attempt, [transition_event(attempt, "stopped", "interrupted before process start")])
379
+ attempt = attempt.transition("stopped")
380
+ @store.save(attempt)
381
+ @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id)
382
+ attempt
383
+ else
384
+ attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
385
+ attempt = attempt.transition("uncertain")
386
+ @store.save(attempt)
387
+ attempt
388
+ end
389
+ end
390
+
391
+ # Resolve an uncertain attempt against a verified, boundary-attributed
392
+ # receipt. Never resolves by assumption.
393
+ def resolve_uncertain(attempt, receipt_path, identity)
394
+ unless attempt.uncertain?
395
+ raise AttemptErrors::InvalidState,
396
+ "Attempt #{attempt.attempt_id} is #{attempt.state}; reconciliation targets uncertain attempts"
397
+ end
398
+ unless receipt_path
399
+ raise AttemptErrors::InvalidState,
400
+ "Reconciliation requires --receipt FILE; uncertain attempts are never resolved by assumption"
401
+ end
402
+
403
+ data = read_receipt_file(receipt_path)
404
+ live_head = candidate_head!
405
+ attempt = invalidate_stale_candidate(attempt, live_head)
406
+
407
+ recorded = reconciler.recorded_runtime(attempt)
408
+ unless data.dig("producer", "runtime") == recorded
409
+ raise AttemptErrors::ReceiptRejected,
410
+ "Receipt runtime #{data.dig('producer', 'runtime').inspect} does not match the recorded " \
411
+ "execution boundary #{recorded.inspect}"
412
+ end
413
+
414
+ receipt = @verifier.verify!(
415
+ data,
416
+ attempt: attempt,
417
+ identity: identity,
418
+ live_head: live_head,
419
+ repo_root: @repo_root
420
+ )
421
+ if @verifier.external_effect?(receipt.operation)
422
+ unless attempt.managed?
423
+ raise AttemptErrors::InvalidState,
424
+ "Taskless attempts cannot record external effects without managed evidence"
425
+ end
426
+
427
+ require_review_evidence(attempt, live_head)
428
+ end
429
+
430
+ reconciliation = Models::EvidenceEvent.build(
431
+ type: "reconciliation",
432
+ attempt_id: attempt.attempt_id,
433
+ payload: {"resolution" => receipt.verdict, "receipt_digest" => receipt.digest},
434
+ previous_digest: last_event_digest(attempt)
435
+ )
436
+ accept(attempt, receipt, live_head, [reconciliation])
437
+ end
438
+
439
+ def transition_event(attempt, to, reason)
440
+ Models::EvidenceEvent.build(
441
+ type: "transition",
442
+ attempt_id: attempt.attempt_id,
443
+ payload: {"from" => attempt.state, "to" => to, "reason" => reason},
444
+ previous_digest: last_event_digest(attempt)
445
+ )
446
+ end
447
+
448
+ def reconciler
449
+ @reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
450
+ end
451
+
452
+ def reserve(assignment, scope, project, identity, base_head, attempt_id) binding = Models::AttemptBinding.new(
453
+ attempt_id: attempt_id,
454
+ assignment_id: assignment.id,
455
+ scope: scope,
456
+ project_id: project,
457
+ task_id: assignment.task_id,
458
+ actor: identity.actor,
459
+ role: identity.role,
460
+ runtime: identity.runtime,
461
+ base_head: base_head,
462
+ evidence_git_ref: assignment.managed? ? journal_for.ref : nil,
463
+ created_at: Time.now.utc
464
+ )
465
+ Models::Attempt.new(binding: binding, state: "reserved").transition("running")
466
+ end
467
+
468
+ def start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
469
+ intent = Models::EvidenceEvent.build(
470
+ type: "intent",
471
+ attempt_id: attempt_id,
472
+ payload: {
473
+ "assignment_id" => assignment_id,
474
+ "scope" => scope,
475
+ "project_id" => project,
476
+ "task_id" => assignment.task_id,
477
+ "base_head" => base_head
478
+ }
479
+ )
480
+ process_start = Models::EvidenceEvent.build(
481
+ type: "process_start",
482
+ attempt_id: attempt_id,
483
+ payload: {
484
+ "actor" => identity.actor,
485
+ "role" => identity.role,
486
+ "runtime" => identity.runtime,
487
+ "pid" => Process.pid
488
+ },
489
+ previous_digest: intent["digest"]
490
+ )
491
+ [intent, process_start]
492
+ end
493
+
494
+ # A changed candidate SHA invalidates prior candidate evidence and
495
+ # authorizations; the change itself is journaled before anything else.
496
+ def invalidate_stale_candidate(attempt, live_head)
497
+ return attempt if attempt.candidate_head.nil? || attempt.candidate_head == live_head
498
+
499
+ event = Models::EvidenceEvent.build(
500
+ type: "candidate_invalidated",
501
+ attempt_id: attempt.attempt_id,
502
+ payload: {"previous_candidate_head" => attempt.candidate_head, "current_head" => live_head}
503
+ )
504
+ attempt = append_events(attempt, [event])
505
+ attempt = attempt.with(candidate_head: nil, accepted_receipts: [])
506
+ @store.save(attempt)
507
+ attempt
508
+ end
509
+
510
+ def accept(attempt, receipt, live_head, pre_events = [])
511
+ previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
512
+ event = Models::EvidenceEvent.build(
513
+ type: "receipt_accepted",
514
+ attempt_id: attempt.attempt_id,
515
+ payload: {"receipt" => receipt.to_h},
516
+ previous_digest: previous
517
+ )
518
+ attempt = append_events(attempt, pre_events + [event])
519
+
520
+ receipts = attempt.accepted_receipts + [receipt.to_h]
521
+ effects = attempt.effects
522
+ if @verifier.external_effect?(receipt.operation)
523
+ effects += [{"operation" => receipt.operation, "receipt_digest" => receipt.digest}]
524
+ end
525
+
526
+ attempt = attempt.with(
527
+ candidate_head: attempt.candidate_head || receipt.head,
528
+ accepted_receipts: receipts,
529
+ effects: effects,
530
+ state: attempt.state,
531
+ updated_at: Time.now.utc
532
+ )
533
+ attempt = attempt.transition(receipt.verdict) if %w[succeeded failed].include?(receipt.verdict)
534
+
535
+ @store.save(attempt)
536
+ @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
537
+
538
+ attempt
539
+ end
540
+
541
+ def require_review_evidence(attempt, live_head)
542
+ receipts = if attempt.managed?
543
+ journal_for.read_events(attempt.binding.assignment_id)
544
+ .select { |event| event["type"] == "receipt_accepted" }
545
+ .map { |event| event.dig("payload", "receipt") }
546
+ .compact
547
+ else
548
+ attempt.accepted_receipts
549
+ end
550
+ approved = receipts.any? do |candidate|
551
+ candidate["operation"] == Molecules::ReceiptVerifier::REVIEW_OPERATION &&
552
+ candidate["verdict"] == "succeeded" &&
553
+ candidate["head"] == live_head &&
554
+ candidate.dig("review", "verdict") == Molecules::ReceiptVerifier::APPROVED_VERDICT
555
+ end
556
+ return if approved
557
+
558
+ raise AttemptErrors::ReceiptRejected,
559
+ "External effect requires an executed independent review receipt for candidate #{live_head}"
560
+ end
561
+
562
+ def read_receipt_file(path)
563
+ raise AttemptErrors::ReceiptRejected, "Receipt file not found: #{path}" unless File.exist?(path.to_s)
564
+
565
+ data = JSON.parse(File.read(path))
566
+ raise AttemptErrors::ReceiptRejected, "Receipt must be a JSON object" unless data.is_a?(Hash)
567
+
568
+ data
569
+ rescue JSON::ParserError => e
570
+ raise AttemptErrors::ReceiptRejected, "Receipt file is not valid JSON: #{e.message}"
571
+ end
572
+
573
+ def append_events(attempt, events)
574
+ if attempt.managed?
575
+ commit = journal_for.append(
576
+ assignment_id: attempt.binding.assignment_id,
577
+ attempt_id: attempt.attempt_id,
578
+ events: events
579
+ )
580
+ attempt.with(journal_commit: commit)
581
+ else
582
+ attempt.with(events: attempt.events + events)
583
+ end
584
+ end
585
+
586
+ # Digest chain continuation: managed attempts chain through the
587
+ # authoritative journal; taskless attempts chain through the local
588
+ # event trail.
589
+ def last_event_digest(attempt)
590
+ if attempt.managed?
591
+ journal_for.read_events(attempt.binding.assignment_id).last&.dig("digest")
592
+ else
593
+ attempt.events.last&.dig("digest")
594
+ end
595
+ end
596
+
597
+ def journal_for
598
+ @journal ||= Molecules::EvidenceJournal.new(repo_root: @repo_root)
599
+ @journal
600
+ end
601
+
602
+ # Prune/writer exclusion shared with overseer prune (durable, outside
603
+ # every deletion target).
604
+ def lifecycle_exclusion
605
+ @lifecycle_exclusion ||= Molecules::LifecycleExclusion.new(repo_root: @repo_root)
606
+ end
607
+
608
+ def candidate_head!
609
+ out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
610
+ head = out.to_s.strip
611
+ return head if status.success? && !head.empty?
612
+
613
+ raise AttemptErrors::EvidenceUnavailable, "Cannot resolve candidate HEAD in #{@repo_root}"
614
+ end
615
+ end
616
+ end
617
+ end
618
+ 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.1"
5
+ VERSION = "0.60.0"
6
6
  end
7
7
  end